@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.45
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/common/bundle-sources.js +20 -1
- 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/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-5DHL5NG3.js +2 -0
- package/out/chunk-6HNXNIQY.js +52 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-AFLZH7WG.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HMAJDVXN.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-NPW52BM2.js +17 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-NV7ES4EW.js +1 -0
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PTGDYLNS.js +36 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-Q6MVYGYV.js +3 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-QRD6GIG5.js +229 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WI7Z5A2T.js +6 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/chunk-XVBEOGOS.js +3 -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 +51 -6
- package/risk/actions/findings.js +156 -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 +165 -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 +68 -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-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/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +62 -0
- package/server-managed/db-methods.ts +29 -10
- package/server-managed/db-neon.ts +30 -2
- package/server-managed/db-schema.ts +9 -2
- package/server-managed/db.ts +7 -0
- 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 +65 -15
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +23 -9
- package/server-managed/github-errors.ts +2 -0
- 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-users.ts +30 -0
- package/server-managed/http.ts +1122 -52
- package/server-managed/index.ts +6 -2
- 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 +173 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +51 -4
- package/server-managed/npm-loads.ts +20 -13
- package/server-managed/npm-packages.ts +24 -64
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +280 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/repository-actions.ts +209 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +54 -13
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +135 -0
- package/server-managed/repository-history.ts +445 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +432 -0
- package/server-managed/repository-risk-read.ts +165 -0
- package/server-managed/repository-risk.ts +113 -105
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +9 -5
- package/server-managed/sql.ts +1 -1
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/team-app.ts +19 -3
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +11 -0
- 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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// A repository's GitHub Actions in a risk scan (repository-risk.ts): its
|
|
2
|
+
// workflows and its own actions scanned (risk scanWorkflow), and the
|
|
3
|
+
// references they make resolved (risk resolveAction). Of a public
|
|
4
|
+
// repository, a reference resolved and what GitHub answers are kept for
|
|
5
|
+
// every scan, as many repositories use the same few actions.
|
|
6
|
+
import { type ActionsSource, type ResolvedAction, type ScanOptions, type WorkflowScan, dockerfileFindings, keptSource, ownFilesOf, resolveAction,
|
|
7
|
+
scanWorkflow, usesOfKey } from '../risk/index.js'
|
|
8
|
+
import type { OpenedRepository } from './repository-risk-read.ts'
|
|
9
|
+
import type { Lru } from './lru.ts'
|
|
10
|
+
import type { KeptStore } from './npm-kept.ts'
|
|
11
|
+
import { type Queue, queue } from './queue.ts'
|
|
12
|
+
import { RISK_KEPT_MS, type RiskFailure, failureOf } from './repository-risk-assess.ts'
|
|
13
|
+
|
|
14
|
+
export const WORKFLOWS_DIR = '.github/workflows'
|
|
15
|
+
// How many files of a repository's Actions are read, and how large.
|
|
16
|
+
const MAX_FILES = 200
|
|
17
|
+
const MAX_FILE_CHARS = 1024 * 1024
|
|
18
|
+
// How many references a scan resolves, how deep it follows what they name,
|
|
19
|
+
// and how many at once.
|
|
20
|
+
const MAX_ACTIONS = 1000
|
|
21
|
+
const MAX_DEPTH = 4
|
|
22
|
+
const RESOLVERS = 4
|
|
23
|
+
|
|
24
|
+
// A file of a repository's Actions: its text, or why it couldn't be had.
|
|
25
|
+
export type ActionsFile = { path: string; kind: 'workflow' | 'action' | 'dockerfile' } & ({ text: string } | RiskFailure)
|
|
26
|
+
export type WorkflowsRead = { files: ActionsFile[] } | RiskFailure
|
|
27
|
+
|
|
28
|
+
// What a scan tells of them: the workflows' paths, each file's scan, and
|
|
29
|
+
// each reference resolved, or why not.
|
|
30
|
+
export type WorkflowsLine = { type: 'workflows'; repo: number } & ({ paths: string[] } | RiskFailure)
|
|
31
|
+
export type WorkflowLine = { type: 'workflow'; repo: number; path: string; kind: ActionsFile['kind'] } & (WorkflowScan | RiskFailure)
|
|
32
|
+
export type ActionLine = { type: 'action' } & (ResolvedAction | { key: string } & RiskFailure)
|
|
33
|
+
|
|
34
|
+
// A reference to resolve, by the owner of the workflow naming it.
|
|
35
|
+
export interface ActionRef { key: string; owner: string | null; depth: number }
|
|
36
|
+
|
|
37
|
+
// What reading a repository's Actions asks of it.
|
|
38
|
+
export type ActionsReading = Required<Pick<OpenedRepository, 'workflows' | 'file'>>
|
|
39
|
+
|
|
40
|
+
// A file's text, or why not: one GitHub hasn't, or larger than read.
|
|
41
|
+
async function readFile(reading: ActionsReading, path: string): Promise<{ text: string } | RiskFailure> {
|
|
42
|
+
try {
|
|
43
|
+
const text = await reading.file(path)
|
|
44
|
+
return text.length > MAX_FILE_CHARS ? { error: 'workflow-too-large' } : { text }
|
|
45
|
+
} catch (error) { return failureOf(error) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Whether a read holds what any read of its commit would: no file that
|
|
49
|
+
// couldn't be had for a while, as GitHub's limits are.
|
|
50
|
+
export const lastingRead = (read: WorkflowsRead) => !('error' in read) && read.files.every(file => 'text' in file || file.error === 'workflow-too-large' || file.error === 'github-not-found')
|
|
51
|
+
|
|
52
|
+
// An action's directory, by its metadata's path.
|
|
53
|
+
const actionDir = (path: string) => path.replace(/\/?action\.ya?ml$/u, '')
|
|
54
|
+
const inRepository = (path: string) => !path.split('/').includes('..')
|
|
55
|
+
const notFound = (read: { text: string } | RiskFailure) => 'error' in read && read.error === 'github-not-found'
|
|
56
|
+
|
|
57
|
+
// A repository's workflows, the actions of its own they use in turn (`./`
|
|
58
|
+
// as the workspace has it, the repository checked out first) and those's
|
|
59
|
+
// Dockerfiles, MAX_FILES at most; why not, where they couldn't be listed.
|
|
60
|
+
export async function readActions(reading: ActionsReading): Promise<WorkflowsRead> {
|
|
61
|
+
let listed
|
|
62
|
+
try { listed = await reading.workflows() } catch (error) { return failureOf(error, 'listing its workflows') }
|
|
63
|
+
const files: ActionsFile[] = await Promise.all(listed.slice(0, MAX_FILES).map(async path => ({ path, kind: 'workflow' as const, ...await readFile(reading, path) })))
|
|
64
|
+
const seen = new Set<string>()
|
|
65
|
+
for (let at = 0; at < files.length; at++) {
|
|
66
|
+
const file = files[at]!
|
|
67
|
+
if (!('text' in file) || file.kind === 'dockerfile') continue
|
|
68
|
+
const { actions, dockerfile } = ownFilesOf(file.kind, file.text)
|
|
69
|
+
const path = dockerfile && [actionDir(file.path), dockerfile].filter(Boolean).join('/')
|
|
70
|
+
if (path && inRepository(path) && files.length < MAX_FILES) files.push({ path, kind: 'dockerfile', ...await readFile(reading, path) })
|
|
71
|
+
for (const dir of actions) {
|
|
72
|
+
if (files.length >= MAX_FILES || seen.has(dir) || !inRepository(dir) || !seen.add(dir)) continue
|
|
73
|
+
const named = dir === '' ? 'action' : `${dir}/action`, yml = await readFile(reading, `${named}.yml`)
|
|
74
|
+
const yaml = notFound(yml) ? await readFile(reading, `${named}.yaml`) : null
|
|
75
|
+
files.push(yaml && !notFound(yaml) ? { path: `${named}.yaml`, kind: 'action', ...yaml } : { path: `${named}.yml`, kind: 'action', ...yml })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { files }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A lockfile's packages, by name and by `name@version`, and those only
|
|
82
|
+
// devDependencies need.
|
|
83
|
+
export function lockedNames(path: string, packages: readonly { name: string; version: string | null; dev?: true }[]) {
|
|
84
|
+
const dev = new Map<string, boolean>()
|
|
85
|
+
for (const { name, version, dev: only } of packages) for (const key of version ? [name, `${name}@${version}`] : [name]) dev.set(key, (dev.get(key) ?? true) && only === true)
|
|
86
|
+
return { path, names: new Set(dev.keys()), dev: new Set([...dev].flatMap(([name, only]) => only ? [name] : [])) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The lines a repository's Actions read are told by, and the references
|
|
90
|
+
// they make to resolve. `lockfiles`, its lockfiles' packages' names by path.
|
|
91
|
+
export function actionsLines(repo: number, owner: string, read: WorkflowsRead, lockfiles: NonNullable<ScanOptions['lockfiles']>) {
|
|
92
|
+
if ('error' in read) return { lines: [{ type: 'workflows', repo, ...read } as WorkflowsLine], refs: [] as ActionRef[] }
|
|
93
|
+
const lines: (WorkflowsLine | WorkflowLine)[] = [{ type: 'workflows', repo, paths: read.files.filter(file => file.kind === 'workflow').map(file => file.path) }]
|
|
94
|
+
const refs = new Map<string, ActionRef>()
|
|
95
|
+
const actions = new Map(read.files.flatMap(file => file.kind === 'action' && 'text' in file ? [[actionDir(file.path), file.text] as const] : []))
|
|
96
|
+
for (const file of read.files) {
|
|
97
|
+
const scan = 'error' in file ? file : file.kind === 'dockerfile' ? { uses: [], findings: dockerfileFindings(file.text) } : scanWorkflow(file.kind, file.text, { owner, lockfiles, actions })
|
|
98
|
+
lines.push({ type: 'workflow', repo, path: file.path, kind: file.kind, ...scan })
|
|
99
|
+
if ('uses' in scan) for (const uses of scan.uses) if (uses.kind === 'action' || uses.kind === 'workflow') refs.set(uses.key!, { key: uses.key!, owner, depth: 0 })
|
|
100
|
+
}
|
|
101
|
+
return { lines, refs: [...refs.values()] }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type ActionAnswers = Lru<{ at: number; answer: unknown }>
|
|
105
|
+
// An answer's weight: an entry of a list, a kilobyte of a file.
|
|
106
|
+
export const answerWeight = ({ answer }: { at: number; answer: unknown }) => Array.isArray(answer) ? answer.length + 1
|
|
107
|
+
: typeof answer === 'string' ? Math.ceil(answer.length / 1024) + 1 : ((answer as { branches?: unknown[] } | null)?.branches?.length ?? 0) + 1
|
|
108
|
+
|
|
109
|
+
const isCommit = (text: string) => /^[\da-f]{40}$/u.test(text)
|
|
110
|
+
|
|
111
|
+
// `source`, what it answers of a public repository kept for every scan, the
|
|
112
|
+
// same whoever asks: of a commit while kept (but none, as one can be pushed
|
|
113
|
+
// later), the rest for RISK_KEPT_MS. A private one's, or one missing, never.
|
|
114
|
+
export function sharedSource(source: ActionsSource, answers: ActionAnswers): ActionsSource {
|
|
115
|
+
const keyOf = (what: string, name: string, ...more: string[]) => [what, name.toLowerCase(), ...more].join('\0')
|
|
116
|
+
const kept = (key: string, lasting = false) => {
|
|
117
|
+
const at = answers.get(key)
|
|
118
|
+
return at && ((lasting && at.answer !== null) || Date.now() - at.at < RISK_KEPT_MS) ? at : undefined
|
|
119
|
+
}
|
|
120
|
+
async function shared<T>(name: string, key: string, lasting: boolean, ask: () => Promise<T>): Promise<T> {
|
|
121
|
+
if (!kept(keyOf('repo', name))) return ask()
|
|
122
|
+
const at = kept(key, lasting)
|
|
123
|
+
if (at) return at.answer as T
|
|
124
|
+
const answer = await ask()
|
|
125
|
+
answers.set(key, { at: Date.now(), answer })
|
|
126
|
+
return answer
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
async repo(name) {
|
|
130
|
+
const at = kept(keyOf('repo', name))
|
|
131
|
+
if (at) return at.answer as Awaited<ReturnType<ActionsSource['repo']>>
|
|
132
|
+
const info = await source.repo(name)
|
|
133
|
+
if (info?.private === false) for (const each of [name, info.fullName]) answers.set(keyOf('repo', each), { at: Date.now(), answer: info })
|
|
134
|
+
return info
|
|
135
|
+
},
|
|
136
|
+
tags: name => shared(name, keyOf('tags', name), false, () => source.tags(name)),
|
|
137
|
+
tag: (name, tag) => shared(name, keyOf('tag', name, tag), false, () => source.tag(name, tag)),
|
|
138
|
+
branch: (name, branch) => shared(name, keyOf('branch', name, branch), false, () => source.branch(name, branch)),
|
|
139
|
+
branches: name => shared(name, keyOf('branches', name), false, () => source.branches(name)),
|
|
140
|
+
contains: (name, base, sha) => shared(name, keyOf('contains', name, base, sha), isCommit(base), () => source.contains(name, base, sha)),
|
|
141
|
+
file: (name, path, commit) => shared(name, keyOf('file', name, path, commit), isCommit(commit), () => source.file(name, path, commit)),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ActionsResolving {
|
|
146
|
+
source: ActionsSource
|
|
147
|
+
signal: AbortSignal
|
|
148
|
+
tell(line: ActionLine): void
|
|
149
|
+
memory: Lru<{ at: number; line: ActionLine }>
|
|
150
|
+
answers: ActionAnswers
|
|
151
|
+
store?: KeptStore<ActionLine> | undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A reference's line: kept, else resolved, and kept where it is a public
|
|
155
|
+
// repository's; why not, where it couldn't be.
|
|
156
|
+
async function resolved(resolving: ActionsResolving, source: ActionsSource, { key, owner }: ActionRef): Promise<ActionLine | null> {
|
|
157
|
+
const kept = resolving.memory.get(key) ?? await resolving.store?.read(key).then(row => row && { at: row.at, line: row.value })
|
|
158
|
+
if (kept && Date.now() - kept.at < RISK_KEPT_MS) { resolving.memory.set(key, kept); return kept.line }
|
|
159
|
+
const uses = usesOfKey(key)
|
|
160
|
+
if (!uses) return { type: 'action', key, error: 'invalid-uses' }
|
|
161
|
+
let found: ResolvedAction
|
|
162
|
+
try { found = await resolveAction(uses, source, { owner }) } catch (error) { return resolving.signal.aborted ? null : { type: 'action', key, ...failureOf(error) } }
|
|
163
|
+
const line: ActionLine = { type: 'action', ...found }
|
|
164
|
+
if (found.repository?.private === false) {
|
|
165
|
+
const at = Date.now()
|
|
166
|
+
resolving.memory.set(key, { at, line })
|
|
167
|
+
await resolving.store?.write(key, line, at)
|
|
168
|
+
}
|
|
169
|
+
return line
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Whether a reference is named by a workflow of its repository's owner.
|
|
173
|
+
const ownOwners = ({ key, owner }: ActionRef) => key.split('/')[0] === owner?.toLowerCase()
|
|
174
|
+
|
|
175
|
+
// Resolves the references added, and those they name in turn, each once a
|
|
176
|
+
// scan, RESOLVERS at once, each told as it is; once more where another
|
|
177
|
+
// owner's workflow names one its own owner's did, as a repository missing is
|
|
178
|
+
// red then, or one is met nearer, its own followed as far as they then go. `close` settles once all are told, or the scan stops.
|
|
179
|
+
export function resolvingActions(resolving: ActionsResolving) {
|
|
180
|
+
const source = keptSource(sharedSource(resolving.source, resolving.answers))
|
|
181
|
+
const pending: Queue<ActionRef> = queue(resolving.signal)
|
|
182
|
+
const latest = new Map<string, ActionRef>()
|
|
183
|
+
let closed = false, waiting = 0
|
|
184
|
+
const settle = () => { if (closed && waiting === 0) pending.close() }
|
|
185
|
+
const add = (refs: ActionRef[]) => {
|
|
186
|
+
for (const ref of refs) {
|
|
187
|
+
const first = latest.get(ref.key)
|
|
188
|
+
if (ref.depth > MAX_DEPTH || (first ? (!ownOwners(first) || ownOwners(ref)) && first.depth <= ref.depth : latest.size >= MAX_ACTIONS)) continue
|
|
189
|
+
const kept = first ? { ...ref, owner: ownOwners(first) ? ref.owner : first.owner, depth: Math.min(ref.depth, first.depth) } : ref
|
|
190
|
+
latest.set(ref.key, kept)
|
|
191
|
+
waiting++
|
|
192
|
+
pending.add(kept)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const work = async () => {
|
|
196
|
+
for (let ref = await pending.take(); ref !== undefined; ref = await pending.take()) {
|
|
197
|
+
try {
|
|
198
|
+
const line = await resolved(resolving, source, ref)
|
|
199
|
+
if (line && latest.get(ref.key) === ref) {
|
|
200
|
+
resolving.tell(line)
|
|
201
|
+
const owner = 'repository' in line ? (line.repository?.fullName ?? line.repo).split('/')[0]! : ref.owner
|
|
202
|
+
if ('uses' in line) add(line.uses.map(key => ({ key, owner, depth: ref.depth + 1 })))
|
|
203
|
+
}
|
|
204
|
+
} finally { waiting--; settle() }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const workers = Array.from({ length: RESOLVERS }, work)
|
|
208
|
+
return { add, async close() { closed = true; settle(); await Promise.all(workers) } }
|
|
209
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// What the code viewer's Agents tab reconstructs agents' sessions
|
|
2
|
+
// from (GET /api/admin/repositories/agents, common/managed/agent-tasks.ts):
|
|
3
|
+
// a page of the repository's pull requests, the most recently updated first,
|
|
4
|
+
// and of its default branch's commits, the newest first, those no pull
|
|
5
|
+
// request merged; on the first page, its latest issues and the newest of
|
|
6
|
+
// its agents' branches (GitHub lists branches by name alone, so every one an
|
|
7
|
+
// agent's prefix names is read, as many as fifty pages of each, and the newest
|
|
8
|
+
// kept). Each artifact's own texts (never comments: an agent links its
|
|
9
|
+
// session in a pull request's body and its commits' messages) are read here
|
|
10
|
+
// for the marks an agent leaves, and a pull request's labels for one naming
|
|
11
|
+
// an agent, and those alone are answered, never the texts. GraphQL alone
|
|
12
|
+
// gives them, so it needs a token.
|
|
13
|
+
import { type AgentAuthor, type AgentBranch, type AgentCommit, type AgentIssue, type AgentPull, type AppAgent, agentSignals } from '../common/managed/agent-tasks.ts'
|
|
14
|
+
import { AGENT_BRANCH_PREFIXES, APP_AGENTS } from './app-agents.ts'
|
|
15
|
+
import { GithubApiError } from './github-app.ts'
|
|
16
|
+
import { ACTOR, type NamedAccount, actorOf, commitAccount, githubGraphql } from './repository-history.ts'
|
|
17
|
+
import { ISSUES_CURSOR } from './repository-issues.ts'
|
|
18
|
+
|
|
19
|
+
export { APP_AGENTS }
|
|
20
|
+
|
|
21
|
+
export const AGENT_PULLS_PAGE = 50
|
|
22
|
+
export const AGENT_COMMITS_PAGE = 100
|
|
23
|
+
const AGENT_ISSUES = 100
|
|
24
|
+
const AGENT_BRANCHES = 500
|
|
25
|
+
const MAX_BRANCH_PAGES = 50
|
|
26
|
+
const CLOSES = 5
|
|
27
|
+
// A pull request's labels read, for one naming an agent: GitHub's most a
|
|
28
|
+
// page.
|
|
29
|
+
const LABELS = 100
|
|
30
|
+
const MAX_TITLE = 1000
|
|
31
|
+
const MAX_NAME = 256
|
|
32
|
+
const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
|
|
33
|
+
const SHA = /^[a-f\d]{40}$/u
|
|
34
|
+
// The address Claude's own commits are authored with.
|
|
35
|
+
const CLAUDE_EMAIL = 'noreply@anthropic.com'
|
|
36
|
+
|
|
37
|
+
// The app agents' accounts' no-reply addresses, by each login of theirs.
|
|
38
|
+
const AGENT_EMAILS = APP_AGENTS.flatMap(({ account }) => account?.logins.map(login => `${account.id}+${login}@users.noreply.github.com`) ?? [])
|
|
39
|
+
|
|
40
|
+
// An agent's branches, a page at a time, as GitHub lists them, by name;
|
|
41
|
+
// each its tip commit's time, message and author.
|
|
42
|
+
const BRANCHES_QUERY = `query($owner: String!, $name: String!, $prefix: String!, $after: String) {
|
|
43
|
+
repository(owner: $owner, name: $name) {
|
|
44
|
+
refs(refPrefix: $prefix, first: 100, after: $after) {
|
|
45
|
+
pageInfo { hasNextPage endCursor }
|
|
46
|
+
nodes { prefix name target { ... on Commit { committedDate message author { email user { login databaseId } } } } }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}`
|
|
50
|
+
// What a commit of the default branch is read by.
|
|
51
|
+
const COMMIT_FIELDS = 'oid messageHeadline message committedDate additions deletions author { email user { login databaseId } } associatedPullRequests(first: 1) { totalCount }'
|
|
52
|
+
// The newest of the default branch's commits the app agents' accounts
|
|
53
|
+
// authored, by their GitHub no-reply addresses, however far back: a bot's
|
|
54
|
+
// commits pushed with no pull request here (as before a repository moved)
|
|
55
|
+
// may lie deeper than pages are read on to.
|
|
56
|
+
const AUTHORED_QUERY = `query($owner: String!, $name: String!, $emails: [String!]!) {
|
|
57
|
+
repository(owner: $owner, name: $name) {
|
|
58
|
+
defaultBranchRef { target { ... on Commit { history(first: ${AGENT_COMMITS_PAGE}, author: { emails: $emails }) { nodes { ${COMMIT_FIELDS} } } } } }
|
|
59
|
+
}
|
|
60
|
+
}`
|
|
61
|
+
const PAGE_QUERY = `query($owner: String!, $name: String!, $pulls: String, $commits: String, $first: Boolean!, $readPulls: Boolean!, $readCommits: Boolean!) {
|
|
62
|
+
repository(owner: $owner, name: $name) {
|
|
63
|
+
pullRequests(first: ${AGENT_PULLS_PAGE}, after: $pulls, orderBy: { field: UPDATED_AT, direction: DESC }) @include(if: $readPulls) {
|
|
64
|
+
pageInfo { hasNextPage endCursor }
|
|
65
|
+
nodes { number title state isDraft createdAt updatedAt author { ${ACTOR} } body headRefName isCrossRepository headRepositoryOwner { login } additions deletions
|
|
66
|
+
commits(last: 1) { totalCount nodes { commit { message } } }
|
|
67
|
+
mergeCommit { message }
|
|
68
|
+
closingIssuesReferences(first: ${CLOSES}) { nodes { number } }
|
|
69
|
+
labels(first: ${LABELS}) { nodes { name } } }
|
|
70
|
+
}
|
|
71
|
+
defaultBranchRef { target { ... on Commit {
|
|
72
|
+
history(first: ${AGENT_COMMITS_PAGE}, after: $commits) @include(if: $readCommits) {
|
|
73
|
+
pageInfo { hasNextPage endCursor }
|
|
74
|
+
nodes { ${COMMIT_FIELDS} }
|
|
75
|
+
}
|
|
76
|
+
} } }
|
|
77
|
+
issues(first: ${AGENT_ISSUES}, orderBy: { field: UPDATED_AT, direction: DESC }) @include(if: $first) {
|
|
78
|
+
nodes { number title state createdAt updatedAt author { ${ACTOR} } }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}`
|
|
82
|
+
|
|
83
|
+
type Login = { login?: unknown; databaseId?: unknown } | null | undefined
|
|
84
|
+
type PullNode = { number?: unknown; title?: unknown; state?: unknown; isDraft?: unknown; createdAt?: unknown; updatedAt?: unknown; author?: Login; body?: unknown
|
|
85
|
+
headRefName?: unknown; isCrossRepository?: unknown; headRepositoryOwner?: Login; additions?: unknown; deletions?: unknown; commits?: { totalCount?: unknown; nodes?: ({ commit?: { message?: unknown } | null } | null)[] }
|
|
86
|
+
mergeCommit?: { message?: unknown } | null; closingIssuesReferences?: { nodes?: ({ number?: unknown } | null)[] }; labels?: { nodes?: ({ name?: unknown } | null)[] } }
|
|
87
|
+
type GitActor = { email?: unknown; user?: Login }
|
|
88
|
+
type CommitNode = { oid?: unknown; messageHeadline?: unknown; message?: unknown; committedDate?: unknown; additions?: unknown; deletions?: unknown
|
|
89
|
+
author?: GitActor | null; associatedPullRequests?: { totalCount?: unknown } }
|
|
90
|
+
type IssueNode = { number?: unknown; title?: unknown; state?: unknown; createdAt?: unknown; updatedAt?: unknown; author?: Login }
|
|
91
|
+
type RefNode = { prefix?: unknown; name?: unknown; target?: { committedDate?: unknown; message?: unknown; author?: GitActor | null } | null }
|
|
92
|
+
type Connection<T> = { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (T | null)[] }
|
|
93
|
+
type Page = { repository?: { pullRequests?: Connection<PullNode>; defaultBranchRef?: { target?: { history?: Connection<CommitNode> } | null } | null
|
|
94
|
+
issues?: Connection<IssueNode> } | null }
|
|
95
|
+
type RefsPage = { repository?: { refs?: Connection<RefNode> } | null }
|
|
96
|
+
|
|
97
|
+
const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
|
|
98
|
+
const number = (value: unknown) => Number.isSafeInteger(value) && (value as number) > 0 ? value as number : null
|
|
99
|
+
const timeOf = (value: unknown) => {
|
|
100
|
+
const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
|
|
101
|
+
return Number.isSafeInteger(at) ? at : null
|
|
102
|
+
}
|
|
103
|
+
const nameOf = (value: unknown, max = MAX_NAME) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null
|
|
104
|
+
const loginOf = (author: Login) => typeof author?.login === 'string' && LOGIN.test(author.login) ? author.login : null
|
|
105
|
+
const PULL_STATES: Record<string, AgentPull['state']> = { OPEN: 'open', MERGED: 'merged', CLOSED: 'closed' }
|
|
106
|
+
|
|
107
|
+
function pull(node: PullNode | null): AgentPull | null {
|
|
108
|
+
const at = number(node?.number)
|
|
109
|
+
if (!node || !at) return null
|
|
110
|
+
const author = accountOf(node.author)
|
|
111
|
+
return { number: at, title: nameOf(node.title, MAX_TITLE) ?? '', state: PULL_STATES[String(node.state)] ?? 'closed', draft: node.isDraft === true,
|
|
112
|
+
author: loginOf(node.author), authorId: author?.id ?? null, createdAt: timeOf(node.createdAt), updatedAt: timeOf(node.updatedAt), head: nameOf(node.headRefName),
|
|
113
|
+
// A fork's, its owner; one GitHub no longer names, a fork of its own.
|
|
114
|
+
fork: node.isCrossRepository === true ? loginOf(node.headRepositoryOwner) ?? `pull-${at}` : null,
|
|
115
|
+
additions: count(node.additions), deletions: count(node.deletions), commits: count(node.commits?.totalCount),
|
|
116
|
+
closes: (node.closingIssuesReferences?.nodes ?? []).flatMap(closed => number(closed?.number) ?? []),
|
|
117
|
+
signals: agentSignals([node.body, node.commits?.nodes?.[0]?.commit?.message, node.mergeCommit?.message], APP_AGENTS, nameOf(node.headRefName),
|
|
118
|
+
author, (node.labels?.nodes ?? []).map(label => label?.name)) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// An account as GitHub names it, by its login and id, both.
|
|
122
|
+
const both = ({ login, id }: NamedAccount): AgentAuthor | null => login !== null && id !== null ? { login, id } : null
|
|
123
|
+
const accountOf = (actor: Login) => both(actorOf(actor))
|
|
124
|
+
// A commit's author: the account GitHub knows by its address, else the one
|
|
125
|
+
// its no-reply address names by id and login, as a bot's, Copilot's or
|
|
126
|
+
// Dependabot's, is told by that alone (repository-history.ts commitAccount).
|
|
127
|
+
const authorOf = (actor: GitActor | null | undefined) => both(commitAccount(actor))
|
|
128
|
+
|
|
129
|
+
// A commit to the default branch no pull request merged, where an agent left
|
|
130
|
+
// its marks on it, or Claude authored it.
|
|
131
|
+
function directCommit(node: CommitNode | null): AgentCommit | null {
|
|
132
|
+
if (!node || typeof node.oid !== 'string' || !SHA.test(node.oid) || count(node.associatedPullRequests?.totalCount) > 0) return null
|
|
133
|
+
const author = authorOf(node.author)
|
|
134
|
+
const signals = agentSignals([node.message], APP_AGENTS, null, author)
|
|
135
|
+
if (node.author?.email === CLAUDE_EMAIL) signals.claude = true
|
|
136
|
+
if (!signals.claude && !signals.codex && signals.agents.length === 0 && signals.sessions.length === 0 && signals.tasks.length === 0) return null
|
|
137
|
+
return { oid: node.oid, title: nameOf(node.messageHeadline, MAX_TITLE) ?? '', at: timeOf(node.committedDate),
|
|
138
|
+
author: author?.login ?? loginOf(node.author?.user), authorId: author?.id ?? null,
|
|
139
|
+
additions: count(node.additions), deletions: count(node.deletions), signals }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function issue(node: IssueNode | null): AgentIssue | null {
|
|
143
|
+
const at = number(node?.number)
|
|
144
|
+
if (!node || !at) return null
|
|
145
|
+
return { number: at, title: nameOf(node.title, MAX_TITLE) ?? '', state: node.state === 'OPEN' ? 'open' : 'closed', author: loginOf(node.author),
|
|
146
|
+
authorId: accountOf(node.author)?.id ?? null, createdAt: timeOf(node.createdAt), updatedAt: timeOf(node.updatedAt) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A branch under `prefix`, an agent's, named in full however GitHub splits
|
|
150
|
+
// its name from the prefix it was listed under.
|
|
151
|
+
const branch = (prefix: string) => (node: RefNode | null): AgentBranch | null => {
|
|
152
|
+
const ref = typeof node?.prefix === 'string' && typeof node.name === 'string' ? `${node.prefix}${node.name}` : null
|
|
153
|
+
const name = ref?.startsWith('refs/heads/') ? nameOf(ref.slice('refs/heads/'.length)) : null
|
|
154
|
+
const author = authorOf(node?.target?.author)
|
|
155
|
+
return name?.startsWith(prefix) ? { name, at: timeOf(node?.target?.committedDate), author: author?.login ?? loginOf(node?.target?.author?.user),
|
|
156
|
+
authorId: author?.id ?? null, signals: agentSignals([node?.target?.message], APP_AGENTS, name, author) } : null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The newest of an agent's branches, those under `prefix`: every one, as many
|
|
160
|
+
// as MAX_BRANCH_PAGES of them list, kept by their tips' times.
|
|
161
|
+
async function agentBranches(owner: string, name: string, prefix: string, token: string, fetchImpl: typeof fetch) {
|
|
162
|
+
const found: AgentBranch[] = []
|
|
163
|
+
let after: string | null = null
|
|
164
|
+
for (let page = 0; page < MAX_BRANCH_PAGES; page++) {
|
|
165
|
+
const refs: Connection<RefNode> | undefined = (await githubGraphql(BRANCHES_QUERY, { owner, name, prefix: `refs/heads/${prefix}`, after }, token, fetchImpl) as RefsPage)
|
|
166
|
+
?.repository?.refs
|
|
167
|
+
found.push(...(refs?.nodes ?? []).flatMap(node => branch(prefix)(node) ?? []))
|
|
168
|
+
const next: Connection<RefNode>['pageInfo'] = refs?.pageInfo
|
|
169
|
+
if (next?.hasNextPage !== true || typeof next.endCursor !== 'string') break
|
|
170
|
+
after = next.endCursor
|
|
171
|
+
}
|
|
172
|
+
return found.toSorted((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, AGENT_BRANCHES)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// What its answers are kept by, with their cursors (http.ts
|
|
176
|
+
// handleRepositoryAgents): bumped as what a page reads, or how it tells an
|
|
177
|
+
// agent, changes, so that no answer an older server kept is sent for a day.
|
|
178
|
+
export const AGENTS_READ_VERSION = 5
|
|
179
|
+
|
|
180
|
+
// A cursor of the default branch's commits: GitHub names one by a commit
|
|
181
|
+
// and how many on from it, `<oid> <offset>`, a space between.
|
|
182
|
+
export const COMMITS_CURSOR = /^(?:[!-~]{1,256}|[\da-f]{40} \d{1,9})$/u
|
|
183
|
+
|
|
184
|
+
const nextCursor = (connection: Connection<unknown> | undefined, cursor = ISSUES_CURSOR) => {
|
|
185
|
+
const info = connection?.pageInfo
|
|
186
|
+
return info?.hasNextPage === true && typeof info.endCursor === 'string' && cursor.test(info.endCursor) ? info.endCursor : null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A page of what the repository's agents left: its pull requests on from
|
|
190
|
+
// the cursor `pulls` ends at, its default branch's commits on from
|
|
191
|
+
// `commits` (the first page where neither is named, with its issues, its
|
|
192
|
+
// agents' branches and the commits the app agents' accounts authored, and
|
|
193
|
+
// the app agents it may name), and the cursors reading on from each, null
|
|
194
|
+
// where either is read whole.
|
|
195
|
+
export async function repositoryAgents(fullName: string, token: string, cursors: { pulls: string | null; commits: string | null },
|
|
196
|
+
fetchImpl: typeof fetch = globalThis.fetch) {
|
|
197
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
198
|
+
const first = cursors.pulls === null && cursors.commits === null
|
|
199
|
+
const [data, branches, authored] = await Promise.all([
|
|
200
|
+
githubGraphql(PAGE_QUERY, { owner, name, pulls: cursors.pulls, commits: cursors.commits, first,
|
|
201
|
+
readPulls: first || cursors.pulls !== null, readCommits: first || cursors.commits !== null }, token, fetchImpl) as Promise<Page>,
|
|
202
|
+
first ? Promise.all(AGENT_BRANCH_PREFIXES.map(prefix => agentBranches(owner, name, prefix, token, fetchImpl))) : [],
|
|
203
|
+
first ? githubGraphql(AUTHORED_QUERY, { owner, name, emails: AGENT_EMAILS }, token, fetchImpl) as Promise<Page> : null,
|
|
204
|
+
])
|
|
205
|
+
const repository = data?.repository
|
|
206
|
+
if (!repository) throw new GithubApiError(404, 'github-not-found')
|
|
207
|
+
const history = repository.defaultBranchRef?.target?.history
|
|
208
|
+
const after = { pulls: nextCursor(repository.pullRequests), commits: nextCursor(history, COMMITS_CURSOR) }
|
|
209
|
+
const commits = [...history?.nodes ?? [], ...authored?.repository?.defaultBranchRef?.target?.history?.nodes ?? []].flatMap(node => directCommit(node) ?? [])
|
|
210
|
+
return {
|
|
211
|
+
pulls: (repository.pullRequests?.nodes ?? []).flatMap(node => pull(node) ?? []),
|
|
212
|
+
commits: commits.filter((commit, index) => commits.findIndex(other => other.oid === commit.oid) === index),
|
|
213
|
+
...first ? {
|
|
214
|
+
issues: (repository.issues?.nodes ?? []).flatMap(node => issue(node) ?? []),
|
|
215
|
+
branches: branches.flat(),
|
|
216
|
+
agents: APP_AGENTS.map(({ id, name: agent, avatar }): AppAgent => ({ id, name: agent, ...avatar ? { avatar } : {} })),
|
|
217
|
+
} : {},
|
|
218
|
+
more: after.pulls !== null || after.commits !== null, after,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import type { ManagedConfig } from './config.ts'
|
|
2
2
|
import type { SelectedRepo } from './db.ts'
|
|
3
|
-
import { GithubApiError, githubJson,
|
|
3
|
+
import { GithubApiError, type GithubIdentity, READ_PERMISSIONS, githubJson, githubRepoPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
|
|
4
4
|
import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
|
|
5
5
|
import { readSolidityEntryPoints } from './solidity-entry-points.ts'
|
|
6
6
|
import { parseGithubCommit } from './bundle-commits.ts'
|
|
7
|
+
import { appDependabotAlerts } from './dependabot-access.ts'
|
|
7
8
|
|
|
8
9
|
const REFS_PAGE = 100
|
|
9
10
|
|
|
11
|
+
// Each branch or tag GitHub lists, by its name and commit, those it names
|
|
12
|
+
// one for.
|
|
13
|
+
const refCommits = (items: { name?: unknown; commit?: { sha?: unknown } }[]) => items.flatMap(item => typeof item?.name === 'string' && item.name
|
|
14
|
+
&& typeof item.commit?.sha === 'string' && /^[a-f\d]{40}$/u.test(item.commit.sha) ? [{ name: item.name, sha: item.commit.sha }] : [])
|
|
15
|
+
|
|
16
|
+
// Each branch GitHub lists, by its name and commit. Branches are listed by
|
|
17
|
+
// name, a full page of them leaving the default branch out where many come
|
|
18
|
+
// before it: its commit is read apart then, by its ref, none where that fails.
|
|
19
|
+
async function branchCommits(read: (suffix: string) => Promise<unknown>, branches: { name?: unknown; commit?: { sha?: unknown } }[], defaultBranch: string) {
|
|
20
|
+
const listed = refCommits(branches)
|
|
21
|
+
if (!defaultBranch || branches.length < REFS_PAGE || listed.some(item => item.name === defaultBranch)) return listed
|
|
22
|
+
const ref = await read(`/git/ref/heads/${defaultBranch.split('/').map(encodeURIComponent).join('/')}`).catch(() => null) as { object?: { sha?: unknown } } | null
|
|
23
|
+
return typeof ref?.object?.sha === 'string' && /^[a-f\d]{40}$/u.test(ref.object.sha) ? [...listed, { name: defaultBranch, sha: ref.object.sha }] : listed
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
|
|
11
27
|
|
|
12
28
|
// Restricted managers can walk the ancestors of their grants, without seeing
|
|
@@ -19,7 +35,6 @@ export function scopedDirectory(path: string, scopes: (string | null)[]): Reposi
|
|
|
19
35
|
}
|
|
20
36
|
|
|
21
37
|
export interface RepositoryBrowserUser { githubUserId: number | null; token: string | null }
|
|
22
|
-
type GithubIdentity = Awaited<ReturnType<typeof githubUserIdentity>>
|
|
23
38
|
|
|
24
39
|
function credentialFailure(err: unknown): err is GithubApiError {
|
|
25
40
|
return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
|
|
@@ -53,16 +68,40 @@ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryB
|
|
|
53
68
|
if (!cached) tokens.set(repo.installationId, token)
|
|
54
69
|
return token
|
|
55
70
|
}
|
|
56
|
-
|
|
71
|
+
// Its Dependabot alerts, with the token of the installation GitHub tells
|
|
72
|
+
// covers it, where the user's own access lets them see them
|
|
73
|
+
// (dependabot-access.ts).
|
|
74
|
+
return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl).then(reader => ({ ...reader,
|
|
75
|
+
dependabotAlerts: () => appDependabotAlerts(config, repo, { identity: getIdentity, permission: reader.permission }, fetchImpl) }))
|
|
57
76
|
},
|
|
58
77
|
}
|
|
59
78
|
}
|
|
60
79
|
|
|
80
|
+
// The files of the tree `tree` (its id) of the repository `fullName`, and of
|
|
81
|
+
// every tree under it, as GitHub lists them at once with `token`, a reader's
|
|
82
|
+
// (readToken): how many and their bytes, or null where GitHub cuts the
|
|
83
|
+
// listing short, as it does past 100,000 entries.
|
|
84
|
+
export async function repositoryTreeFiles(fullName: string, token: string | null, tree: string, fetchImpl: typeof fetch = globalThis.fetch) {
|
|
85
|
+
if (!/^[a-f\d]{40}$/u.test(tree)) throw new GithubApiError(400, 'bad-tree')
|
|
86
|
+
const url = `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}/git/trees/${tree}?recursive=1`
|
|
87
|
+
const data = await githubJson(url, token, fetchImpl) as { tree?: unknown; truncated?: unknown }
|
|
88
|
+
if (!Array.isArray(data?.tree)) throw new GithubApiError(502, 'github-malformed')
|
|
89
|
+
if (data.truncated === true) return null
|
|
90
|
+
let bytes = 0, files = 0
|
|
91
|
+
for (const item of data.tree as { type?: unknown; size?: unknown }[]) {
|
|
92
|
+
if (item?.type !== 'blob') continue
|
|
93
|
+
files++
|
|
94
|
+
if (typeof item.size === 'number' && item.size > 0) bytes += item.size
|
|
95
|
+
}
|
|
96
|
+
return { files, bytes }
|
|
97
|
+
}
|
|
98
|
+
|
|
61
99
|
async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
|
|
62
100
|
const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
63
101
|
// Public reads can use the signed-in user's quota without repository grants.
|
|
64
|
-
// Nonpublic reads still require the repository App and the effective-user gate
|
|
65
|
-
|
|
102
|
+
// Nonpublic reads still require the repository App and the effective-user gate,
|
|
103
|
+
// whose permission level is kept (`level`).
|
|
104
|
+
let level: string | null = null, token = userToken ?? await installationToken()
|
|
66
105
|
const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
|
|
67
106
|
const metadata = async () => {
|
|
68
107
|
try { return await read('') }
|
|
@@ -90,9 +129,8 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
|
|
|
90
129
|
if (!token) throw new GithubApiError(404, 'no-repository')
|
|
91
130
|
const appToken = await installationToken()
|
|
92
131
|
if (!appToken) throw new GithubApiError(404, 'no-repository')
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
132
|
+
level = await githubRepoPermission(repo.fullName, appToken, await getIdentity(freshIdentity), fetchImpl)
|
|
133
|
+
if (!READ_PERMISSIONS.has(level)) throw new GithubApiError(404, 'no-repository')
|
|
96
134
|
token = appToken
|
|
97
135
|
return data
|
|
98
136
|
}
|
|
@@ -103,6 +141,9 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
|
|
|
103
141
|
// Server-only credential, selected by the same live visibility/permission
|
|
104
142
|
// gate as directory browsing. Never included in an HTTP response.
|
|
105
143
|
readToken: () => token,
|
|
144
|
+
// The user's permission level on it where the gate asked GitHub of it, as
|
|
145
|
+
// for a private repository; null for a public one.
|
|
146
|
+
permission: () => level,
|
|
106
147
|
async refs() {
|
|
107
148
|
// Suggestions are bounded; the input also accepts any branch or tag name.
|
|
108
149
|
const list = async (kind: string) => {
|
|
@@ -112,11 +153,11 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
|
|
|
112
153
|
}
|
|
113
154
|
const [branches, tags] = await Promise.all([list('branches'), list('tags')])
|
|
114
155
|
const names = (items: { name?: unknown }[]) => items.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
|
|
115
|
-
// Each tag's commit, for the
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
156
|
+
// Each branch's and tag's commit, for the code viewer's header to name a
|
|
157
|
+
// commit by, and each tag's for the tag cache. A full page may leave
|
|
158
|
+
// tags out; only a shorter one lists every tag.
|
|
159
|
+
return { defaultBranch, branches: names(branches), tags: names(tags), branchCommits: await branchCommits(read, branches, defaultBranch),
|
|
160
|
+
tagCommits: refCommits(tags), tagsComplete: tags.length < REFS_PAGE }
|
|
120
161
|
},
|
|
121
162
|
async commit(ref: string) {
|
|
122
163
|
if (/^[a-f\d]{40}$/iu.test(ref)) return ref
|