@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
package/server-managed/http.ts
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
// GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
|
|
9
9
|
// GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
|
|
10
10
|
// GET /api/auth/session → { user, csrfToken } | 401
|
|
11
|
+
// GET /api/auth/profile → { profile: { login, name, githubName, nameAccount, avatarAccount, accounts } }, the
|
|
12
|
+
// GitHub accounts linked to the user's and which of them the displayed name and avatar come from | 401/403
|
|
13
|
+
// POST /api/auth/profile → CSRF, chooses those ({ name?, avatar? }: a linked account's id, null the sign-in one) | 400/401/403/404
|
|
14
|
+
// DELETE /api/auth/profile/accounts/<id> → CSRF, unlinks a linked GitHub account | 401/403/404
|
|
15
|
+
// GET /api/oauth/github/link → 302 to GitHub to link another account (+ state cookie); the callback lands on /my/profile
|
|
11
16
|
// GET /api/teams → the current user's teams + their reports and bundles | 401
|
|
12
17
|
// GET /api/teams/<id>/fixes → PR/issue metadata from visible findings' stored Fix links
|
|
13
18
|
// GET /api/teams/<id>/reports → all reports and links filtered through this team | 401/404
|
|
@@ -17,14 +22,48 @@
|
|
|
17
22
|
// GET /api/reports/<id>/triage → triage entries (by finding id, shared across reports) for a viewable report's findings | 401/404
|
|
18
23
|
// POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
|
|
19
24
|
// GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
|
|
20
|
-
// GET /api/avatar/<id>
|
|
25
|
+
// GET /api/avatar/<id>[?own] → cached avatar bytes by user id, of the account chosen on their profile (`own`: the
|
|
26
|
+
// sign-in account's), or by linked account id; `app-<id>` or `user-<id>`, an app agent's GitHub App's or account's (APP_AGENTS) | 401/404
|
|
21
27
|
// GET /api/admin/users → admin-only user list | 401/403
|
|
22
28
|
// GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
|
|
23
29
|
// POST /api/admin/set-role → admin sets another user's role | 401/403/404
|
|
24
30
|
// GET /api/admin/repositories → admin repo list (each flagged selected) | 401/403
|
|
25
31
|
// POST /api/admin/repositories/select → admin selects/deactivates a repo | 401/403
|
|
32
|
+
// POST /api/admin/repositories/dependency-review → admin sets a repo's dependency review: enabled, the reviews each
|
|
33
|
+
// version needs, and the commit its lockfiles' versions need none since | 400/401/403/404
|
|
26
34
|
// GET /api/admin/repositories/impact → admin attached data summary
|
|
27
|
-
// GET /api/admin/repositories/{browsable,refs,contents} →
|
|
35
|
+
// GET /api/admin/repositories/{browsable,refs,contents} → view or above, within the reader's team grants, + GitHub read access
|
|
36
|
+
// (browsable: the names alone; these and the reads below are the code viewer's, open to every team member)
|
|
37
|
+
// GET /api/admin/repositories/code?repoId=&ref=&path= → the files of a directory at a revision, within the reader's grants,
|
|
38
|
+
// Brotli-encoded | 400/401/403/404/409/413/429/502
|
|
39
|
+
// GET /api/admin/repositories/history?repoId=&ref=&path=&page=[&all=true[&from=]] → the commits a revision reaches, a page at a time,
|
|
40
|
+
// or across the most recently updated branches, with the branches and tags naming them, then on from the commits `from`
|
|
41
|
+
// names | 400/401/403/404/409/429/502
|
|
42
|
+
// GET /api/admin/repositories/changes?repoId=&path=&shas= → the files each of the commits `shas` names (fifty at most)
|
|
43
|
+
// adds, modifies, removes and renames within `path`, as `git log --name-status` lists them | 400/401/403/404/409/429/502
|
|
44
|
+
// GET /api/admin/repositories/tree?repoId=&path=&commit= → the files commit `commit` holds within `path`, as many as
|
|
45
|
+
// 75,000, for Replay to start from | 400/401/403/404/409/429/502
|
|
46
|
+
// GET /api/admin/repositories/issues?repoId=&kind=issues|pulls&state=open|closed[&after=] → a page of the repository's issues
|
|
47
|
+
// or pull requests, the most recently updated first, with how many are open and closed | 400/401/403/404/409/429/502
|
|
48
|
+
// GET /api/admin/repositories/agents?repoId=[&pulls=&commits=] → a page of what agents left: pull requests and commits to
|
|
49
|
+
// the default branch no pull request merged, with issues, agents' branches and the app agents first | 400/401/403/404/409/429/502
|
|
50
|
+
// GET /api/admin/repositories/item?repoId=&number= → an issue or pull request, as its popup shows it, with its
|
|
51
|
+
// description's Markdown | 400/401/403/404/409/429/502
|
|
52
|
+
// GET /api/admin/repositories/pull-dependencies?repoId=&number= → the package versions a pull request's npm, pnpm and
|
|
53
|
+
// Yarn 1 lockfiles lock that they didn't at its merge base, an update's with those it was, by directory; with review=1, each's
|
|
54
|
+
// review, where its repository reviews dependencies | 400/401/403/404/409/429/502
|
|
55
|
+
// GET /api/admin/repositories/branch-dependencies?repoId= → the versions the default branch's lockfiles lock that want review, where
|
|
56
|
+
// its review is in effect for the reader | 400/401/403/404/409/429/502
|
|
57
|
+
// GET /api/admin/repositories/lockfiles?repoId= → the lockfiles of the default branch its dependency review reads, by their
|
|
58
|
+
// paths, for its settings to name | 400/401/403/404/409/429/502
|
|
59
|
+
// GET /api/admin/repositories/pulse?repoId=&weeks=1|4|13|52 → the default branch's commits and lines a week and a day over
|
|
60
|
+
// the last year, its contributors and when in the period they commit, counted from its commits as Commits keeps them (how many are
|
|
61
|
+
// read while they are), and the pull requests
|
|
62
|
+
// merged and opened and the issues closed and opened in the last `weeks` weeks and the same days a period earlier | 400/401/403/404/409/429/502
|
|
63
|
+
// GET /api/admin/repositories/pulse/items?repoId=&weeks=&from=[&to=&authors=] → those pull requests and issues of the days
|
|
64
|
+
// `from` to `to` (YYYY-MM-DD, UTC; no `to`, on), and of any of `authors` (GitHub logins, comma-separated) | 400/401/403/404/409/429/502
|
|
65
|
+
// History, issues, item, agents and pulse each carry `users`: the app's users and agents the GitHub accounts they
|
|
66
|
+
// name are (github-users.ts), by login
|
|
28
67
|
// POST /api/admin/repositories/remove → admin permanently removes a repo
|
|
29
68
|
// GET /api/admin/repositories/risk[?team=|?repo=] → admin: each connected repo's npm lockfiles, or a team's or one repo's,
|
|
30
69
|
// their packages and what risk tells of each, Brotli-encoded ndjson as each is found; the reader leaving stops it | 400/401/403/404
|
|
@@ -64,6 +103,8 @@
|
|
|
64
103
|
// GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set | 400/401/403/404/409
|
|
65
104
|
// POST /api/npm/reviews/discard → author discards an unpublished review and its file rows | 400/401/403/409
|
|
66
105
|
// GET /api/npm/reviews/latest → latest own review for exact target bytes, across all bases | 400/401/403
|
|
106
|
+
// GET /api/npm/reviews/team?teamId=&beforeTime=&beforeId= → the reviews published to a team, the latest first, for one who
|
|
107
|
+
// reads them there | 400/401/404
|
|
67
108
|
// GET /api/npm/package?name=&version= → a published version's manifest and files | 400/401/404/413/429/502
|
|
68
109
|
// GET /api/npm/versions?name= → its dist-tags, versions, when each was published and its first was, and whether a team lists its scope | 400/401/404/429/502
|
|
69
110
|
// GET /api/npm/download?name=&version= → its tarball | 400/401/404/413/429/502
|
|
@@ -82,6 +123,9 @@
|
|
|
82
123
|
// Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
83
124
|
// GET /api/npm/type-only?name=&version=&otherVersion= → the files that changed between two versions in types alone,
|
|
84
125
|
// Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
126
|
+
// GET /api/npm/delta?name=&version=&otherVersion= → the lines a version adds and removes against another, or `(empty)`, and
|
|
127
|
+
// those but for declaration files changed in types alone, where any are, and whether binary files changed
|
|
128
|
+
// | 400/401/404/413/422/429/502/503/504
|
|
85
129
|
// GET /api/npm/risk?name=&version=&otherVersion= → what calls for a look at a version, as risk tells it, against another where given | 400/401/404/413/429/502
|
|
86
130
|
// POST /api/auth/logout → same-origin + CSRF, drops the session (and any view)
|
|
87
131
|
// POST /api/auth/view-as → admin opens a read-only view as another user | 400/401/403/404
|
|
@@ -90,7 +134,9 @@ import { mergeLinkGroups } from './link-reports.ts'
|
|
|
90
134
|
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
91
135
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
92
136
|
import { pipeline } from 'node:stream/promises'
|
|
93
|
-
import {
|
|
137
|
+
import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmVersionStore, pullDependencyStore,
|
|
138
|
+
repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
139
|
+
import { type NpmDelta, npmDelta } from './npm-delta.ts'
|
|
94
140
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
95
141
|
import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
|
|
96
142
|
import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
|
|
@@ -115,6 +161,7 @@ import { isRole, roleAtLeast } from '../common/managed/roles.ts'
|
|
|
115
161
|
import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
|
|
116
162
|
import { filterReportData } from '../common/managed/report-filter.ts'
|
|
117
163
|
import type { TriageEntryPatch } from '../common/managed/triage.ts'
|
|
164
|
+
import { GITHUB_AGENTS } from '../common/managed/github-agents.ts'
|
|
118
165
|
import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
|
|
119
166
|
import { reportRepoGithub } from '@preventive/report'
|
|
120
167
|
import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
|
|
@@ -130,8 +177,10 @@ import type { ConnectedRepo } from './github-app.ts'
|
|
|
130
177
|
import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
|
|
131
178
|
import { RepositoryDiscovery } from './repository-discovery.ts'
|
|
132
179
|
import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines } from './repository-risk.ts'
|
|
133
|
-
import {
|
|
134
|
-
import {
|
|
180
|
+
import { WORKFLOWS_DIR } from './repository-actions.ts'
|
|
181
|
+
import { githubActionsSource } from './github-actions.ts'
|
|
182
|
+
import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
|
|
183
|
+
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
135
184
|
import { clearCookie, endSession, readSession } from './session.ts'
|
|
136
185
|
import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
|
|
137
186
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
@@ -143,6 +192,10 @@ import { lookupIssueLinks } from './github-issue-links.ts'
|
|
|
143
192
|
import { visibleManagedIssues } from './managed-issues.ts'
|
|
144
193
|
import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
|
|
145
194
|
import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
|
|
195
|
+
import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallback } from './github-link-oauth.ts'
|
|
196
|
+
import type { ProfileChoice } from './github-links.ts'
|
|
197
|
+
import { withAppUsers } from './github-users.ts'
|
|
198
|
+
import { isBlobId } from './object-storage.ts'
|
|
146
199
|
import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
|
|
147
200
|
import { triageWireEntry } from './triage-response.ts'
|
|
148
201
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
@@ -154,6 +207,25 @@ import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
|
154
207
|
import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
|
|
155
208
|
import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
|
|
156
209
|
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
210
|
+
import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
|
|
211
|
+
import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
|
|
212
|
+
import { COAUTHORS_READ } from './commit-coauthors.ts'
|
|
213
|
+
import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, type SquashedPull, type Squashes, historyFrom, historyStarts, openPullHeads,
|
|
214
|
+
repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
|
|
215
|
+
import { AGENTS_READ_VERSION, APP_AGENTS, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
|
|
216
|
+
import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
|
|
217
|
+
import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependencies } from './pull-dependencies.ts'
|
|
218
|
+
import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
|
|
219
|
+
import { MAX_REQUIRED_REVIEWS, SINCE_COMMIT } from './repository-dependency-reviews.ts'
|
|
220
|
+
import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
|
|
221
|
+
import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
|
|
222
|
+
import { formattedPull, withFormatted } from './dependency-review-comments.ts'
|
|
223
|
+
import type { ReviewedDependencies } from './reviewed-dependencies.ts'
|
|
224
|
+
import { commentReviews } from './pull-comment-reviews.ts'
|
|
225
|
+
import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitStats, defaultHead, periodStats, pulseSince, repositoryActivity, repositoryLines,
|
|
226
|
+
repositoryStats, withLines, yearCommits } from './repository-pulse.ts'
|
|
227
|
+
import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
|
|
228
|
+
import { releaseCommits } from './release-commits.ts'
|
|
157
229
|
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
158
230
|
import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
|
|
159
231
|
import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
|
|
@@ -168,6 +240,7 @@ import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
|
|
|
168
240
|
import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
169
241
|
|
|
170
242
|
const SESSION_PATH = '/api/auth/session'
|
|
243
|
+
const PROFILE_PATH = '/api/auth/profile'
|
|
171
244
|
const AVATAR_PREFIX = '/api/avatar/'
|
|
172
245
|
const LOGOUT_PATH = '/api/auth/logout'
|
|
173
246
|
const ADMIN_USERS_PATH = '/api/admin/users'
|
|
@@ -178,7 +251,20 @@ const ADMIN_REPOS_PATH = '/api/admin/repositories'
|
|
|
178
251
|
const BROWSABLE_REPOS_PATH = '/api/admin/repositories/browsable'
|
|
179
252
|
const REPO_REFS_PATH = '/api/admin/repositories/refs'
|
|
180
253
|
const REPO_CONTENTS_PATH = '/api/admin/repositories/contents'
|
|
254
|
+
const REPO_CODE_PATH = '/api/admin/repositories/code'
|
|
255
|
+
const REPO_HISTORY_PATH = '/api/admin/repositories/history'
|
|
256
|
+
const REPO_CHANGES_PATH = '/api/admin/repositories/changes'
|
|
257
|
+
const REPO_TREE_PATH = '/api/admin/repositories/tree'
|
|
258
|
+
const REPO_ISSUES_PATH = '/api/admin/repositories/issues'
|
|
259
|
+
const REPO_AGENTS_PATH = '/api/admin/repositories/agents'
|
|
260
|
+
const REPO_ITEM_PATH = '/api/admin/repositories/item'
|
|
261
|
+
const REPO_PULL_DEPENDENCIES_PATH = '/api/admin/repositories/pull-dependencies'
|
|
262
|
+
const REPO_BRANCH_DEPENDENCIES_PATH = '/api/admin/repositories/branch-dependencies'
|
|
263
|
+
const REPO_LOCKFILES_PATH = '/api/admin/repositories/lockfiles'
|
|
264
|
+
const REPO_PULSE_PATH = '/api/admin/repositories/pulse'
|
|
265
|
+
const REPO_PULSE_ITEMS_PATH = '/api/admin/repositories/pulse/items'
|
|
181
266
|
const SELECT_REPO_PATH = '/api/admin/repositories/select'
|
|
267
|
+
const REPO_DEPENDENCY_REVIEW_PATH = '/api/admin/repositories/dependency-review'
|
|
182
268
|
const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
|
|
183
269
|
const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
|
|
184
270
|
const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
|
|
@@ -220,6 +306,7 @@ const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
|
|
|
220
306
|
const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
|
|
221
307
|
const NPM_COMPARISON_PATH = '/api/npm/comparison'
|
|
222
308
|
const NPM_RISK_PATH = '/api/npm/risk'
|
|
309
|
+
const NPM_DELTA_PATH = '/api/npm/delta'
|
|
223
310
|
// What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
|
|
224
311
|
// having no such package or version, or a tarball unpacking past risk's
|
|
225
312
|
// bounds; npm failing otherwise a 502.
|
|
@@ -232,7 +319,7 @@ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
|
|
|
232
319
|
sendJson(res, status, { error: err.code })
|
|
233
320
|
}
|
|
234
321
|
const NPM_PATHS = new Set([NPM_PACKAGE_PATH, NPM_VERSIONS_PATH, NPM_DOWNLOAD_PATH, NPM_STATS_PATH, NPM_ADVISORIES_PATH, NPM_TAGS_PATH, NPM_SOCKET_PATH, NPM_PROVENANCE_PATH, NPM_PRETTY_PATH,
|
|
235
|
-
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH])
|
|
322
|
+
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH, NPM_DELTA_PATH])
|
|
236
323
|
// How long an npm response may go unread before its connection is dropped.
|
|
237
324
|
const NPM_RESPONSE_IDLE_MS = 60_000
|
|
238
325
|
const MAX_TEAM_NAME = 100
|
|
@@ -353,6 +440,9 @@ export interface ManagedHttpDeps {
|
|
|
353
440
|
serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
|
|
354
441
|
next?: Handler
|
|
355
442
|
serverInfo?: ServerInfo
|
|
443
|
+
// The reviewed dependencies the host registered, as the server was
|
|
444
|
+
// assembled (reviewed-dependencies.ts).
|
|
445
|
+
reviewedDependencies?: ReviewedDependencies
|
|
356
446
|
}
|
|
357
447
|
|
|
358
448
|
type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
@@ -367,8 +457,9 @@ function firstHeader(v: string | string[] | undefined): string | null {
|
|
|
367
457
|
return null
|
|
368
458
|
}
|
|
369
459
|
|
|
370
|
-
|
|
371
|
-
|
|
460
|
+
// `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
|
|
461
|
+
async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id): Promise<void> {
|
|
462
|
+
const avatar = await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id))
|
|
372
463
|
if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
|
|
373
464
|
res.writeHead(200, {
|
|
374
465
|
'content-type': avatar.contentType,
|
|
@@ -379,6 +470,26 @@ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId
|
|
|
379
470
|
res.end(avatar.bytes)
|
|
380
471
|
}
|
|
381
472
|
|
|
473
|
+
// The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
|
|
474
|
+
// alone: the agents' known by their GitHub Apps or accounts (APP_AGENTS),
|
|
475
|
+
// each its mark on the Agents tab, and those the app draws agents' accounts
|
|
476
|
+
// by (GITHUB_AGENTS). Each is fetched from GitHub once and
|
|
477
|
+
// kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
|
|
478
|
+
// an account's, `…-0001-<id>`.
|
|
479
|
+
const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
|
|
480
|
+
.flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
|
|
481
|
+
const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
|
|
482
|
+
|
|
483
|
+
async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
|
|
484
|
+
const [, kind, id] = AGENT_AVATAR.exec(avatar)!
|
|
485
|
+
const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
|
|
486
|
+
if (await avatarStore.get(key) == null) {
|
|
487
|
+
await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
|
|
488
|
+
.catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
|
|
489
|
+
}
|
|
490
|
+
await serveAvatar(res, avatarStore, key)
|
|
491
|
+
}
|
|
492
|
+
|
|
382
493
|
const MAX_JSON_BODY_BYTES = 4096
|
|
383
494
|
|
|
384
495
|
// Buffer the request body, aborting (and throwing 'too-large') once it exceeds
|
|
@@ -481,9 +592,10 @@ function sendViewOnly(res: ServerResponse): void {
|
|
|
481
592
|
// Reads stay available while an admin views as another user. Everything else
|
|
482
593
|
// is refused before dispatch, except ending the view or signing out, and the
|
|
483
594
|
// batch report preview, a POST without effects. Issue authorization would
|
|
484
|
-
// grant GitHub access on the viewed user's behalf
|
|
595
|
+
// grant GitHub access on the viewed user's behalf, and linking an account
|
|
596
|
+
// would link it to the viewed user.
|
|
485
597
|
function viewAllows(method: string, path: string): boolean {
|
|
486
|
-
if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH
|
|
598
|
+
if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH && path !== LINK_LOGIN_PATH
|
|
487
599
|
return (method === 'DELETE' && path === VIEW_AS_PATH) || (method === 'POST' && (path === LOGOUT_PATH || path === REPORT_QUERY_PATH))
|
|
488
600
|
}
|
|
489
601
|
|
|
@@ -528,6 +640,65 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
|
|
|
528
640
|
res.end()
|
|
529
641
|
}
|
|
530
642
|
|
|
643
|
+
// GET /api/auth/profile — the user's profile (github-links.ts). POST chooses
|
|
644
|
+
// the accounts their displayed name and avatar come from. Workspace access is
|
|
645
|
+
// required: the accounts a user links are reserved for them.
|
|
646
|
+
async function handleProfile(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
647
|
+
const method = req.method ?? 'GET'
|
|
648
|
+
if (method === 'GET') {
|
|
649
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
650
|
+
if (s) await sendProfile(res, deps, s.user.id)
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
if (method !== 'POST') { send405(res, 'GET, POST'); return }
|
|
654
|
+
const s = await profileMutation(req, res, deps, cookie)
|
|
655
|
+
if (s == null) return
|
|
656
|
+
let body: unknown
|
|
657
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
658
|
+
const choice = parseProfileChoice(body)
|
|
659
|
+
if (choice == null) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
660
|
+
await deps.db.setProfileChoice(s.session.id, choice, Date.now())
|
|
661
|
+
await sendProfile(res, deps, s.user.id)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// DELETE /api/auth/profile/accounts/<id> — unlink one of the user's linked
|
|
665
|
+
// GitHub accounts, which may then sign in or register again.
|
|
666
|
+
async function handleUnlinkAccount(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
667
|
+
if ((req.method ?? 'GET') !== 'DELETE') { send405(res, 'DELETE'); return }
|
|
668
|
+
const s = await profileMutation(req, res, deps, cookie)
|
|
669
|
+
if (s == null) return
|
|
670
|
+
const link = await deps.db.unlinkGithubAccount(s.session.id, id, Date.now())
|
|
671
|
+
if (link == null) { sendJson(res, 404, { error: 'no-account' }); return }
|
|
672
|
+
await deps.avatarStore.delete(link.id).catch((err) => { console.warn('managed: linked avatar removal failed:', err) })
|
|
673
|
+
await activity(deps, s.user, 'access', `unlinked GitHub account @${link.login}`)
|
|
674
|
+
await sendProfile(res, deps, s.user.id)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function profileMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
678
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
679
|
+
if (s != null && !roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
680
|
+
return s
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function sendProfile(res: ServerResponse, deps: ManagedHttpDeps, userId: string): Promise<void> {
|
|
684
|
+
const profile = await deps.db.getProfile(userId)
|
|
685
|
+
if (profile == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
686
|
+
sendJson(res, 200, { profile })
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// { name?, avatar? }, each a linked account's id or null; at least one.
|
|
690
|
+
function parseProfileChoice(body: unknown): ProfileChoice | null {
|
|
691
|
+
if (body == null || typeof body !== 'object' || Array.isArray(body)) return null
|
|
692
|
+
const choice: ProfileChoice = {}
|
|
693
|
+
for (const key of ['name', 'avatar'] as const) {
|
|
694
|
+
if (!Object.hasOwn(body, key)) continue
|
|
695
|
+
const value = (body as Record<string, unknown>)[key]
|
|
696
|
+
if (value !== null && (typeof value !== 'string' || !isBlobId(value))) return null
|
|
697
|
+
choice[key] = value
|
|
698
|
+
}
|
|
699
|
+
return Object.keys(choice).length > 0 ? choice : null
|
|
700
|
+
}
|
|
701
|
+
|
|
531
702
|
// POST /api/admin/set-role — an admin sets ANOTHER user's role. Admin-only, and
|
|
532
703
|
// refused for the caller's own id so an admin can't drop their own admin (keeps
|
|
533
704
|
// at least one admin).
|
|
@@ -587,16 +758,19 @@ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
587
758
|
// Who am I? While an admin views as another user, that user, plus the admin
|
|
588
759
|
// as `viewer`. A view that has ended (with the admin's session or role, or the
|
|
589
760
|
// viewed account) is cleared, returning to the own session. Null signed out.
|
|
761
|
+
// `dependencyReview` where they publish dependency reviews to some team.
|
|
590
762
|
async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: string | undefined): Promise<{ body: ManagedSessionInfo | null; headers: Record<string, string> }> {
|
|
591
763
|
const now = Date.now()
|
|
592
764
|
let s = await readSession(config, db, cookie, now)
|
|
593
765
|
const ended = s == null && viewToken(config, cookie) != null
|
|
594
766
|
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
767
|
+
const reviews = s != null && (await db.listDependencyReviewTeams(s.user.id)).length > 0
|
|
595
768
|
return {
|
|
596
769
|
body: s && {
|
|
597
770
|
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
598
771
|
csrfToken: s.session.csrfToken,
|
|
599
772
|
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
773
|
+
...(reviews ? { dependencyReview: true as const } : {}),
|
|
600
774
|
},
|
|
601
775
|
headers: ended ? { 'set-cookie': clearViewCookie(config) } : {},
|
|
602
776
|
}
|
|
@@ -644,9 +818,13 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
|
|
|
644
818
|
let tokenMissing = false
|
|
645
819
|
let repositories
|
|
646
820
|
if (scope === 'connected') {
|
|
821
|
+
const reviews = await deps.db.listRepoDependencyReviews()
|
|
822
|
+
const reviewTeams = await deps.db.listRepoReviewTeams()
|
|
647
823
|
repositories = allRows.map((r) => ({
|
|
648
824
|
id: r.repoId, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl,
|
|
649
825
|
installed: r.installationId != null, selected: r.active, active: r.active,
|
|
826
|
+
dependencyReview: reviews.get(r.repoId) ?? { enabled: false, required: 1, since: null, lockfiles: EVERY_LOCKFILE },
|
|
827
|
+
reviewTeams: reviewTeams.get(r.repoId) ?? [],
|
|
650
828
|
}))
|
|
651
829
|
} else {
|
|
652
830
|
const showAll = scope === 'installed' && url.searchParams.get('showAll') === 'true'
|
|
@@ -676,6 +854,7 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
|
|
|
676
854
|
inactiveCount: allRows.filter((r) => !r.active).length,
|
|
677
855
|
total,
|
|
678
856
|
tokenMissing,
|
|
857
|
+
...deps.reviewedDependencies ? { reviewedDependencies: true } : {},
|
|
679
858
|
})
|
|
680
859
|
}
|
|
681
860
|
|
|
@@ -810,6 +989,39 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
810
989
|
await selectRepository(res, deps, s.user, repoId, cookie)
|
|
811
990
|
}
|
|
812
991
|
|
|
992
|
+
// POST /api/admin/repositories/dependency-review — a repository's dependency
|
|
993
|
+
// review (repository-dependency-reviews.ts): `enabled`, the published reviews
|
|
994
|
+
// each version its pull requests' lockfiles lock needs (`required`, 1 to
|
|
995
|
+
// MAX_REQUIRED_REVIEWS), and the commit whose lockfiles' versions need none
|
|
996
|
+
// (`since`, a full commit id, or null; where the host registered reviewed
|
|
997
|
+
// dependencies, which replace it, none, `400 since-unsupported`, the one
|
|
998
|
+
// stored kept as it is). Answers the setting then. Admin, CSRF.
|
|
999
|
+
async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1000
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
1001
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1002
|
+
if (s == null) return
|
|
1003
|
+
let body: Record<string, unknown> | null
|
|
1004
|
+
try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1005
|
+
const repoId = body?.['repoId']
|
|
1006
|
+
const enabled = body?.['enabled']
|
|
1007
|
+
const required = body?.['required']
|
|
1008
|
+
const since = body?.['since'] ?? null
|
|
1009
|
+
// The lockfiles it holds to, kept as they are where none is sent.
|
|
1010
|
+
const lockfiles = body?.['lockfiles'] === undefined ? undefined : scopeOf(body['lockfiles'])
|
|
1011
|
+
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof enabled !== 'boolean'
|
|
1012
|
+
|| typeof required !== 'number' || !Number.isSafeInteger(required) || required < 1 || required > MAX_REQUIRED_REVIEWS
|
|
1013
|
+
|| (since !== null && (typeof since !== 'string' || !SINCE_COMMIT.test(since))) || lockfiles === null) {
|
|
1014
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
1015
|
+
}
|
|
1016
|
+
if (since !== null && deps.reviewedDependencies) { sendJson(res, 400, { error: 'since-unsupported' }); return }
|
|
1017
|
+
const repo = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
|
|
1018
|
+
const setting = repo ? await deps.db.setRepoDependencyReview(repoId, { enabled, required, ...deps.reviewedDependencies ? {} : { since: since as string | null },
|
|
1019
|
+
...lockfiles ? { lockfiles } : {} }) : null
|
|
1020
|
+
if (!repo || !setting) { sendJson(res, 404, { error: 'no-repository' }); return }
|
|
1021
|
+
await activity(deps, s.user, 'repository', enabled ? 'enabled dependency review' : 'disabled dependency review', { repo: repo.fullName })
|
|
1022
|
+
sendJson(res, 200, { ok: true, dependencyReview: setting })
|
|
1023
|
+
}
|
|
1024
|
+
|
|
813
1025
|
async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
|
|
814
1026
|
const ids = new Set<string>()
|
|
815
1027
|
for (const report of reports) {
|
|
@@ -1249,8 +1461,10 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
|
|
|
1249
1461
|
|
|
1250
1462
|
// Listing connected repository names uses managed access only. Verify GitHub
|
|
1251
1463
|
// access after selection, before returning any refs or source directory data.
|
|
1464
|
+
// Any team member (view or above) lists those their teams reach, for the code
|
|
1465
|
+
// viewer as for Create a bundle, whose builds stay admin|manage.
|
|
1252
1466
|
async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1253
|
-
const s = await
|
|
1467
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1254
1468
|
if (!s) return
|
|
1255
1469
|
sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
|
|
1256
1470
|
}
|
|
@@ -1289,25 +1503,54 @@ function scopeRepositoryContents(contents: RepositoryContents | null, virtualEnt
|
|
|
1289
1503
|
} : {}) }
|
|
1290
1504
|
}
|
|
1291
1505
|
|
|
1292
|
-
//
|
|
1293
|
-
|
|
1506
|
+
// A browse's repository, revision and directory, as its query names them;
|
|
1507
|
+
// null, answered, where it names none.
|
|
1508
|
+
function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
|
|
1294
1509
|
const repoId = Number(query.get('repoId'))
|
|
1295
1510
|
const normalized = normalizeTeamPath(query.get('path') ?? '')
|
|
1296
1511
|
const ref = query.get('ref') ?? ''
|
|
1297
1512
|
if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
|
|
1298
|
-
sendJson(res, 400, { error: 'bad-request' }); return
|
|
1513
|
+
sendJson(res, 400, { error: 'bad-request' }); return null
|
|
1299
1514
|
}
|
|
1515
|
+
return { repoId, ref, path: normalized.path ?? '' }
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// The repository `repoId` as the reader may browse it at `path`: one of their
|
|
1519
|
+
// bundle repositories, and what their grants show of that directory
|
|
1520
|
+
// (`virtualEntries`, null where they reach all of it: scopedDirectory). Null,
|
|
1521
|
+
// answered, where it is none of theirs. Every team member (view or above)
|
|
1522
|
+
// reads the repositories their teams reach; the reader then checks their own
|
|
1523
|
+
// GitHub read permission (repository-browser.ts), on each read.
|
|
1524
|
+
async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
|
|
1525
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1526
|
+
if (!s) return null
|
|
1527
|
+
const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
|
|
1528
|
+
if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
|
|
1529
|
+
const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
|
|
1530
|
+
return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// The repository as it was authorized, but for the shared default branch hint,
|
|
1534
|
+
// which a read may refresh meanwhile: that changes none of its identity,
|
|
1535
|
+
// credentials or managed grants.
|
|
1536
|
+
const sameRepository = (current: SelectedRepo, authorized: SelectedRepo) =>
|
|
1537
|
+
JSON.stringify({ ...current, cachedDefaultBranch: authorized.cachedDefaultBranch }) === JSON.stringify(authorized)
|
|
1538
|
+
|
|
1539
|
+
function sendGithubError(res: ServerResponse, err: GithubApiError) {
|
|
1540
|
+
sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Recheck repository/path grants after upstream reads, before returning names.
|
|
1544
|
+
async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
|
|
1545
|
+
const asked = repositoryQuery(res, query)
|
|
1546
|
+
if (!asked) return
|
|
1547
|
+
const { repoId, ref } = asked
|
|
1300
1548
|
const withDefault = refs && query.get('withDefault') === 'true'
|
|
1301
|
-
const path = refs ? '' :
|
|
1549
|
+
const path = refs ? '' : asked.path
|
|
1302
1550
|
const authorize = async () => {
|
|
1303
|
-
const
|
|
1304
|
-
if (!
|
|
1305
|
-
|
|
1306
|
-
if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
|
|
1307
|
-
const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
|
|
1308
|
-
const virtualEntries = scopedDirectory(path, scopes)
|
|
1309
|
-
if ((!refs || withDefault) && virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1310
|
-
return { repo, virtualEntries, user: s.user }
|
|
1551
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1552
|
+
if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1553
|
+
return access
|
|
1311
1554
|
}
|
|
1312
1555
|
const access = await authorize()
|
|
1313
1556
|
if (!access) return
|
|
@@ -1329,23 +1572,729 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1329
1572
|
await reader.recheckAccess()
|
|
1330
1573
|
const current = await authorize()
|
|
1331
1574
|
if (!current) return
|
|
1332
|
-
// This request (or another reader) may have refreshed the shared hint.
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
sendJson(res, 409, { error: 'repository-changed' }); return
|
|
1336
|
-
}
|
|
1337
|
-
sendJson(res, 200, refs ? { ...listed, ...(withDefault ? {
|
|
1575
|
+
// This request (or another reader) may have refreshed the shared hint.
|
|
1576
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1577
|
+
sendJson(res, 200, refs ? { ...listed, tagCommits, ...(withDefault ? {
|
|
1338
1578
|
defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
|
|
1339
1579
|
} : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
|
|
1340
1580
|
} catch (err) {
|
|
1341
|
-
if (err instanceof GithubApiError) {
|
|
1342
|
-
|
|
1581
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1582
|
+
throw err
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// GET /api/admin/repositories/code: the files of a directory of a repository
|
|
1587
|
+
// at a revision, for the code viewer (repository-code.ts), read as browsing
|
|
1588
|
+
// it is, with the reader's own GitHub access, and only where their grants
|
|
1589
|
+
// reach the whole directory: one they reach only the ancestor of is none to
|
|
1590
|
+
// read. Its tree's listing says first whether its files fit, before its
|
|
1591
|
+
// tarball is asked for; the reader's access is checked again once it is
|
|
1592
|
+
// read, before any of it is sent. The load stays held until this response is
|
|
1593
|
+
// written or abandoned, and a reader who stops reading it gives it up after
|
|
1594
|
+
// a while.
|
|
1595
|
+
async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1596
|
+
const asked = repositoryQuery(res, query)
|
|
1597
|
+
if (!asked) return
|
|
1598
|
+
const { repoId, ref, path } = asked
|
|
1599
|
+
const authorize = async () => {
|
|
1600
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1601
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1602
|
+
return access
|
|
1603
|
+
}
|
|
1604
|
+
const access = await authorize()
|
|
1605
|
+
if (!access) return
|
|
1606
|
+
try {
|
|
1607
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1608
|
+
const commit = await reader.commit(ref)
|
|
1609
|
+
const repo = access.repo.fullName
|
|
1610
|
+
const client = createClient({ token: reader.readToken() })
|
|
1611
|
+
// A directory the commit has none at is none to read, as browsing it says.
|
|
1612
|
+
const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1613
|
+
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
|
|
1614
|
+
}))
|
|
1615
|
+
// Access still held, and the same repository, before anything read is sent.
|
|
1616
|
+
const fenced = async () => {
|
|
1617
|
+
await reader.recheckAccess()
|
|
1618
|
+
const current = await authorize()
|
|
1619
|
+
if (!current) return false
|
|
1620
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
|
|
1621
|
+
return true
|
|
1622
|
+
}
|
|
1623
|
+
// Too large names the commit it was read at, for History to read.
|
|
1624
|
+
if (!repositoryCodeFits(await repositoryTreeFiles(repo, reader.readToken(), tree))) {
|
|
1625
|
+
if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
|
|
1343
1626
|
return
|
|
1344
1627
|
}
|
|
1628
|
+
if (res.destroyed) return
|
|
1629
|
+
const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
|
|
1630
|
+
res.once('close', load.release)
|
|
1631
|
+
const bytes = await load.result
|
|
1632
|
+
if (!(await fenced()) || res.destroyed) return
|
|
1633
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
1634
|
+
res.writeHead(200, {
|
|
1635
|
+
'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
|
|
1636
|
+
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
1637
|
+
})
|
|
1638
|
+
writeResponse(res, bytes)
|
|
1639
|
+
} catch (err) {
|
|
1640
|
+
if (res.headersSent || res.destroyed) return
|
|
1641
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1642
|
+
if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
|
|
1643
|
+
throw err
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// GET /api/admin/repositories/history: what the code viewer's History tab
|
|
1648
|
+
// draws (repository-history.ts), read as browsing is, with the reader's own
|
|
1649
|
+
// GitHub access, within their grants as the code is. From a revision (`ref`),
|
|
1650
|
+
// a page at a time (`page`), the commits touching `path` where it names a
|
|
1651
|
+
// directory, with the branches and tags naming commits on the first page; or,
|
|
1652
|
+
// with `all`, across the repository's most recently updated branches, which
|
|
1653
|
+
// needs a GitHub token. A whole repository's history, from a revision or all
|
|
1654
|
+
// branches, is read from commits a page each, kept by the commit each starts
|
|
1655
|
+
// at, and those kept glued on (historyFrom); a later page reads on from the
|
|
1656
|
+
// commits `from` names. The branches and tags naming commits are kept as an
|
|
1657
|
+
// issue's page is (keptAnswer). Access is checked again once it is read,
|
|
1658
|
+
// before any of it is sent, with the app's users its authors are
|
|
1659
|
+
// (github-users.ts).
|
|
1660
|
+
async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1661
|
+
const asked = repositoryQuery(res, query)
|
|
1662
|
+
if (!asked) return
|
|
1663
|
+
const { repoId, ref, path } = asked
|
|
1664
|
+
const page = Number(query.get('page') ?? '1')
|
|
1665
|
+
const all = query.get('all') === 'true'
|
|
1666
|
+
// Every branch's heads read now, not as kept: the client asks so where
|
|
1667
|
+
// they miss the commit shown, as a branch moved since may.
|
|
1668
|
+
const fresh = query.get('fresh') === 'true'
|
|
1669
|
+
// A whole repository's later pages read on from where those before stop:
|
|
1670
|
+
// all branches', always; a revision's, where the client names them.
|
|
1671
|
+
const from = query.get('from')
|
|
1672
|
+
const starts = from === null ? null : historyStarts(from)
|
|
1673
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
|
|
1674
|
+
|| (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1675
|
+
const authorize = async () => {
|
|
1676
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1677
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1678
|
+
return access
|
|
1679
|
+
}
|
|
1680
|
+
const access = await authorize()
|
|
1681
|
+
if (!access) return
|
|
1682
|
+
try {
|
|
1683
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1684
|
+
const repo = access.repo.fullName
|
|
1685
|
+
const token = reader.readToken()
|
|
1686
|
+
// With a token, as GraphQL reads them.
|
|
1687
|
+
const pages = historyPages(deps, repoId, token !== null)
|
|
1688
|
+
let body: object
|
|
1689
|
+
if (all) {
|
|
1690
|
+
if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
|
|
1691
|
+
// What a later page reads on from, the client knows; whether one may
|
|
1692
|
+
// follow, the deepest page says.
|
|
1693
|
+
// Asked for afresh, those kept answered as just read: how long till
|
|
1694
|
+
// they may be read again (`retryIn`), for the client to ask then.
|
|
1695
|
+
const heads = starts ? null : await keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token), undefined, fresh)
|
|
1696
|
+
const retryIn = heads && fresh && heads.kept ? heads.at + REPOSITORY_READ_FLOOR_MS - Date.now() : 0
|
|
1697
|
+
body = starts ? { all: true, page, commits: (await historyFrom(repo, token, starts, pages)).commits, more: page < MAX_HISTORY_PAGE }
|
|
1698
|
+
: { all: true, ...await repositoryAllHistory(repo, token, heads!.value, pages), ...retryIn > 0 ? { retryIn } : {} }
|
|
1699
|
+
} else {
|
|
1700
|
+
const commit = await reader.commit(ref)
|
|
1701
|
+
// The branches and tags naming commits: with a token, every branch All
|
|
1702
|
+
// branches reads and the latest tags, kept for both; without, GitHub's
|
|
1703
|
+
// first page of each.
|
|
1704
|
+
const naming = () => token
|
|
1705
|
+
? keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token)).then(({ value }) => ({
|
|
1706
|
+
branches: value.heads.map(({ name, sha }) => ({ name, sha })), tags: value.tags }))
|
|
1707
|
+
: keptAnswer(deps, `${repoId}\0refs`, () => repositoryRefTips(repo, token)).then(answer => answer.value)
|
|
1708
|
+
const tips = page === 1 ? naming() : null
|
|
1709
|
+
// A whole repository's from its commit, or on from those `from` names
|
|
1710
|
+
// (a page by its number where none are, as GitHub lists them); a
|
|
1711
|
+
// directory's by its number.
|
|
1712
|
+
const history = path !== '' || (page > 1 && !starts) ? repositoryHistory(repo, token, commit, path, page)
|
|
1713
|
+
: historyFrom(repo, token, starts ?? [commit], pages).then(read => ({ commits: read.commits, more: (starts !== null || read.more) && page < MAX_HISTORY_PAGE }))
|
|
1714
|
+
const [read, named] = await Promise.all([history, tips])
|
|
1715
|
+
body = { commit, path, page, ...read, ...named }
|
|
1716
|
+
}
|
|
1717
|
+
// The pull requests squashed into a whole repository's commits, drawn as
|
|
1718
|
+
// the merges they were, and on its first page the open ones' heads,
|
|
1719
|
+
// naming commits: with a token, as far as GitHub answers them, the
|
|
1720
|
+
// history sent all the same where it doesn't.
|
|
1721
|
+
const listed = (body as { commits: HistoryCommit[] }).commits
|
|
1722
|
+
const quiet = <T>(fallback: T) => (err: unknown) => {
|
|
1723
|
+
if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
|
|
1724
|
+
return fallback
|
|
1725
|
+
}
|
|
1726
|
+
const [pulls, openPulls] = token && path === '' ? await Promise.all([
|
|
1727
|
+
squashedPulls(repo, token, listed, historySquashes(deps, repoId)).catch(quiet<SquashedPull[]>([])),
|
|
1728
|
+
page === 1 ? keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null)) : null,
|
|
1729
|
+
]) : [[], null]
|
|
1730
|
+
await reader.recheckAccess()
|
|
1731
|
+
const current = await authorize()
|
|
1732
|
+
if (!current) return
|
|
1733
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1734
|
+
// Each commit as the tab draws it, its co-authors with it: what Pulse
|
|
1735
|
+
// counts of it (its lines, its author's time) not sent.
|
|
1736
|
+
const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
|
|
1737
|
+
({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
|
|
1738
|
+
sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
|
|
1739
|
+
...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...openPulls?.length ? { openPulls } : {} }))
|
|
1740
|
+
} catch (err) {
|
|
1741
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1742
|
+
throw err
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// GET /api/admin/repositories/changes: the files each commit `shas` names
|
|
1747
|
+
// changes, as the code viewer's Replay plays them (repository-changes.ts),
|
|
1748
|
+
// within `path`, each commit's kept whole by the commit (historyChanges);
|
|
1749
|
+
// read as Replay's reads are (readForReplay).
|
|
1750
|
+
async function handleRepositoryChanges(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1751
|
+
const shas = changesAsked(query.get('shas'))
|
|
1752
|
+
if (!shas) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1753
|
+
await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
|
|
1754
|
+
const changes = await repositoryChanges(fullName, token, shas, historyChanges(deps, repoId))
|
|
1755
|
+
return { path, changes: [...changes].map(([sha, read]) => read ? { sha, ...changesWithin(read, path) } : { sha, files: null, more: false }) }
|
|
1756
|
+
})
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// GET /api/admin/repositories/tree: the files commit `commit` holds within
|
|
1760
|
+
// `path` and their bytes (`sizes`), which Replay starts from and the Treemap
|
|
1761
|
+
// of a directory too large to open draws (repository-changes.ts
|
|
1762
|
+
// directoryFiles: the directory's own tree where the commit's is cut short),
|
|
1763
|
+
// kept whole by the commit and the rules it is read by (historyTreeStore,
|
|
1764
|
+
// TREES_READ); none (null) for one GitHub has none of. Read as Replay's
|
|
1765
|
+
// reads are (readForReplay).
|
|
1766
|
+
async function handleRepositoryTree(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1767
|
+
const commit = query.get('commit') ?? ''
|
|
1768
|
+
if (!/^[a-f\d]{40}$/u.test(commit)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1769
|
+
const store = historyTreeStore<CommitTree>(deps.db, deps.config.debug)
|
|
1770
|
+
await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
|
|
1771
|
+
const kept = { read: async (sha: string) => {
|
|
1772
|
+
const value = (await store.read(`${repoId}\0${TREES_READ}\0${sha}`))?.value
|
|
1773
|
+
return value && Array.isArray(value.files) && Array.isArray(value.sizes) && value.sizes.length === value.files.length ? value : null
|
|
1774
|
+
}, write: (sha: string, tree: CommitTree) => store.write(`${repoId}\0${TREES_READ}\0${sha}`, tree, Date.now()) }
|
|
1775
|
+
const tree = await directoryFiles(fullName, token, commit, path, kept)
|
|
1776
|
+
return { commit, path, ...tree ?? { files: null, sizes: null, cut: false } }
|
|
1777
|
+
})
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// What Replay reads of repository `repoId` within `path` (`read`, with its
|
|
1781
|
+
// name and the reader's token), as the history is read: with the reader's
|
|
1782
|
+
// own GitHub access, within their grants as the code is, a directory they
|
|
1783
|
+
// reach all of, and sent once access is checked again.
|
|
1784
|
+
async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams,
|
|
1785
|
+
read: (fullName: string, token: string | null, repoId: number, path: string) => Promise<object>): Promise<void> {
|
|
1786
|
+
const asked = repositoryQuery(res, query)
|
|
1787
|
+
if (!asked) return
|
|
1788
|
+
const { repoId, path } = asked
|
|
1789
|
+
const authorize = async () => {
|
|
1790
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1791
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1792
|
+
return access
|
|
1793
|
+
}
|
|
1794
|
+
const access = await authorize()
|
|
1795
|
+
if (!access) return
|
|
1796
|
+
try {
|
|
1797
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1798
|
+
const body = await read(access.repo.fullName, reader.readToken(), repoId, path)
|
|
1799
|
+
await reader.recheckAccess()
|
|
1800
|
+
const current = await authorize()
|
|
1801
|
+
if (!current) return
|
|
1802
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1803
|
+
sendJson(res, 200, body)
|
|
1804
|
+
} catch (err) {
|
|
1805
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1806
|
+
throw err
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// The files repository `repoId`'s commits change, kept by each commit
|
|
1811
|
+
// (historyChangeStore, repository-changes.ts repositoryChanges).
|
|
1812
|
+
function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
|
|
1813
|
+
const store = historyChangeStore<CommitChanges>(deps.db, deps.config.debug)
|
|
1814
|
+
const keyOf = (sha: string) => `${repoId}\0${sha}`
|
|
1815
|
+
return {
|
|
1816
|
+
async read(shas) {
|
|
1817
|
+
const kept = await store.readMany(shas.map(keyOf))
|
|
1818
|
+
return new Map(shas.flatMap(sha => {
|
|
1819
|
+
const value = kept.get(keyOf(sha))?.value
|
|
1820
|
+
return value && Array.isArray(value.files) ? [[sha, value] as const] : []
|
|
1821
|
+
}))
|
|
1822
|
+
},
|
|
1823
|
+
write: (sha, changes) => store.write(keyOf(sha), changes, Date.now()),
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// How long what readWholeRepository read is answered as kept, unread again;
|
|
1828
|
+
// and how long one read is answered as kept even where it is asked for afresh.
|
|
1829
|
+
const REPOSITORY_READ_FRESH_MS = 2 * 60_000
|
|
1830
|
+
const REPOSITORY_READ_FLOOR_MS = 10_000
|
|
1831
|
+
// The repositories' reads being read again, behind a kept answer or as asked
|
|
1832
|
+
// for afresh, by key: one at a time, those asking meanwhile answered by it.
|
|
1833
|
+
const repositoryRereads = new Map<string, Promise<unknown>>()
|
|
1834
|
+
// A read GitHub refused, not failed: its credential or the GitHub App's
|
|
1835
|
+
// permission for it withdrawn (Issues, Pull requests, Checks), the
|
|
1836
|
+
// repository or what was asked of it gone. What was kept of it goes too.
|
|
1837
|
+
const refusedRead = (err: unknown) => err instanceof GithubApiError
|
|
1838
|
+
&& (err.status === 401 || err.status === 403 || err.status === 404 || err.message === 'github-status-403')
|
|
1839
|
+
|
|
1840
|
+
// What `read` answers of a repository, kept by `key` (its id first) for every
|
|
1841
|
+
// reader (repositoryReadStore), each one's access checked as they ask before
|
|
1842
|
+
// this: answered as kept for REPOSITORY_READ_FRESH_MS, then, while it is
|
|
1843
|
+
// kept, answered at once and read again behind it for the next to ask, where
|
|
1844
|
+
// the instance outlives its answer (not serverless, where it is read again
|
|
1845
|
+
// before it is answered); `kept` where it was, or where reading it again
|
|
1846
|
+
// failed as `keepThrough` lets it. One asked for afresh (`fresh`) is read
|
|
1847
|
+
// now, unless it was read REPOSITORY_READ_FLOOR_MS since, what was kept
|
|
1848
|
+
// answered where that read fails. One GitHub refuses to read again is no
|
|
1849
|
+
// longer kept: the next to ask is refused as GitHub refused it. `at` is when
|
|
1850
|
+
// what it answers was read.
|
|
1851
|
+
async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>,
|
|
1852
|
+
keepThrough: (err: unknown) => boolean = () => false, fresh = false): Promise<{ value: T; kept: boolean; at: number }> {
|
|
1853
|
+
const store = repositoryReadStore(deps.db, deps.config.debug)
|
|
1854
|
+
// One kept longer than a day, not yet removed (pruneKept removes them as
|
|
1855
|
+
// others are written), is none.
|
|
1856
|
+
const stored = await store.read(key)
|
|
1857
|
+
const held = stored && Date.now() - stored.at < REPOSITORY_READ_KEPT_MS ? stored : null
|
|
1858
|
+
// Read, and kept as read when it was, which it is said read at.
|
|
1859
|
+
const readKept = async () => {
|
|
1860
|
+
try {
|
|
1861
|
+
const value = await read()
|
|
1862
|
+
const at = Date.now()
|
|
1863
|
+
await store.write(key, value, at)
|
|
1864
|
+
return { value, at }
|
|
1865
|
+
} catch (err) {
|
|
1866
|
+
if (refusedRead(err)) await store.remove(key)
|
|
1867
|
+
throw err
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
const reread = () => {
|
|
1871
|
+
let reading = repositoryRereads.get(key)
|
|
1872
|
+
if (!reading) {
|
|
1873
|
+
reading = readKept().finally(() => repositoryRereads.delete(key))
|
|
1874
|
+
repositoryRereads.set(key, reading)
|
|
1875
|
+
}
|
|
1876
|
+
return reading as Promise<{ value: T; at: number }>
|
|
1877
|
+
}
|
|
1878
|
+
const age = held ? Date.now() - held.at : Infinity
|
|
1879
|
+
const now = fresh && age >= REPOSITORY_READ_FLOOR_MS
|
|
1880
|
+
if (held && !now && (age < REPOSITORY_READ_FRESH_MS || !deps.config.serverless)) {
|
|
1881
|
+
if (age >= REPOSITORY_READ_FRESH_MS && !repositoryRereads.has(key)) {
|
|
1882
|
+
// Its failure leaves the kept answer sent until it is a day old, so it is said.
|
|
1883
|
+
deps.track(reread().catch((err: unknown) => { console.warn('managed: repository read failed:', err) }))
|
|
1884
|
+
}
|
|
1885
|
+
return { value: held.value as T, kept: true, at: held.at }
|
|
1886
|
+
}
|
|
1887
|
+
try {
|
|
1888
|
+
return { ...now ? await reread() : await readKept(), kept: false }
|
|
1889
|
+
} catch (err) {
|
|
1890
|
+
if (held && ((now && !refusedRead(err)) || keepThrough(err))) return { value: held.value as T, kept: true, at: held.at }
|
|
1891
|
+
throw err
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// The pages of repository `repoId`'s history kept (historyPageStore), each by
|
|
1896
|
+
// the commit it starts at (repository-history.ts historyFrom) and the rules
|
|
1897
|
+
// its co-authors were read by (commit-coauthors.ts COAUTHORS_READ), those
|
|
1898
|
+
// telling each commit's co-authors (kept since they are read), and with
|
|
1899
|
+
// `graphql`, only those read with GraphQL, telling its lines and the accounts
|
|
1900
|
+
// its co-authors' addresses are (a page GitHub's REST API listed tells
|
|
1901
|
+
// neither); the rest read again.
|
|
1902
|
+
function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): HistoryPages {
|
|
1903
|
+
const store = historyPageStore<HistoryCommit[]>(deps.db, deps.config.debug)
|
|
1904
|
+
const keyOf = (top: string) => `${repoId}\0${COAUTHORS_READ}\0${top}`
|
|
1905
|
+
return {
|
|
1906
|
+
async read(tops) {
|
|
1907
|
+
const kept = await store.readMany(tops.map(keyOf))
|
|
1908
|
+
return new Map(tops.flatMap(top => {
|
|
1909
|
+
const page = kept.get(keyOf(top))?.value
|
|
1910
|
+
return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
|
|
1911
|
+
? [[top, page] as const] : []
|
|
1912
|
+
}))
|
|
1913
|
+
},
|
|
1914
|
+
write: (top, commits) => store.write(keyOf(top), commits, Date.now()),
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// The pull requests squashed into repository `repoId`'s commits, kept by
|
|
1919
|
+
// each commit and the rules their commits' co-authors were read by
|
|
1920
|
+
// (COAUTHORS_READ), null for one none was (repository-history.ts
|
|
1921
|
+
// squashedPulls); one kept before its commits' co-authors were read, read
|
|
1922
|
+
// again.
|
|
1923
|
+
function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
|
|
1924
|
+
const store = historySquashStore<SquashedPull | null>(deps.db, deps.config.debug)
|
|
1925
|
+
const keyOf = (sha: string) => `${repoId}\0${COAUTHORS_READ}\0${sha}`
|
|
1926
|
+
return {
|
|
1927
|
+
async read(shas) {
|
|
1928
|
+
const kept = await store.readMany(shas.map(keyOf))
|
|
1929
|
+
return new Map(shas.flatMap(sha => {
|
|
1930
|
+
const entry = kept.get(keyOf(sha))
|
|
1931
|
+
return entry && (entry.value === null || (Array.isArray(entry.value.commits) && entry.value.commits.every(commit => Array.isArray(commit.coauthors))))
|
|
1932
|
+
? [[sha, entry.value] as const] : []
|
|
1933
|
+
}))
|
|
1934
|
+
},
|
|
1935
|
+
write: (sha, pull) => store.write(keyOf(sha), pull, Date.now()),
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// What the whole of repository `repoId` gives in GraphQL (`read`, with its
|
|
1940
|
+
// name and a token), read as browsing is, with the reader's own GitHub
|
|
1941
|
+
// access: only by a reader whose grants reach all of it, with a token, and
|
|
1942
|
+
// sent once access is checked again after it is read; what `key` names kept
|
|
1943
|
+
// (keptAnswer), sent as kept, its reader's access checked as they ask; with
|
|
1944
|
+
// `readAt`, when it was read from GitHub, one kept as old as it was kept, so
|
|
1945
|
+
// that a reader holding two reads of the same can tell the later.
|
|
1946
|
+
async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
|
|
1947
|
+
read: (fullName: string, token: string) => Promise<object>, key: string | null = null, readAt = false): Promise<void> {
|
|
1948
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
1949
|
+
const { value, kept, at } = key === null ? { value: await read(fullName, token), kept: false, at: Date.now() }
|
|
1950
|
+
: await keptAnswer(deps, `${repoId}\0${key}`, () => read(fullName, token))
|
|
1951
|
+
return { value: readAt ? { ...value, readAt: at } : value, kept }
|
|
1952
|
+
})
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// What `answer` gives of the whole of repository `repoId` (with its name and
|
|
1956
|
+
// a token), as readWholeRepository reads it: sent at once where it was kept,
|
|
1957
|
+
// else once access is checked again, with the app's users it names
|
|
1958
|
+
// (github-users.ts), never kept with it.
|
|
1959
|
+
async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
|
|
1960
|
+
answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>): Promise<void> {
|
|
1961
|
+
const authorize = async () => {
|
|
1962
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, '')
|
|
1963
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1964
|
+
return access
|
|
1965
|
+
}
|
|
1966
|
+
const access = await authorize()
|
|
1967
|
+
if (!access) return
|
|
1968
|
+
try {
|
|
1969
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1970
|
+
const token = reader.readToken()
|
|
1971
|
+
if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
|
|
1972
|
+
const answered = await answer(access.repo.fullName, token, access.user)
|
|
1973
|
+
if (answered.kept) { sendJson(res, 200, await withAppUsers(deps.db, answered.value)); return }
|
|
1974
|
+
await reader.recheckAccess()
|
|
1975
|
+
const current = await authorize()
|
|
1976
|
+
if (!current) return
|
|
1977
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1978
|
+
sendJson(res, 200, await withAppUsers(deps.db, answered.value))
|
|
1979
|
+
} catch (err) {
|
|
1980
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1345
1981
|
throw err
|
|
1346
1982
|
}
|
|
1347
1983
|
}
|
|
1348
1984
|
|
|
1985
|
+
// GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
|
|
1986
|
+
// requests tabs show (repository-issues.ts): `kind`'s open or closed
|
|
1987
|
+
// (`state`), a page at a time, on from the cursor `after` names. They are
|
|
1988
|
+
// the whole repository's (readWholeRepository).
|
|
1989
|
+
async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1990
|
+
const repoId = Number(query.get('repoId'))
|
|
1991
|
+
const kind = query.get('kind')
|
|
1992
|
+
const state = query.get('state') ?? 'open'
|
|
1993
|
+
const after = query.get('after')
|
|
1994
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
|
|
1995
|
+
|| (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1996
|
+
await readWholeRepository(res, deps, cookie, repoId, async (fullName, token) => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }),
|
|
1997
|
+
`issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`, true)
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// GET /api/admin/repositories/agents: what the code viewer's Agents tab
|
|
2001
|
+
// reconstructs agents' sessions from (repository-agents.ts): a page
|
|
2002
|
+
// of pull requests on from the cursor `pulls` names, and of the default
|
|
2003
|
+
// branch's commits on from `commits`, the first page, naming neither, with
|
|
2004
|
+
// issues and branches. They are the whole repository's (readWholeRepository).
|
|
2005
|
+
async function handleRepositoryAgents(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2006
|
+
const repoId = Number(query.get('repoId'))
|
|
2007
|
+
const cursors = { pulls: query.get('pulls'), commits: query.get('commits') }
|
|
2008
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || (cursors.pulls !== null && !ISSUES_CURSOR.test(cursors.pulls))
|
|
2009
|
+
|| (cursors.commits !== null && !COMMITS_CURSOR.test(cursors.commits))) {
|
|
2010
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
2011
|
+
}
|
|
2012
|
+
await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryAgents(fullName, token, cursors),
|
|
2013
|
+
`agents\0${AGENTS_READ_VERSION}\0${cursors.pulls ?? ''}\0${cursors.commits ?? ''}`)
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// GET /api/admin/repositories/item: the issue or pull request numbered
|
|
2017
|
+
// `number`, as the code viewer's popup for it shows it (repository-issues.ts
|
|
2018
|
+
// repositoryItem). It is the whole repository's (readWholeRepository).
|
|
2019
|
+
async function handleRepositoryItem(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2020
|
+
const repoId = Number(query.get('repoId'))
|
|
2021
|
+
const number = itemNumber(query)
|
|
2022
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2023
|
+
await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryItem(fullName, token, number), `item\0${ISSUES_READ_VERSION}\0${number}`, true)
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// The issue's or pull request's number a request names, as GraphQL takes one
|
|
2027
|
+
// (an Int, at most 2³¹ − 1); null for none.
|
|
2028
|
+
function itemNumber(query: URLSearchParams): number | null {
|
|
2029
|
+
const number = Number(query.get('number'))
|
|
2030
|
+
return /^[1-9]\d{0,9}$/u.test(query.get('number') ?? '') && number <= 2 ** 31 - 1 ? number : null
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// GET /api/admin/repositories/pull-dependencies: the package versions pull
|
|
2034
|
+
// request `number`'s lockfiles add (pull-dependencies.ts), what its base's and head's
|
|
2035
|
+
// commits tell kept by them (pullDependencyKept), its `number` with them.
|
|
2036
|
+
// They are the whole repository's (answerWholeRepository), kept by its number
|
|
2037
|
+
// as its popup's item is, its head moving with each push. With `review=1`,
|
|
2038
|
+
// as an open one's are asked, where the repository reviews dependencies,
|
|
2039
|
+
// each version's review (pull-dependency-reviews.ts), read afresh as each
|
|
2040
|
+
// asks, reviews being published meanwhile; its baseline kept, where the host
|
|
2041
|
+
// registered no reviewed dependencies, which are asked in its place.
|
|
2042
|
+
async function handleRepositoryPullDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2043
|
+
const repoId = Number(query.get('repoId'))
|
|
2044
|
+
const number = itemNumber(query)
|
|
2045
|
+
const review = query.get('review') === '1'
|
|
2046
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null || !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2047
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
|
|
2048
|
+
const answered = await keptAnswer(deps, `${repoId}\0pull-dependencies\0${PULL_DEPENDENCIES_READ}\0${number}`,
|
|
2049
|
+
async () => ({ number, ...await pullDependencies(fullName, token, number, pullDependencyKept(deps, repoId)) }))
|
|
2050
|
+
// Where its review is on, those of lockfiles it leaves out told to none,
|
|
2051
|
+
// whoever asks.
|
|
2052
|
+
const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
|
|
2053
|
+
const held = setting?.enabled ? heldDependencies(answered.value, setting.lockfiles) : answered.value
|
|
2054
|
+
const now = review ? await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(held)) : null
|
|
2055
|
+
// Its review by the lockfiles they were told by, though they have changed
|
|
2056
|
+
// since; turned off meanwhile, all of them, as where it is off.
|
|
2057
|
+
const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
|
|
2058
|
+
const told = review && !now && setting?.enabled && !(await deps.db.listRepoDependencyReviews()).get(repoId)?.enabled ? answered.value : held
|
|
2059
|
+
const reviewed = deps.reviewedDependencies ?? null
|
|
2060
|
+
const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
|
|
2061
|
+
// Its comments read for reviews, where the host reads them, while it is
|
|
2062
|
+
// open (pull-comment-reviews.ts reads whether it is with them).
|
|
2063
|
+
const parse = reviewed?.parseComments
|
|
2064
|
+
const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
|
|
2065
|
+
// The comment the host formats of the reader's reviews of its versions, for its copy button.
|
|
2066
|
+
const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
|
|
2067
|
+
return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
|
|
2068
|
+
...formatted ? { formatted } : {} }, kept: answered.kept }
|
|
2069
|
+
})
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// GET /api/admin/repositories/branch-dependencies: what the lockfiles of the
|
|
2073
|
+
// repository's default branch lock that wants review, where its review is in
|
|
2074
|
+
// effect for the reader (`review`, else none), as a team's Dependencies tab
|
|
2075
|
+
// shows it: its head, as its pulse reads it, kept a while; every lockfile
|
|
2076
|
+
// there its review holds to (lockfile-scope.ts, commit-lockfiles.ts) as one
|
|
2077
|
+
// directory, its versions as a pull request's others are
|
|
2078
|
+
// (pull-dependency-reviews.ts), each left unread a directory of its own
|
|
2079
|
+
// failing the review, as a pull request's are.
|
|
2080
|
+
async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2081
|
+
const repoId = Number(query.get('repoId'))
|
|
2082
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2083
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
|
|
2084
|
+
const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
|
|
2085
|
+
if (!head) return { value: { head: null, review: null }, kept: false }
|
|
2086
|
+
// Those its review holds to alone read, as many as are read of them.
|
|
2087
|
+
const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
|
|
2088
|
+
if (!setting?.enabled) return { value: { head, review: null }, kept: false }
|
|
2089
|
+
const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path))
|
|
2090
|
+
const { packages, paths } = lockedIn(read)
|
|
2091
|
+
const told = { base: head, head, cut: false, lockfiles: [{ paths, added: [], more: 0, locked: packages.slice(0, MAX_LOCKED), moreLocked: Math.max(0, packages.length - MAX_LOCKED) },
|
|
2092
|
+
...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
|
|
2093
|
+
const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
|
|
2094
|
+
if (!now) return { value: { head, review: null }, kept: false }
|
|
2095
|
+
// Told by the lockfiles it held to as they were read, though they have
|
|
2096
|
+
// changed since.
|
|
2097
|
+
const state = { ...now, lockfiles: setting.lockfiles }
|
|
2098
|
+
const reviewed = deps.reviewedDependencies ?? null
|
|
2099
|
+
const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null } : await baselineFor(fullName, token, repoId, state, baselineKept(deps))
|
|
2100
|
+
return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: false }
|
|
2101
|
+
})
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
|
|
2105
|
+
// default branch its dependency review reads (commit-lockfiles.ts), by their
|
|
2106
|
+
// paths, with their formats, at its head, as its pulse reads it, for its
|
|
2107
|
+
// settings to name those it holds to (lockfile-scope.ts).
|
|
2108
|
+
async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2109
|
+
const repoId = Number(query.get('repoId'))
|
|
2110
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2111
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2112
|
+
const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
|
|
2113
|
+
return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head)).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
|
|
2114
|
+
})
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// The app's users `accounts` are, by their logins in lower case, of those
|
|
2118
|
+
// who publish reviews to a team counting for repository `repoId`.
|
|
2119
|
+
async function repoReviewers(deps: ManagedHttpDeps, repoId: number, accounts: { login: string; githubId: number | null }[]) {
|
|
2120
|
+
const users = await deps.db.listUsersByAccount(accounts)
|
|
2121
|
+
const counted = await deps.db.listRepoReviewers(repoId, [...new Set(Object.values(users).map(user => user.id))])
|
|
2122
|
+
return new Map(Object.entries(users).flatMap(([login, user]) => counted.has(user.id) ? [[login.toLowerCase(), user.id] as const] : []))
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// The versions repository lockfiles locked at the commits their dependencies
|
|
2126
|
+
// are reviewed since, kept by each (reviewBaselineStore).
|
|
2127
|
+
function baselineKept(deps: ManagedHttpDeps): ReviewBaselineStore {
|
|
2128
|
+
const store = reviewBaselineStore(deps.db, deps.config.debug)
|
|
2129
|
+
return {
|
|
2130
|
+
async read(key) {
|
|
2131
|
+
const value = (await store.read(key))?.value
|
|
2132
|
+
const strings = (list: unknown) => Array.isArray(list) && list.every(item => typeof item === 'string')
|
|
2133
|
+
return strings(value?.versions) && strings(value?.unread) ? value : null
|
|
2134
|
+
},
|
|
2135
|
+
write: (key, baseline) => store.write(key, baseline, Date.now()),
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// What repository `repoId`'s pull requests' lockfiles add, kept by the
|
|
2140
|
+
// commits of each's base and head and the rules they were told by
|
|
2141
|
+
// (PULL_DEPENDENCIES_READ, pullDependencyStore).
|
|
2142
|
+
function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependencyStore {
|
|
2143
|
+
const store = pullDependencyStore<PullDependencies>(deps.db, deps.config.debug)
|
|
2144
|
+
const keyOf = (commits: string) => `${repoId}\0${PULL_DEPENDENCIES_READ}\0${commits}`
|
|
2145
|
+
return {
|
|
2146
|
+
async read(commits) {
|
|
2147
|
+
const value = (await store.read(keyOf(commits)))?.value
|
|
2148
|
+
return value && Array.isArray(value.lockfiles) ? value : null
|
|
2149
|
+
},
|
|
2150
|
+
write: (commits, told) => store.write(keyOf(commits), told, Date.now()),
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
|
|
2155
|
+
// (repository-pulse.ts): the default branch's year, counted from its commits
|
|
2156
|
+
// (pulseCommits), and the pull requests and issues of the last `weeks`
|
|
2157
|
+
// weeks, each kept apart. A year not read whole yet answers how many of its
|
|
2158
|
+
// commits are (`reading`), or of the pull requests merged in it (`pulls`);
|
|
2159
|
+
// one GitHub's statistics count, while it is still computing them,
|
|
2160
|
+
// `pending`, for the client to ask again. They are the whole repository's
|
|
2161
|
+
// (readWholeRepository).
|
|
2162
|
+
async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2163
|
+
const repoId = Number(query.get('repoId'))
|
|
2164
|
+
const weeks = Number(query.get('weeks') ?? '4')
|
|
2165
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2166
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2167
|
+
const [period, year] = await Promise.all([
|
|
2168
|
+
keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => repositoryActivity(fullName, token, weeks)),
|
|
2169
|
+
pulseCommits(deps, repoId, fullName, token),
|
|
2170
|
+
])
|
|
2171
|
+
return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
|
|
2172
|
+
pending: year.pending }, kept: period.kept && year.kept }
|
|
2173
|
+
})
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// The default branch's year (repository-pulse.ts commitStats), counted from
|
|
2177
|
+
// its commits as the Commits tab keeps them, with their lines
|
|
2178
|
+
// (yearCommits): kept by the head it was read from (repositoryReadStore),
|
|
2179
|
+
// that head kept as an issue's page is; its commits merged from an agent's
|
|
2180
|
+
// pull request told by those merged (agentMerges), kept as read, the rest
|
|
2181
|
+
// read on by the next answer as the year's commits are, and read again as
|
|
2182
|
+
// an answer kept is, a label changing whose a pull request is without
|
|
2183
|
+
// moving the head, the year counted again where what they bring changed.
|
|
2184
|
+
// A year not read whole yet is how many of its commits are (`reading`), or
|
|
2185
|
+
// of the pull requests merged (`pulls`), beside the year last counted from
|
|
2186
|
+
// an earlier head over the same weeks, where one is kept, so that a head
|
|
2187
|
+
// moved on shows the year as it was till it is counted again; one past
|
|
2188
|
+
// YEAR_COMMITS is GitHub's statistics' (repositoryStats, repositoryLines),
|
|
2189
|
+
// kept together as an issue's page is and through GitHub computing them
|
|
2190
|
+
// again, `pending` while it first computes them, the year last counted
|
|
2191
|
+
// beside it so too.
|
|
2192
|
+
// A day as a pulse's items are narrowed by (YYYY-MM-DD, UTC), when it starts;
|
|
2193
|
+
// a GitHub login, as many of them as PULSE_AUTHORS.
|
|
2194
|
+
const PULSE_DAY = /^\d{4}-\d{2}-\d{2}$/u
|
|
2195
|
+
const GITHUB_LOGIN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?:\[bot\])?$/iu
|
|
2196
|
+
const PULSE_AUTHORS = 10
|
|
2197
|
+
const pulseDay = (value: string | null) => {
|
|
2198
|
+
const at = value !== null && PULSE_DAY.test(value) ? Date.parse(`${value}T00:00:00Z`) : Number.NaN
|
|
2199
|
+
return Number.isSafeInteger(at) ? at : null
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// GET /api/admin/repositories/pulse/items: the Pulse tab's pull requests and
|
|
2203
|
+
// issues narrowed to a picked day or week (`from` to `to`, else on from
|
|
2204
|
+
// `from`) and the GitHub accounts a picked contributor committed as
|
|
2205
|
+
// (`authors`), as repositoryActivity reads them, kept apart for each.
|
|
2206
|
+
async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2207
|
+
const repoId = Number(query.get('repoId'))
|
|
2208
|
+
const weeks = Number(query.get('weeks') ?? '4')
|
|
2209
|
+
const [from, to] = [pulseDay(query.get('from')), query.has('to') ? pulseDay(query.get('to')) : null]
|
|
2210
|
+
const logins = (query.get('authors') ?? '').split(',').filter(Boolean)
|
|
2211
|
+
// Each once, whatever its case, as GitHub matches logins.
|
|
2212
|
+
const authors = [...new Set(logins.map(login => login.toLowerCase()))].toSorted()
|
|
2213
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks) || from === null || (query.has('to') && (to === null || to < from))
|
|
2214
|
+
|| authors.length > PULSE_AUTHORS || !logins.every(login => GITHUB_LOGIN.test(login))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2215
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2216
|
+
const narrowing = { from, to, authors }
|
|
2217
|
+
const items = await keptAnswer(deps, `${repoId}\0pulse-items\0${weeks}\0${from}\0${to ?? ''}\0${narrowing.authors.join(',')}`,
|
|
2218
|
+
() => repositoryActivity(fullName, token, weeks, Date.now(), globalThis.fetch, narrowing))
|
|
2219
|
+
return { value: { weeks, ...items.value }, kept: items.kept }
|
|
2220
|
+
})
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
|
|
2224
|
+
const first = pulseSince(52, Date.now())
|
|
2225
|
+
const head = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
|
|
2226
|
+
if (!head.value.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
|
|
2227
|
+
const store = repositoryReadStore(deps.db, deps.config.debug)
|
|
2228
|
+
const key = `${repoId}\0pulse-year\0${PULSE_YEAR_VERSION}\0${head.value.sha}\0${first}`
|
|
2229
|
+
const lastKey = `${repoId}\0pulse-year-last`
|
|
2230
|
+
const mergesKey = `${repoId}\0pulse-merges\0${PULSE_YEAR_VERSION}`
|
|
2231
|
+
const last = async () => {
|
|
2232
|
+
const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
|
|
2233
|
+
return kept?.first === first ? kept.stats : null
|
|
2234
|
+
}
|
|
2235
|
+
const sha = head.value.sha
|
|
2236
|
+
// What tells whose the year's commits are, kept as read: the agents' pull
|
|
2237
|
+
// requests merged (agentMerges), and the commits that release the
|
|
2238
|
+
// repository (`releases`, release-commits.ts releaseCommits).
|
|
2239
|
+
type Attribution = AgentMerges & { releases?: string[] }
|
|
2240
|
+
const keptAttribution = async () => ((await store.read(mergesKey))?.value as Attribution | undefined) ?? null
|
|
2241
|
+
const attribution = async (kept: Attribution | null): Promise<Attribution> => {
|
|
2242
|
+
const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept), releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
|
|
2243
|
+
return { ...merges, releases }
|
|
2244
|
+
}
|
|
2245
|
+
// The year counted from its `commits` as `read` tells whose they are and
|
|
2246
|
+
// which were pushed directly, the branch followed through those it was
|
|
2247
|
+
// read on past (`passed`, yearCommits) too; kept by its head and as the
|
|
2248
|
+
// year last counted.
|
|
2249
|
+
const count = async ({ commits, passed }: { commits: HistoryCommit[]; passed: HistoryCommit[] }, read: Attribution) => {
|
|
2250
|
+
const walked = [...commits, ...passed]
|
|
2251
|
+
const stats = commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha))
|
|
2252
|
+
await Promise.all([store.write(key, stats, Date.now()), store.write(lastKey, { first, stats }, Date.now())])
|
|
2253
|
+
return stats
|
|
2254
|
+
}
|
|
2255
|
+
// A year kept by its head, what tells whose its commits are read again as
|
|
2256
|
+
// an answer kept is (keptAnswer), a label or a tag changing it without
|
|
2257
|
+
// moving the head: counted again from its pages kept where what agents'
|
|
2258
|
+
// pull requests bring, or the releases, changed, or a read of the pull
|
|
2259
|
+
// requests left unfinished is finished; the year as kept answered
|
|
2260
|
+
// meanwhile, and where reading them fails.
|
|
2261
|
+
const relabelled = async (stats: PulseStats) => {
|
|
2262
|
+
const recounted: { stats?: PulseStats } = {}
|
|
2263
|
+
await keptAnswer(deps, mergesKey, async () => {
|
|
2264
|
+
const kept = await keptAttribution()
|
|
2265
|
+
const read = await attribution(kept)
|
|
2266
|
+
const same = kept && !kept.next && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join()
|
|
2267
|
+
if (read.next || same) return read
|
|
2268
|
+
const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
|
|
2269
|
+
if (year.complete) recounted.stats = await count(year, read)
|
|
2270
|
+
return read
|
|
2271
|
+
}, () => true).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
|
|
2272
|
+
return recounted.stats ?? stats
|
|
2273
|
+
}
|
|
2274
|
+
const held = (await store.read(key))?.value as PulseStats | { tooMany: true } | undefined
|
|
2275
|
+
if (held && !('tooMany' in held)) return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
|
|
2276
|
+
if (!held) {
|
|
2277
|
+
const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
|
|
2278
|
+
if (year.complete) {
|
|
2279
|
+
const read = await attribution(await keptAttribution())
|
|
2280
|
+
await store.write(mergesKey, read, Date.now())
|
|
2281
|
+
if (read.next) return { stats: await last(), reading: null, pulls: read.next.read, pending: false, kept: false }
|
|
2282
|
+
return { stats: await count(year, read), reading: null, pulls: null, pending: false, kept: false }
|
|
2283
|
+
}
|
|
2284
|
+
if (!year.tooMany) return { stats: await last(), reading: year.read, pulls: null, pending: false, kept: false }
|
|
2285
|
+
await store.write(key, { tooMany: true }, Date.now())
|
|
2286
|
+
}
|
|
2287
|
+
// Its commits and lines read together, kept as one: never the one GitHub
|
|
2288
|
+
// has counted since a push beside the other kept from before it.
|
|
2289
|
+
const computing = (err: unknown) => err instanceof StatsPending
|
|
2290
|
+
const counted = await keptAnswer(deps, `${repoId}\0pulse-stats`, async () => {
|
|
2291
|
+
const [stats, lines] = await Promise.all([repositoryStats(fullName, token), repositoryLines(fullName, token, first)])
|
|
2292
|
+
return withLines(stats, lines)
|
|
2293
|
+
}, computing).catch((err: unknown) => { if (computing(err)) return null; throw err })
|
|
2294
|
+
if (counted) return { stats: counted.value, reading: null, pulls: null, pending: false, kept: counted.kept }
|
|
2295
|
+
return { stats: await last(), reading: null, pulls: null, pending: true, kept: false }
|
|
2296
|
+
}
|
|
2297
|
+
|
|
1349
2298
|
function prebuildBundle(deps: ManagedHttpDeps, id: string) {
|
|
1350
2299
|
if (!deps.bundleCache || deps.config.serverless) return
|
|
1351
2300
|
deps.track((async () => {
|
|
@@ -1420,9 +2369,13 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
|
|
|
1420
2369
|
// (repository-browser.ts), and that access checked again once it is read,
|
|
1421
2370
|
// before any of it is told; packages as the npm viewer's admins read them, in
|
|
1422
2371
|
// spare assessments (npm-risk.ts), trusted however new where a team lists
|
|
1423
|
-
// their scope, and each lockfile's asked of npm's advisories at once
|
|
1424
|
-
//
|
|
1425
|
-
// it
|
|
2372
|
+
// their scope, and each lockfile's asked of npm's advisories at once; each
|
|
2373
|
+
// repository's Dependabot alerts, open or dismissed, read with the App's
|
|
2374
|
+
// installation token where the admin's own access to it lets them see them on
|
|
2375
|
+
// GitHub (dependabot-access.ts); and its GitHub Actions, the references they
|
|
2376
|
+
// make resolved with the admin's own GitHub token (github-actions.ts). The
|
|
2377
|
+
// reader leaving, losing their admin role or
|
|
2378
|
+
// the server shutting down stops it.
|
|
1426
2379
|
async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1427
2380
|
const s = await readAdminSession(res, deps, cookie)
|
|
1428
2381
|
if (!s) return
|
|
@@ -1435,7 +2388,8 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1435
2388
|
const controller = new AbortController()
|
|
1436
2389
|
res.on('close', () => { controller.abort() })
|
|
1437
2390
|
res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
|
|
1438
|
-
const
|
|
2391
|
+
const user = await repositoryBrowserUser(deps, s.user.id)
|
|
2392
|
+
const browser = createRepositoryBrowser(deps.config, user)
|
|
1439
2393
|
const scopes = new Map<string, Promise<boolean>>()
|
|
1440
2394
|
const teamScoped = (name: string) => {
|
|
1441
2395
|
const scope = npmPackageScope(name)
|
|
@@ -1460,8 +2414,15 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1460
2414
|
lockfiles: async () => (await upstreamGithub(() => client.findRepoLockfiles({ repo: repo.fullName, sha: commit }))).lockfiles,
|
|
1461
2415
|
file: path => upstreamGithub(() => client.getRepoFile({ repo: repo.fullName, path, ref: commit })),
|
|
1462
2416
|
recheck: async () => { await reader.recheckAccess() },
|
|
2417
|
+
// As GitHub has them now, where the admin's own access lets them see them.
|
|
2418
|
+
dependabot: () => reader.dependabotAlerts(),
|
|
2419
|
+
workflows: () => reader.directory(WORKFLOWS_DIR, commit).then(({ entries }) => entries.filter(entry => entry.type === 'file' && /\.ya?ml$/iu.test(entry.name)).map(entry => entry.path),
|
|
2420
|
+
(error: unknown) => { if (error instanceof GithubApiError && error.status === 404) return []; throw error }),
|
|
1463
2421
|
}
|
|
1464
2422
|
},
|
|
2423
|
+
actions: githubActionsSource(user.token),
|
|
2424
|
+
actionFiles: scanReadStore(deps.db, 'action-files', SCAN_READ_KEPT_MS, deps.config.debug),
|
|
2425
|
+
actionLines: scanReadStore(deps.db, 'actions', RISK_KEPT_MS, deps.config.debug),
|
|
1465
2426
|
teamScoped,
|
|
1466
2427
|
// Each package's downloads readied before it is assessed
|
|
1467
2428
|
// (scan-downloads.ts), so that none waits on npm's downloads API; a
|
|
@@ -2713,6 +3674,11 @@ function found<T>(value: T | null | undefined): T {
|
|
|
2713
3674
|
return value
|
|
2714
3675
|
}
|
|
2715
3676
|
|
|
3677
|
+
// Version documents a list of reviews reads at once: each holds a version
|
|
3678
|
+
// document's room in what npm's reads may hold at once (npm-packages.ts),
|
|
3679
|
+
// which refuses past thirty-two of them.
|
|
3680
|
+
const REVIEW_READS_AT_ONCE = 8
|
|
3681
|
+
|
|
2716
3682
|
// Lists must enforce the same target and base access as the review itself,
|
|
2717
3683
|
// including permission changes while the registry requests are in flight.
|
|
2718
3684
|
async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[]): Promise<T[] | null> {
|
|
@@ -2722,18 +3688,15 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
|
|
|
2722
3688
|
try {
|
|
2723
3689
|
const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
|
|
2724
3690
|
if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2725
|
-
const documents = new Map<string, Promise<NpmVersionDocument | null>>()
|
|
2726
3691
|
// Public versions' documents as the viewer keeps them (npm-kept.ts), a
|
|
2727
|
-
// day: a list asks npm for none it read within it.
|
|
3692
|
+
// day: a list asks npm for none it read within it. Each version once, as
|
|
3693
|
+
// many at once as REVIEW_READS_AT_ONCE.
|
|
2728
3694
|
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
2729
|
-
const
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
}
|
|
2734
|
-
const packages = await Promise.all(reviews.map(review => Promise.all([
|
|
2735
|
-
read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
|
|
2736
|
-
])))
|
|
3695
|
+
const versionsOf = (review: T) => [[review.package, review.version], ...review.basePackage ? [[review.basePackage, review.baseVersion!]] : []] as [string, string][]
|
|
3696
|
+
const asked = new Map(reviews.flatMap(versionsOf).map(([name, version]) => [`${name}\0${version}`, [name, version] as const]))
|
|
3697
|
+
const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
|
|
3698
|
+
const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
|
|
3699
|
+
const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
|
|
2737
3700
|
const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
|
|
2738
3701
|
if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2739
3702
|
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
@@ -2882,7 +3845,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2882
3845
|
const allBases = params.get('allBases') === 'true'
|
|
2883
3846
|
const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
|
|
2884
3847
|
const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
|
|
2885
|
-
if (reviews) sendJson(res, 200, { reviews, teams: await deps.db.listDependencyReviewTeams(current.userId) })
|
|
3848
|
+
if (reviews) sendJson(res, 200, { reviews: await withFormatted(deps.reviewedDependencies ?? null, current.userId, reviews), teams: await deps.db.listDependencyReviewTeams(current.userId) })
|
|
2886
3849
|
return
|
|
2887
3850
|
}
|
|
2888
3851
|
const checked = await checkMutation(req, res, deps, cookie)
|
|
@@ -2892,7 +3855,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2892
3855
|
sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
|
|
2893
3856
|
}
|
|
2894
3857
|
const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
|
|
2895
|
-
sendJson(res, 200, { review })
|
|
3858
|
+
sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
|
|
2896
3859
|
} catch (err) {
|
|
2897
3860
|
if (controller.signal.aborted) return
|
|
2898
3861
|
if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
|
|
@@ -2916,7 +3879,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2916
3879
|
if (!isNpmPackageName(name) || !isNpmPackageName(otherName) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
|
|
2917
3880
|
|| (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
|
|
2918
3881
|
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
|
|
2919
|
-
|| (path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
|
|
3882
|
+
|| ((path === NPM_COMPARISON_PATH || path === NPM_DELTA_PATH) && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
|
|
2920
3883
|
sendJson(res, 400, { error: 'bad-package' }); return
|
|
2921
3884
|
}
|
|
2922
3885
|
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
@@ -3020,6 +3983,20 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3020
3983
|
return
|
|
3021
3984
|
}
|
|
3022
3985
|
if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
|
|
3986
|
+
// The lines it changes against `otherVersion`, or `(empty)`, its whole
|
|
3987
|
+
// package's, and those but for declaration files changed in types alone
|
|
3988
|
+
// (npm-delta.ts), as a list of versions wanting reviews tells them.
|
|
3989
|
+
if (path === NPM_DELTA_PATH) {
|
|
3990
|
+
const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
3991
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
3992
|
+
const side = (version: NpmVersionDocument) => ({ integrity: version.dist.integrity, files: async () => {
|
|
3993
|
+
const load = loadNpmFiles(version)
|
|
3994
|
+
try { return await load.result } finally { load.release() }
|
|
3995
|
+
} })
|
|
3996
|
+
const delta = await npmDelta(old ? side(old) : null, side(doc), npmDeltaStore<NpmDelta>(deps.db, deps.config.debug))
|
|
3997
|
+
await reply(doc.private || old?.private === true, { name, version: doc.version, otherVersion: old?.version ?? null, ...delta })
|
|
3998
|
+
return
|
|
3999
|
+
}
|
|
3023
4000
|
// Its files paired with those of `otherVersion` (of `otherName`, else the
|
|
3024
4001
|
// same package), as Compare and Review pair them, with what their
|
|
3025
4002
|
// Overview reads beside them (comparison.ts): the version shown against
|
|
@@ -3207,6 +4184,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3207
4184
|
if (path === CALLBACK_PATH) {
|
|
3208
4185
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3209
4186
|
try {
|
|
4187
|
+
if (isLinkOAuthCallback(config, url.searchParams, cookie)) {
|
|
4188
|
+
if (viewing) { sendViewOnly(res); return }
|
|
4189
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
4190
|
+
if (!s) return
|
|
4191
|
+
const result = await linkOAuthCallback(config, db, s.session, url.searchParams, cookie, { avatarStore })
|
|
4192
|
+
if (result.linked != null) await activity(deps, s.user, 'access', `linked GitHub account @${result.linked}`)
|
|
4193
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
4194
|
+
res.end(); return
|
|
4195
|
+
}
|
|
3210
4196
|
if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
|
|
3211
4197
|
if (viewing) { sendViewOnly(res); return }
|
|
3212
4198
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
@@ -3234,6 +4220,17 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3234
4220
|
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
3235
4221
|
res.end(); return
|
|
3236
4222
|
}
|
|
4223
|
+
if (path === LINK_LOGIN_PATH) {
|
|
4224
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4225
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
4226
|
+
if (!s) return
|
|
4227
|
+
const result = linkLoginRedirect(config, s.session)
|
|
4228
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
4229
|
+
res.end(); return
|
|
4230
|
+
}
|
|
4231
|
+
if (path === PROFILE_PATH) { await handleProfile(req, res, deps, cookie); return }
|
|
4232
|
+
const unlink = /^\/api\/auth\/profile\/accounts\/([a-f\d-]{36})$/iu.exec(path)
|
|
4233
|
+
if (unlink) { await handleUnlinkAccount(req, res, deps, cookie, unlink[1]!); return }
|
|
3237
4234
|
if (path === SESSION_PATH) {
|
|
3238
4235
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3239
4236
|
const { body, headers } = await currentSession(config, db, cookie)
|
|
@@ -3274,12 +4271,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3274
4271
|
if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
|
|
3275
4272
|
// Cached avatar by user id, served same-origin (the page CSP forbids the
|
|
3276
4273
|
// github CDN). The id in the path keys the browser cache per user, so a user
|
|
3277
|
-
// switch never serves a stale avatar. Workspace access is required.
|
|
4274
|
+
// switch never serves a stale avatar. Workspace access is required. A user's
|
|
4275
|
+
// is that of the account chosen on their profile; `own` asks for the
|
|
4276
|
+
// sign-in account's. A linked account's is served by its own id.
|
|
3278
4277
|
if (path.startsWith(AVATAR_PREFIX)) {
|
|
3279
4278
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3280
4279
|
const s = await readSession(config, db, cookie, Date.now())
|
|
3281
4280
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3282
|
-
|
|
4281
|
+
const id = path.slice(AVATAR_PREFIX.length)
|
|
4282
|
+
if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
|
|
4283
|
+
await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
|
|
3283
4284
|
return
|
|
3284
4285
|
}
|
|
3285
4286
|
// Admin: list users (admin-only).
|
|
@@ -3320,8 +4321,57 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3320
4321
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3321
4322
|
await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
|
|
3322
4323
|
}
|
|
4324
|
+
if (path === REPO_CODE_PATH) {
|
|
4325
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4326
|
+
await handleRepositoryCode(res, deps, cookie, url.searchParams); return
|
|
4327
|
+
}
|
|
4328
|
+
if (path === REPO_HISTORY_PATH) {
|
|
4329
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4330
|
+
await handleRepositoryHistory(res, deps, cookie, url.searchParams); return
|
|
4331
|
+
}
|
|
4332
|
+
if (path === REPO_CHANGES_PATH) {
|
|
4333
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4334
|
+
await handleRepositoryChanges(res, deps, cookie, url.searchParams); return
|
|
4335
|
+
}
|
|
4336
|
+
if (path === REPO_TREE_PATH) {
|
|
4337
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4338
|
+
await handleRepositoryTree(res, deps, cookie, url.searchParams); return
|
|
4339
|
+
}
|
|
4340
|
+
if (path === REPO_ISSUES_PATH) {
|
|
4341
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4342
|
+
await handleRepositoryIssues(res, deps, cookie, url.searchParams); return
|
|
4343
|
+
}
|
|
4344
|
+
if (path === REPO_AGENTS_PATH) {
|
|
4345
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4346
|
+
await handleRepositoryAgents(res, deps, cookie, url.searchParams); return
|
|
4347
|
+
}
|
|
4348
|
+
if (path === REPO_ITEM_PATH) {
|
|
4349
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4350
|
+
await handleRepositoryItem(res, deps, cookie, url.searchParams); return
|
|
4351
|
+
}
|
|
4352
|
+
if (path === REPO_PULL_DEPENDENCIES_PATH) {
|
|
4353
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4354
|
+
await handleRepositoryPullDependencies(res, deps, cookie, url.searchParams); return
|
|
4355
|
+
}
|
|
4356
|
+
if (path === REPO_BRANCH_DEPENDENCIES_PATH) {
|
|
4357
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4358
|
+
await handleRepositoryBranchDependencies(res, deps, cookie, url.searchParams); return
|
|
4359
|
+
}
|
|
4360
|
+
if (path === REPO_LOCKFILES_PATH) {
|
|
4361
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4362
|
+
await handleRepositoryLockfiles(res, deps, cookie, url.searchParams); return
|
|
4363
|
+
}
|
|
4364
|
+
if (path === REPO_PULSE_PATH) {
|
|
4365
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4366
|
+
await handleRepositoryPulse(res, deps, cookie, url.searchParams); return
|
|
4367
|
+
}
|
|
4368
|
+
if (path === REPO_PULSE_ITEMS_PATH) {
|
|
4369
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4370
|
+
await handleRepositoryPulseItems(res, deps, cookie, url.searchParams); return
|
|
4371
|
+
}
|
|
3323
4372
|
if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
|
|
3324
4373
|
if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
|
|
4374
|
+
if (path === REPO_DEPENDENCY_REVIEW_PATH) { await handleRepositoryDependencyReview(req, res, deps, cookie); return }
|
|
3325
4375
|
if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
|
|
3326
4376
|
if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
|
|
3327
4377
|
// Reports: list / upload on the exact path, download / delete per-id on the
|
|
@@ -3513,7 +4563,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3513
4563
|
}
|
|
3514
4564
|
const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
|
|
3515
4565
|
const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
|
|
3516
|
-
if (visible) sendJson(res, 200, { review: visible[0] ?? null })
|
|
4566
|
+
if (visible) sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, s.user.id, visible))[0] ?? null })
|
|
3517
4567
|
return
|
|
3518
4568
|
}
|
|
3519
4569
|
if (path === '/api/npm/reviews/discard') {
|
|
@@ -3543,7 +4593,27 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3543
4593
|
}
|
|
3544
4594
|
const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
3545
4595
|
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
|
|
3546
|
-
if (reviews) sendJson(res, 200, { ...page, reviews })
|
|
4596
|
+
if (reviews) sendJson(res, 200, { ...page, reviews: await withFormatted(deps.reviewedDependencies ?? null, s.user.id, reviews) })
|
|
4597
|
+
return
|
|
4598
|
+
}
|
|
4599
|
+
// Those published to a team, of one who reads them there, as its
|
|
4600
|
+
// Dependencies tab lists them, fifty a page.
|
|
4601
|
+
if (path === '/api/npm/reviews/team') {
|
|
4602
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4603
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
4604
|
+
if (!s) return
|
|
4605
|
+
const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
|
|
4606
|
+
if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
|
|
4607
|
+
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
4608
|
+
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
4609
|
+
}
|
|
4610
|
+
const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
4611
|
+
if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
4612
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
|
|
4613
|
+
if (!reviews) return
|
|
4614
|
+
// Its access asked again, as it may have been taken while npm was read.
|
|
4615
|
+
if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
4616
|
+
sendJson(res, 200, { ...page, reviews })
|
|
3547
4617
|
return
|
|
3548
4618
|
}
|
|
3549
4619
|
if (path === '/api/npm/reviews') {
|