@preventive/triage 1.0.0-alpha.43 → 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-compare-diff.js +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- 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 +91 -10
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +11 -0
- package/common/workspace-app.js +19 -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 -0
- 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 +31 -31
- 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 +55 -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 +13 -8
- package/risk/npm/compare.js +34 -10
- package/risk/npm/dependencies.js +11 -0
- 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/comparison.ts +41 -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 +1219 -81
- package/server-managed/index.ts +6 -2
- package/server-managed/locked-packages.ts +48 -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/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- 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 +116 -106
- 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 +54 -14
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +11 -0
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4F6JSF57.js +0 -3
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-7RU5EDRO.js +0 -226
- package/out/chunk-A7M4GV7M.js +0 -1
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- 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-LAAB5SZT.js +0 -2
- package/out/chunk-NWZ4PVPN.js +0 -1
- package/out/chunk-OSIMGFUF.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-RSHMVYNS.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UXNUAIIC.js +0 -33
- 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-VQJCXGIY.js +0 -7
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-XOCAPDCM.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
- package/out/chunk-ZMQQCGF6.js +0 -2
|
@@ -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
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// The files each commit changes, as the code viewer's Replay plays them
|
|
2
|
+
// (GET /api/admin/repositories/changes), as `git log --name-status` lists
|
|
3
|
+
// them: each file's path and whether the commit added (`A`), modified (`M`),
|
|
4
|
+
// removed (`D`) or renamed (`R`, with the path it had) it, as GitHub's REST
|
|
5
|
+
// API lists a commit's files (GET /repos/:owner/:repo/commits/:sha), a
|
|
6
|
+
// page of FILES_PAGE at a time, as many as MAX_FILES, `more` where it had
|
|
7
|
+
// more. What a commit changes never changes, so each is kept by the commit
|
|
8
|
+
// (`kept`), whole, read of GitHub once; what a reader is answered, only the
|
|
9
|
+
// files within the directory they asked of (changesWithin). And the files a
|
|
10
|
+
// commit holds, which Replay starts from (GET /api/admin/repositories/tree),
|
|
11
|
+
// as GitHub lists its tree at once (repositoryTree), kept as well.
|
|
12
|
+
|
|
13
|
+
import { GithubApiError, githubJson } from './github-app.ts'
|
|
14
|
+
|
|
15
|
+
// Commits an answer tells at most, read of GitHub as many at once as
|
|
16
|
+
// CHANGES_PARALLEL.
|
|
17
|
+
export const CHANGES_ASKED = 50
|
|
18
|
+
const CHANGES_PARALLEL = 6
|
|
19
|
+
// A commit's files read, as many as fill MAX_FILES, FILES_PAGE a page (the
|
|
20
|
+
// most GitHub lists a page; it lists fewer, unasked).
|
|
21
|
+
export const MAX_FILES = 300
|
|
22
|
+
export const FILES_PAGE = 100
|
|
23
|
+
const MAX_PATH = 4096
|
|
24
|
+
const SHA = /^[a-f\d]{40}$/u
|
|
25
|
+
|
|
26
|
+
export type ChangeStatus = 'A' | 'M' | 'D' | 'R'
|
|
27
|
+
// A file a commit changes: its path, its status, and a rename's path before.
|
|
28
|
+
export type ChangedFile = [path: string, status: Exclude<ChangeStatus, 'R'>] | [path: string, status: 'R', from: string]
|
|
29
|
+
export interface CommitChanges { files: ChangedFile[]; more: boolean }
|
|
30
|
+
|
|
31
|
+
// What each commit changes kept, by the commit.
|
|
32
|
+
export interface ChangesStore {
|
|
33
|
+
read(shas: string[]): Promise<Map<string, CommitChanges>>
|
|
34
|
+
write(sha: string, changes: CommitChanges): Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pathOf = (value: unknown) => typeof value === 'string' && value !== '' && value.length <= MAX_PATH && !/\p{Cc}/u.test(value) ? value : null
|
|
38
|
+
|
|
39
|
+
// A file's status as git names it: a copy is a file added; one GitHub
|
|
40
|
+
// lists unchanged (its mode alone changed, say) changes none.
|
|
41
|
+
const STATUSES: Record<string, ChangeStatus> = { added: 'A', copied: 'A', modified: 'M', changed: 'M', removed: 'D', renamed: 'R' }
|
|
42
|
+
|
|
43
|
+
type GithubFile = { filename?: unknown; status?: unknown; previous_filename?: unknown }
|
|
44
|
+
|
|
45
|
+
// A commit's files as GitHub lists them; a rename it names no path before
|
|
46
|
+
// for, a file added.
|
|
47
|
+
export function commitChanges(data: unknown): CommitChanges {
|
|
48
|
+
const listed = (data as { files?: unknown } | null)?.files
|
|
49
|
+
if (!Array.isArray(listed)) throw new GithubApiError(502, 'github-malformed')
|
|
50
|
+
const files = (listed as GithubFile[]).slice(0, MAX_FILES).flatMap((file): ChangedFile[] => {
|
|
51
|
+
const path = pathOf(file?.filename)
|
|
52
|
+
const status = typeof file?.status === 'string' ? STATUSES[file.status] : undefined
|
|
53
|
+
if (!path || !status) return []
|
|
54
|
+
const from = status === 'R' ? pathOf(file.previous_filename) : null
|
|
55
|
+
return [status === 'R' ? from ? [path, 'R', from] : [path, 'A'] : [path, status]]
|
|
56
|
+
})
|
|
57
|
+
return { files, more: listed.length >= MAX_FILES }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// What a commit changes within the directory `path` ('' the whole
|
|
61
|
+
// repository): its files there, a rename across its edge the file added
|
|
62
|
+
// where it came in, removed where it went out.
|
|
63
|
+
export function changesWithin({ files, more }: CommitChanges, path: string): CommitChanges {
|
|
64
|
+
if (path === '') return { files, more }
|
|
65
|
+
const within = (file: string) => file.startsWith(`${path}/`)
|
|
66
|
+
return { more, files: files.flatMap((file): ChangedFile[] => {
|
|
67
|
+
if (file[1] !== 'R') return within(file[0]) ? [file] : []
|
|
68
|
+
const [to, , from] = file
|
|
69
|
+
if (within(to)) return [within(from) ? file : [to, 'A']]
|
|
70
|
+
return within(from) ? [[from, 'D']] : []
|
|
71
|
+
}) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Runs `run` for each of `items`, as many at once as `parallel`.
|
|
75
|
+
async function eachOf<T>(items: T[], parallel: number, run: (item: T) => Promise<void>) {
|
|
76
|
+
let next = 0
|
|
77
|
+
await Promise.all(Array.from({ length: Math.min(parallel, items.length) }, async () => { while (next < items.length) await run(items[next++]!) }))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A commit's files as GitHub lists them, read a page at a time while each
|
|
81
|
+
// is full, till MAX_FILES are.
|
|
82
|
+
async function commitFiles(url: string, token: string | null, fetchImpl: typeof fetch) {
|
|
83
|
+
const files: unknown[] = []
|
|
84
|
+
for (let page = 1; files.length < MAX_FILES; page++) {
|
|
85
|
+
const data = await githubJson(`${url}?per_page=${FILES_PAGE}&page=${page}`, token, fetchImpl) as { files?: unknown } | null
|
|
86
|
+
if (!Array.isArray(data?.files)) throw new GithubApiError(502, 'github-malformed')
|
|
87
|
+
files.push(...data.files)
|
|
88
|
+
if (data.files.length < FILES_PAGE) break
|
|
89
|
+
}
|
|
90
|
+
return { files }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// What each of `shas` changes, kept, else read of GitHub and then kept; null
|
|
94
|
+
// for one GitHub has no commit of (as one of another repository is).
|
|
95
|
+
export async function repositoryChanges(fullName: string, token: string | null, shas: string[], kept: ChangesStore,
|
|
96
|
+
fetchImpl: typeof fetch = globalThis.fetch): Promise<Map<string, CommitChanges | null>> {
|
|
97
|
+
const asked = [...new Set(shas)].filter(sha => SHA.test(sha))
|
|
98
|
+
const found = new Map<string, CommitChanges | null>(await kept.read(asked))
|
|
99
|
+
const repo = `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
100
|
+
await eachOf(asked.filter(sha => !found.has(sha)), CHANGES_PARALLEL, async sha => {
|
|
101
|
+
try {
|
|
102
|
+
const changes = commitChanges(await commitFiles(`${repo}/commits/${sha}`, token, fetchImpl))
|
|
103
|
+
found.set(sha, changes)
|
|
104
|
+
await kept.write(sha, changes)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// No such commit: GitHub's 404, or its 422 for an id naming none.
|
|
107
|
+
if (err instanceof GithubApiError && (err.message === 'github-not-found' || err.message === 'github-status-422')) found.set(sha, null)
|
|
108
|
+
else throw err
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
return new Map(asked.map(sha => [sha, found.get(sha) ?? null]))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The files a commit (or a tree) holds, as many as MAX_TREE_FILES, each by its path and
|
|
115
|
+
// its bytes (`sizes`, in the same order), as
|
|
116
|
+
// GitHub lists its tree and every tree under it at once
|
|
117
|
+
// (GET /repos/:owner/:repo/git/trees/:tree?recursive=1); where GitHub cuts
|
|
118
|
+
// that listing short (past 100,000 entries or 7 MB, as a large repository's
|
|
119
|
+
// is), as GitHub advises: the tree's own entries listed alone, and each tree
|
|
120
|
+
// in it read so in turn, as many at once as TREES_PARALLEL (treeByTrees).
|
|
121
|
+
// Its blobs, not its directories nor submodules, sorted by path; past
|
|
122
|
+
// MAX_TREE_FILES those nearest its root of those read, `cut` where some are
|
|
123
|
+
// left out: more files than MAX_TREE_FILES, a tree's own entries cut short,
|
|
124
|
+
// or more trees to read than MAX_TREE_READS.
|
|
125
|
+
// Null for a commit GitHub has none of. Kept by the commit and the rules it
|
|
126
|
+
// is read by (TREES_READ), a tree kept by older ones (without sizes, the
|
|
127
|
+
// last) read again.
|
|
128
|
+
export const MAX_TREE_FILES = 75_000
|
|
129
|
+
export const TREES_READ = 3
|
|
130
|
+
const MAX_TREE_READS = 400
|
|
131
|
+
const TREES_PARALLEL = 6
|
|
132
|
+
export interface CommitTree { files: string[]; sizes: number[]; cut: boolean }
|
|
133
|
+
export interface TreeStore {
|
|
134
|
+
read(commit: string): Promise<CommitTree | null>
|
|
135
|
+
write(commit: string, tree: CommitTree): Promise<void>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type TreeEntry = { type?: unknown; path?: unknown; sha?: unknown; size?: unknown }
|
|
139
|
+
// A file read: its path and its bytes.
|
|
140
|
+
type Blob = [path: string, size: number]
|
|
141
|
+
type TreeListing = (tree: string, recursive: boolean) => Promise<{ entries: TreeEntry[]; truncated: boolean }>
|
|
142
|
+
|
|
143
|
+
export async function repositoryTree(fullName: string, token: string | null, commit: string, kept: TreeStore,
|
|
144
|
+
fetchImpl: typeof fetch = globalThis.fetch): Promise<CommitTree | null> {
|
|
145
|
+
if (!SHA.test(commit)) return null
|
|
146
|
+
const held = await kept.read(commit)
|
|
147
|
+
if (held) return held
|
|
148
|
+
const repo = `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
149
|
+
const listing: TreeListing = async (tree, recursive) => {
|
|
150
|
+
const data = await githubJson(`${repo}/git/trees/${tree}${recursive ? '?recursive=1' : ''}`, token, fetchImpl) as { tree?: unknown; truncated?: unknown }
|
|
151
|
+
if (!Array.isArray(data?.tree)) throw new GithubApiError(502, 'github-malformed')
|
|
152
|
+
return { entries: data.tree as TreeEntry[], truncated: data.truncated === true }
|
|
153
|
+
}
|
|
154
|
+
let whole: Awaited<ReturnType<TreeListing>>
|
|
155
|
+
try {
|
|
156
|
+
whole = await listing(commit, true)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err instanceof GithubApiError && (err.message === 'github-not-found' || err.message === 'github-status-422')) return null
|
|
159
|
+
throw err
|
|
160
|
+
}
|
|
161
|
+
const files: Blob[] = []
|
|
162
|
+
let cut = false
|
|
163
|
+
if (whole.truncated) cut = await treeByTrees(commit, listing, files)
|
|
164
|
+
else blobsOf(whole.entries, '', files)
|
|
165
|
+
const depth = (path: string) => path.split('/').length
|
|
166
|
+
const byPath = ([a]: Blob, [b]: Blob) => a < b ? -1 : a > b ? 1 : 0
|
|
167
|
+
const nearest = files.length > MAX_TREE_FILES ? files.toSorted((a, b) => depth(a[0]) - depth(b[0]) || byPath(a, b)).slice(0, MAX_TREE_FILES) : files
|
|
168
|
+
const sorted = nearest.toSorted(byPath)
|
|
169
|
+
const tree = { files: sorted.map(([path]) => path), sizes: sorted.map(([, size]) => size), cut: cut || files.length > MAX_TREE_FILES }
|
|
170
|
+
await kept.write(commit, tree)
|
|
171
|
+
return tree
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The blobs of a tree's entries, each its path under `prefix` and its bytes
|
|
175
|
+
// (none, as GitHub names none), onto `files`.
|
|
176
|
+
function blobsOf(entries: TreeEntry[], prefix: string, files: Blob[]) {
|
|
177
|
+
for (const item of entries) {
|
|
178
|
+
if (item?.type !== 'blob' || !pathOf(item.path)) continue
|
|
179
|
+
files.push([`${prefix}${item.path as string}`, Number.isSafeInteger(item.size) && (item.size as number) > 0 ? item.size as number : 0])
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The files under the commit's tree, `commit`, onto `files`, its trees read
|
|
184
|
+
// a round at a time, those nearest the root first: its own entries, then
|
|
185
|
+
// each tree in it whole, or, where that is cut short, alone in turn, and so
|
|
186
|
+
// on. Each round read whole, whatever answers first, but for trees no file
|
|
187
|
+
// of which could be kept: as deep as, or deeper than, the files nearest the
|
|
188
|
+
// root, as many as MAX_TREE_FILES, of those read (files of a tree are at
|
|
189
|
+
// least a level deeper than it is). As many trees read in all as
|
|
190
|
+
// MAX_TREE_READS, those of a round first in its order. Whether some were
|
|
191
|
+
// left out.
|
|
192
|
+
async function treeByTrees(commit: string, listing: TreeListing, files: Blob[]): Promise<boolean> {
|
|
193
|
+
let cut = false, reads = 1, round = [{ tree: commit, prefix: '', recursive: false }]
|
|
194
|
+
const atDepth: number[] = []
|
|
195
|
+
let counted = 0
|
|
196
|
+
while (round.length > 0) {
|
|
197
|
+
for (; counted < files.length; counted++) {
|
|
198
|
+
const depth = files[counted]![0].split('/').length
|
|
199
|
+
atDepth[depth] = (atDepth[depth] ?? 0) + 1
|
|
200
|
+
}
|
|
201
|
+
// The depth no file deeper than is kept.
|
|
202
|
+
let kept = Number.POSITIVE_INFINITY
|
|
203
|
+
for (let depth = 0, sum = 0; depth < atDepth.length; depth++) {
|
|
204
|
+
sum += atDepth[depth] ?? 0
|
|
205
|
+
if (sum >= MAX_TREE_FILES) {
|
|
206
|
+
kept = depth
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const asked = round.filter(({ prefix }) => prefix.split('/').length - 1 < kept).slice(0, Math.max(0, MAX_TREE_READS - reads))
|
|
211
|
+
cut ||= asked.length < round.length
|
|
212
|
+
reads += asked.length
|
|
213
|
+
const listed: Awaited<ReturnType<TreeListing>>[] = []
|
|
214
|
+
await eachOf([...asked.keys()], TREES_PARALLEL, async at => { listed[at] = await listing(asked[at]!.tree, asked[at]!.recursive) })
|
|
215
|
+
const next: typeof round = []
|
|
216
|
+
for (const [at, { tree, prefix, recursive }] of asked.entries()) {
|
|
217
|
+
const { entries, truncated } = listed[at]!
|
|
218
|
+
if (truncated && recursive) {
|
|
219
|
+
next.push({ tree, prefix, recursive: false })
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
cut ||= truncated
|
|
223
|
+
blobsOf(entries, prefix, files)
|
|
224
|
+
if (recursive) continue
|
|
225
|
+
for (const item of entries) {
|
|
226
|
+
const path = pathOf(item?.path)
|
|
227
|
+
if (item?.type === 'tree' && path && typeof item.sha === 'string' && SHA.test(item.sha)) next.push({ tree: item.sha, prefix: `${prefix}${path}/`, recursive: true })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
round = next
|
|
231
|
+
}
|
|
232
|
+
return cut
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The files commit `commit` holds within the directory `path` ('' the whole
|
|
236
|
+
// repository), as repositoryTree reads them: its whole tree's within it; where
|
|
237
|
+
// that is cut short, some of the directory's perhaps among those left out,
|
|
238
|
+
// the directory's own tree read so (directoryTree), kept by its id, the files
|
|
239
|
+
// nearest it kept. Null for a commit GitHub has none of.
|
|
240
|
+
export async function directoryFiles(fullName: string, token: string | null, commit: string, path: string, kept: TreeStore,
|
|
241
|
+
fetchImpl: typeof fetch = globalThis.fetch): Promise<CommitTree | null> {
|
|
242
|
+
const whole = await repositoryTree(fullName, token, commit, kept, fetchImpl)
|
|
243
|
+
if (!whole || path === '' || !whole.cut) return whole && treeWithin(whole, path)
|
|
244
|
+
const id = await directoryTree(fullName, token, commit, path, fetchImpl)
|
|
245
|
+
const own = id ? await repositoryTree(fullName, token, id, kept, fetchImpl) : null
|
|
246
|
+
return own ? { ...own, files: own.files.map(file => `${path}/${file}`) } : treeWithin(whole, path)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The id of the tree at `path` in commit `commit`, read a level at a time
|
|
250
|
+
// (GET /repos/:owner/:repo/git/trees/:tree, its own entries alone); null
|
|
251
|
+
// where it has none there, or GitHub has no such commit.
|
|
252
|
+
async function directoryTree(fullName: string, token: string | null, commit: string, path: string, fetchImpl: typeof fetch): Promise<string | null> {
|
|
253
|
+
const repo = `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
254
|
+
let tree = commit
|
|
255
|
+
for (const name of path.split('/')) {
|
|
256
|
+
let data: { tree?: unknown }
|
|
257
|
+
try {
|
|
258
|
+
data = await githubJson(`${repo}/git/trees/${tree}`, token, fetchImpl) as typeof data
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if (err instanceof GithubApiError && (err.message === 'github-not-found' || err.message === 'github-status-422')) return null
|
|
261
|
+
throw err
|
|
262
|
+
}
|
|
263
|
+
if (!Array.isArray(data?.tree)) throw new GithubApiError(502, 'github-malformed')
|
|
264
|
+
const entry = (data.tree as TreeEntry[]).find(item => item?.path === name && item.type === 'tree')
|
|
265
|
+
if (typeof entry?.sha !== 'string' || !SHA.test(entry.sha)) return null
|
|
266
|
+
tree = entry.sha
|
|
267
|
+
}
|
|
268
|
+
return tree
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The files of a tree within the directory `path` ('' the whole repository).
|
|
272
|
+
export function treeWithin({ files, sizes, cut }: CommitTree, path: string): CommitTree {
|
|
273
|
+
if (path === '') return { files, sizes, cut }
|
|
274
|
+
const within = [...files.keys()].filter(at => files[at]!.startsWith(`${path}/`))
|
|
275
|
+
return { files: within.map(at => files[at]!), sizes: within.map(at => sizes[at]!), cut }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// The commits a request names (`shas`), each once, as many as
|
|
279
|
+
// CHANGES_ASKED; null for none, or for one that isn't a commit id.
|
|
280
|
+
export function changesAsked(shas: string | null): string[] | null {
|
|
281
|
+
if (!shas) return null
|
|
282
|
+
const asked = [...new Set(shas.split(','))]
|
|
283
|
+
return asked.length <= CHANGES_ASKED && asked.every(sha => SHA.test(sha)) ? asked : null
|
|
284
|
+
}
|