@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +25 -2
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/line-diff.js +5 -4
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +88 -7
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +3 -0
- package/common/workspace-app.js +6 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4UFAMJAX.js +1 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7XRR4OFU.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-BOIXMUU5.js +3 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-CC4OM6TZ.js +3 -0
- package/out/chunk-G24A42V6.js +2 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -1
- package/out/chunk-JKGQ3O7O.js +6 -0
- package/out/chunk-LE5ZWLG4.js +235 -0
- package/out/chunk-MSION3G4.js +36 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-S6GIXWQW.js +52 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-V3KWNNMY.js +1 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +30 -34
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +53 -6
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +71 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +1 -1
- package/risk/npm/compare.js +34 -10
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/bundle-cache.ts +7 -1
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +72 -0
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +66 -19
- package/server-managed/db-neon.ts +89 -21
- package/server-managed/db-schema.ts +13 -2
- package/server-managed/db.ts +9 -1
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +78 -18
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +33 -13
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +1251 -97
- package/server-managed/index.ts +13 -8
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/locked-packages.ts +37 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +178 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +83 -6
- package/server-managed/npm-loads.ts +114 -37
- package/server-managed/npm-packages.ts +110 -64
- package/server-managed/npm-risk.ts +4 -1
- package/server-managed/pretty-print.ts +67 -23
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +300 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +16 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +42 -9
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +166 -37
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +157 -0
- package/server-managed/repository-history.ts +461 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +439 -0
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +133 -118
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +27 -9
- package/server-managed/sql.ts +6 -3
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-app.ts +19 -3
- package/server-managed/team-reports.ts +24 -12
- package/server-managed/upstream-cache.ts +12 -0
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +25 -5
- package/out/chunk-2ABLKCOY.js +0 -46
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-5LSXOLUS.js +0 -1
- package/out/chunk-5RCM2J63.js +0 -1
- package/out/chunk-7B6HB4D7.js +0 -1
- package/out/chunk-AJ4GTATZ.js +0 -3
- package/out/chunk-CH4UN6WK.js +0 -225
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-EDKLBQQN.js +0 -17
- package/out/chunk-F47DXCEV.js +0 -33
- package/out/chunk-FK3DZEKL.js +0 -1
- package/out/chunk-G6AJWENO.js +0 -1
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-GZWM7UQM.js +0 -2
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LPCTJYNA.js +0 -7
- package/out/chunk-O4RJY5TP.js +0 -2
- package/out/chunk-OD4LPN3H.js +0 -1
- package/out/chunk-QWDWTBM7.js +0 -1
- package/out/chunk-QYWKC557.js +0 -1
- package/out/chunk-R75XOKUP.js +0 -1
- package/out/chunk-SWTLADW5.js +0 -1
- package/out/chunk-T7OKH4VW.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UTR2LVME.js +0 -2
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-ZESLQCTF.js +0 -6
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,49 @@
|
|
|
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; with numbers=1,2,3 (twenty at most) in place of number, those of each,
|
|
55
|
+
// { pulls }, one GitHub fails told { number, error } | 400/401/403/404/409/429/502
|
|
56
|
+
// GET /api/admin/repositories/branch-dependencies?repoId= → the versions the default branch's lockfiles lock that want review, where
|
|
57
|
+
// its review is in effect for the reader, GitHub not asked where it reviews none | 400/401/403/404/409/429/502
|
|
58
|
+
// GET /api/admin/repositories/lockfiles?repoId= → the lockfiles of the default branch its dependency review reads, by their
|
|
59
|
+
// paths, for its settings to name | 400/401/403/404/409/429/502
|
|
60
|
+
// GET /api/admin/repositories/pulse?repoId=&weeks=1|4|13|52 → the default branch's commits and lines a week and a day over
|
|
61
|
+
// the last year, its contributors and when in the period they commit, counted from its commits as Commits keeps them (how many are
|
|
62
|
+
// read while they are), and the pull requests
|
|
63
|
+
// 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
|
|
64
|
+
// GET /api/admin/repositories/pulse/items?repoId=&weeks=&from=[&to=&authors=] → those pull requests and issues of the days
|
|
65
|
+
// `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
|
|
66
|
+
// History, issues, item, agents and pulse each carry `users`: the app's users and agents the GitHub accounts they
|
|
67
|
+
// name are (github-users.ts), by login
|
|
28
68
|
// POST /api/admin/repositories/remove → admin permanently removes a repo
|
|
29
69
|
// GET /api/admin/repositories/risk[?team=|?repo=] → admin: each connected repo's npm lockfiles, or a team's or one repo's,
|
|
30
70
|
// 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 +104,8 @@
|
|
|
64
104
|
// GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set | 400/401/403/404/409
|
|
65
105
|
// POST /api/npm/reviews/discard → author discards an unpublished review and its file rows | 400/401/403/409
|
|
66
106
|
// GET /api/npm/reviews/latest → latest own review for exact target bytes, across all bases | 400/401/403
|
|
107
|
+
// GET /api/npm/reviews/team?teamId=&beforeTime=&beforeId= → the reviews published to a team, the latest first, for one who
|
|
108
|
+
// reads them there | 400/401/404
|
|
67
109
|
// GET /api/npm/package?name=&version= → a published version's manifest and files | 400/401/404/413/429/502
|
|
68
110
|
// 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
111
|
// GET /api/npm/download?name=&version= → its tarball | 400/401/404/413/429/502
|
|
@@ -82,6 +124,9 @@
|
|
|
82
124
|
// Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
83
125
|
// GET /api/npm/type-only?name=&version=&otherVersion= → the files that changed between two versions in types alone,
|
|
84
126
|
// Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
127
|
+
// GET /api/npm/delta?name=&version=&otherVersion= → the lines a version adds and removes against another, or `(empty)`, and
|
|
128
|
+
// those but for declaration files changed in types alone, where any are, and whether binary files changed
|
|
129
|
+
// | 400/401/404/413/422/429/502/503/504
|
|
85
130
|
// 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
131
|
// POST /api/auth/logout → same-origin + CSRF, drops the session (and any view)
|
|
87
132
|
// POST /api/auth/view-as → admin opens a read-only view as another user | 400/401/403/404
|
|
@@ -90,7 +135,9 @@ import { mergeLinkGroups } from './link-reports.ts'
|
|
|
90
135
|
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
91
136
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
92
137
|
import { pipeline } from 'node:stream/promises'
|
|
93
|
-
import {
|
|
138
|
+
import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmPublishTimeStore, npmVersionStore, pullDependencyStore,
|
|
139
|
+
repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
140
|
+
import { type NpmDelta, npmDelta } from './npm-delta.ts'
|
|
94
141
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
95
142
|
import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
|
|
96
143
|
import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
|
|
@@ -115,6 +162,7 @@ import { isRole, roleAtLeast } from '../common/managed/roles.ts'
|
|
|
115
162
|
import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
|
|
116
163
|
import { filterReportData } from '../common/managed/report-filter.ts'
|
|
117
164
|
import type { TriageEntryPatch } from '../common/managed/triage.ts'
|
|
165
|
+
import { GITHUB_AGENTS } from '../common/managed/github-agents.ts'
|
|
118
166
|
import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
|
|
119
167
|
import { reportRepoGithub } from '@preventive/report'
|
|
120
168
|
import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
|
|
@@ -130,9 +178,11 @@ import type { ConnectedRepo } from './github-app.ts'
|
|
|
130
178
|
import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
|
|
131
179
|
import { RepositoryDiscovery } from './repository-discovery.ts'
|
|
132
180
|
import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines } from './repository-risk.ts'
|
|
133
|
-
import {
|
|
134
|
-
import {
|
|
135
|
-
import {
|
|
181
|
+
import { WORKFLOWS_DIR } from './repository-actions.ts'
|
|
182
|
+
import { githubActionsSource } from './github-actions.ts'
|
|
183
|
+
import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
|
|
184
|
+
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
185
|
+
import { clearCookie, endSession, readSession, runRequestPresence } from './session.ts'
|
|
136
186
|
import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
|
|
137
187
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
138
188
|
import { acceptsReportMetadata } from './report-response.ts'
|
|
@@ -143,7 +193,11 @@ import { lookupIssueLinks } from './github-issue-links.ts'
|
|
|
143
193
|
import { visibleManagedIssues } from './managed-issues.ts'
|
|
144
194
|
import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
|
|
145
195
|
import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
|
|
146
|
-
import {
|
|
196
|
+
import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallback } from './github-link-oauth.ts'
|
|
197
|
+
import type { ProfileChoice } from './github-links.ts'
|
|
198
|
+
import { withAppUsers } from './github-users.ts'
|
|
199
|
+
import { isBlobId } from './object-storage.ts'
|
|
200
|
+
import { attachmentDisposition, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
|
|
147
201
|
import { triageWireEntry } from './triage-response.ts'
|
|
148
202
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
149
203
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
@@ -152,8 +206,28 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
|
152
206
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
153
207
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
154
208
|
import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
|
|
155
|
-
import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions
|
|
209
|
+
import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions,
|
|
210
|
+
readTogether } from './npm-packages.ts'
|
|
156
211
|
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
212
|
+
import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
|
|
213
|
+
import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
|
|
214
|
+
import { COAUTHORS_READ } from './commit-coauthors.ts'
|
|
215
|
+
import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, type SquashedPull, type Squashes, historyFrom, historyStarts, openPullHeads,
|
|
216
|
+
repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
|
|
217
|
+
import { AGENTS_READ_VERSION, APP_AGENTS, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
|
|
218
|
+
import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
|
|
219
|
+
import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
|
|
220
|
+
import { type GithubAsked, lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
|
|
221
|
+
import { MAX_REQUIRED_REVIEWS, SINCE_COMMIT } from './repository-dependency-reviews.ts'
|
|
222
|
+
import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
|
|
223
|
+
import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
|
|
224
|
+
import { formattedPull, withFormatted } from './dependency-review-comments.ts'
|
|
225
|
+
import type { ReviewedDependencies } from './reviewed-dependencies.ts'
|
|
226
|
+
import { commentReviews } from './pull-comment-reviews.ts'
|
|
227
|
+
import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitStats, defaultHead, periodStats, pulseSince, repositoryActivity, repositoryLines,
|
|
228
|
+
repositoryStats, withLines, yearCommits } from './repository-pulse.ts'
|
|
229
|
+
import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
|
|
230
|
+
import { releaseCommits } from './release-commits.ts'
|
|
157
231
|
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
158
232
|
import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
|
|
159
233
|
import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
|
|
@@ -168,6 +242,7 @@ import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
|
|
|
168
242
|
import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
169
243
|
|
|
170
244
|
const SESSION_PATH = '/api/auth/session'
|
|
245
|
+
const PROFILE_PATH = '/api/auth/profile'
|
|
171
246
|
const AVATAR_PREFIX = '/api/avatar/'
|
|
172
247
|
const LOGOUT_PATH = '/api/auth/logout'
|
|
173
248
|
const ADMIN_USERS_PATH = '/api/admin/users'
|
|
@@ -178,7 +253,20 @@ const ADMIN_REPOS_PATH = '/api/admin/repositories'
|
|
|
178
253
|
const BROWSABLE_REPOS_PATH = '/api/admin/repositories/browsable'
|
|
179
254
|
const REPO_REFS_PATH = '/api/admin/repositories/refs'
|
|
180
255
|
const REPO_CONTENTS_PATH = '/api/admin/repositories/contents'
|
|
256
|
+
const REPO_CODE_PATH = '/api/admin/repositories/code'
|
|
257
|
+
const REPO_HISTORY_PATH = '/api/admin/repositories/history'
|
|
258
|
+
const REPO_CHANGES_PATH = '/api/admin/repositories/changes'
|
|
259
|
+
const REPO_TREE_PATH = '/api/admin/repositories/tree'
|
|
260
|
+
const REPO_ISSUES_PATH = '/api/admin/repositories/issues'
|
|
261
|
+
const REPO_AGENTS_PATH = '/api/admin/repositories/agents'
|
|
262
|
+
const REPO_ITEM_PATH = '/api/admin/repositories/item'
|
|
263
|
+
const REPO_PULL_DEPENDENCIES_PATH = '/api/admin/repositories/pull-dependencies'
|
|
264
|
+
const REPO_BRANCH_DEPENDENCIES_PATH = '/api/admin/repositories/branch-dependencies'
|
|
265
|
+
const REPO_LOCKFILES_PATH = '/api/admin/repositories/lockfiles'
|
|
266
|
+
const REPO_PULSE_PATH = '/api/admin/repositories/pulse'
|
|
267
|
+
const REPO_PULSE_ITEMS_PATH = '/api/admin/repositories/pulse/items'
|
|
181
268
|
const SELECT_REPO_PATH = '/api/admin/repositories/select'
|
|
269
|
+
const REPO_DEPENDENCY_REVIEW_PATH = '/api/admin/repositories/dependency-review'
|
|
182
270
|
const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
|
|
183
271
|
const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
|
|
184
272
|
const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
|
|
@@ -220,6 +308,7 @@ const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
|
|
|
220
308
|
const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
|
|
221
309
|
const NPM_COMPARISON_PATH = '/api/npm/comparison'
|
|
222
310
|
const NPM_RISK_PATH = '/api/npm/risk'
|
|
311
|
+
const NPM_DELTA_PATH = '/api/npm/delta'
|
|
223
312
|
// What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
|
|
224
313
|
// having no such package or version, or a tarball unpacking past risk's
|
|
225
314
|
// bounds; npm failing otherwise a 502.
|
|
@@ -232,7 +321,7 @@ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
|
|
|
232
321
|
sendJson(res, status, { error: err.code })
|
|
233
322
|
}
|
|
234
323
|
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])
|
|
324
|
+
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH, NPM_DELTA_PATH])
|
|
236
325
|
// How long an npm response may go unread before its connection is dropped.
|
|
237
326
|
const NPM_RESPONSE_IDLE_MS = 60_000
|
|
238
327
|
const MAX_TEAM_NAME = 100
|
|
@@ -248,7 +337,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
248
337
|
if (!s) return
|
|
249
338
|
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
|
|
250
339
|
const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
|
|
251
|
-
const manualUrls = storedFixUrls(await deps.db.
|
|
340
|
+
const manualUrls = storedFixUrls(await deps.db.listTriageFixes(ids))
|
|
252
341
|
const storedIssues = await deps.db.listManagedIssues(ids)
|
|
253
342
|
const issues = visibleManagedIssues(storedIssues, snapshot)
|
|
254
343
|
const visibleIds = new Set(issues.map(issue => issue.findingId))
|
|
@@ -259,7 +348,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
259
348
|
: { fixes: await lookupFixes(deps.config, deps.db, snapshot, urls), updates: [] }
|
|
260
349
|
// Cold report reads, token refresh and upstream batches can outlive changes
|
|
261
350
|
// to security/links, team grants, publication, sessions or the Fix links.
|
|
262
|
-
const currentUrls = storedFixUrls(await deps.db.
|
|
351
|
+
const currentUrls = storedFixUrls(await deps.db.listTriageFixes(ids))
|
|
263
352
|
if (await readWorkspaceSession(res, deps, cookie) == null) return
|
|
264
353
|
await recheckTeam(deps.db, s.session.id, snapshot)
|
|
265
354
|
if (JSON.stringify(currentUrls) !== JSON.stringify(manualUrls)) {
|
|
@@ -298,9 +387,8 @@ function activity(deps: ManagedHttpDeps, user: StoredUser, kind: ActivityInput['
|
|
|
298
387
|
return deps.db.recordActivity({ kind, actor: user.login, actorId: user.id, action, ...context }, Date.now())
|
|
299
388
|
}
|
|
300
389
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return (await deps.db.listAllRepos()).find(repo => repo.repoId === repoId)?.fullName ?? null
|
|
390
|
+
function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
|
|
391
|
+
return repoId == null ? Promise.resolve(null) : deps.db.getRepoFullName(repoId)
|
|
304
392
|
}
|
|
305
393
|
|
|
306
394
|
// Workspace history is authorized BEFORE filtering, counting, or paging.
|
|
@@ -324,8 +412,14 @@ async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie:
|
|
|
324
412
|
if (s.user.role !== 'admin') {
|
|
325
413
|
const findings = new Map<string, ActivityContext>()
|
|
326
414
|
const reports = kind === 'all' || kind === 'triage' ? await deps.db.listActivityReports(s.user.id) : []
|
|
415
|
+
// Every report's record in one read, as visibleFindingIds reads each one's.
|
|
416
|
+
const snapshot = reports.length > 0 ? await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), reports.map(report => report.reportId)) : { reports: [] }
|
|
417
|
+
if (snapshot == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
418
|
+
const records = new Map(snapshot.reports.map(record => [record.id, record]))
|
|
327
419
|
for (const report of reports) {
|
|
328
|
-
|
|
420
|
+
const record = records.get(report.reportId)
|
|
421
|
+
if (record == null) continue
|
|
422
|
+
for (const finding of await reportFindingIds(deps, record, record.repo.github)) {
|
|
329
423
|
if (!findings.has(finding)) findings.set(finding, { finding, ...report })
|
|
330
424
|
}
|
|
331
425
|
}
|
|
@@ -349,10 +443,16 @@ export interface ManagedHttpDeps {
|
|
|
349
443
|
originGate: OriginGate
|
|
350
444
|
isShuttingDown: () => boolean
|
|
351
445
|
track: (p: Promise<unknown>) => void
|
|
446
|
+
// Serverless (Vercel's): keeps the invocation alive for work that goes on
|
|
447
|
+
// past its answer, as a kept read read again behind it does.
|
|
448
|
+
waitUntil?: (p: Promise<unknown>) => void
|
|
352
449
|
// Combined boot delegates unmatched paths to e2e and overrides discovery.
|
|
353
450
|
serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
|
|
354
451
|
next?: Handler
|
|
355
452
|
serverInfo?: ServerInfo
|
|
453
|
+
// The reviewed dependencies the host registered, as the server was
|
|
454
|
+
// assembled (reviewed-dependencies.ts).
|
|
455
|
+
reviewedDependencies?: ReviewedDependencies
|
|
356
456
|
}
|
|
357
457
|
|
|
358
458
|
type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
@@ -367,8 +467,9 @@ function firstHeader(v: string | string[] | undefined): string | null {
|
|
|
367
467
|
return null
|
|
368
468
|
}
|
|
369
469
|
|
|
370
|
-
|
|
371
|
-
|
|
470
|
+
// `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
|
|
471
|
+
async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id): Promise<void> {
|
|
472
|
+
const avatar = await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id))
|
|
372
473
|
if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
|
|
373
474
|
res.writeHead(200, {
|
|
374
475
|
'content-type': avatar.contentType,
|
|
@@ -379,6 +480,26 @@ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId
|
|
|
379
480
|
res.end(avatar.bytes)
|
|
380
481
|
}
|
|
381
482
|
|
|
483
|
+
// The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
|
|
484
|
+
// alone: the agents' known by their GitHub Apps or accounts (APP_AGENTS),
|
|
485
|
+
// each its mark on the Agents tab, and those the app draws agents' accounts
|
|
486
|
+
// by (GITHUB_AGENTS). Each is fetched from GitHub once and
|
|
487
|
+
// kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
|
|
488
|
+
// an account's, `…-0001-<id>`.
|
|
489
|
+
const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
|
|
490
|
+
.flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
|
|
491
|
+
const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
|
|
492
|
+
|
|
493
|
+
async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
|
|
494
|
+
const [, kind, id] = AGENT_AVATAR.exec(avatar)!
|
|
495
|
+
const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
|
|
496
|
+
if (await avatarStore.get(key) == null) {
|
|
497
|
+
await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
|
|
498
|
+
.catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
|
|
499
|
+
}
|
|
500
|
+
await serveAvatar(res, avatarStore, key)
|
|
501
|
+
}
|
|
502
|
+
|
|
382
503
|
const MAX_JSON_BODY_BYTES = 4096
|
|
383
504
|
|
|
384
505
|
// Buffer the request body, aborting (and throwing 'too-large') once it exceeds
|
|
@@ -481,9 +602,10 @@ function sendViewOnly(res: ServerResponse): void {
|
|
|
481
602
|
// Reads stay available while an admin views as another user. Everything else
|
|
482
603
|
// is refused before dispatch, except ending the view or signing out, and the
|
|
483
604
|
// batch report preview, a POST without effects. Issue authorization would
|
|
484
|
-
// grant GitHub access on the viewed user's behalf
|
|
605
|
+
// grant GitHub access on the viewed user's behalf, and linking an account
|
|
606
|
+
// would link it to the viewed user.
|
|
485
607
|
function viewAllows(method: string, path: string): boolean {
|
|
486
|
-
if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH
|
|
608
|
+
if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH && path !== LINK_LOGIN_PATH
|
|
487
609
|
return (method === 'DELETE' && path === VIEW_AS_PATH) || (method === 'POST' && (path === LOGOUT_PATH || path === REPORT_QUERY_PATH))
|
|
488
610
|
}
|
|
489
611
|
|
|
@@ -528,6 +650,65 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
|
|
|
528
650
|
res.end()
|
|
529
651
|
}
|
|
530
652
|
|
|
653
|
+
// GET /api/auth/profile — the user's profile (github-links.ts). POST chooses
|
|
654
|
+
// the accounts their displayed name and avatar come from. Workspace access is
|
|
655
|
+
// required: the accounts a user links are reserved for them.
|
|
656
|
+
async function handleProfile(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
657
|
+
const method = req.method ?? 'GET'
|
|
658
|
+
if (method === 'GET') {
|
|
659
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
660
|
+
if (s) await sendProfile(res, deps, s.user.id)
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
if (method !== 'POST') { send405(res, 'GET, POST'); return }
|
|
664
|
+
const s = await profileMutation(req, res, deps, cookie)
|
|
665
|
+
if (s == null) return
|
|
666
|
+
let body: unknown
|
|
667
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
668
|
+
const choice = parseProfileChoice(body)
|
|
669
|
+
if (choice == null) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
670
|
+
await deps.db.setProfileChoice(s.session.id, choice, Date.now())
|
|
671
|
+
await sendProfile(res, deps, s.user.id)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// DELETE /api/auth/profile/accounts/<id> — unlink one of the user's linked
|
|
675
|
+
// GitHub accounts, which may then sign in or register again.
|
|
676
|
+
async function handleUnlinkAccount(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
677
|
+
if ((req.method ?? 'GET') !== 'DELETE') { send405(res, 'DELETE'); return }
|
|
678
|
+
const s = await profileMutation(req, res, deps, cookie)
|
|
679
|
+
if (s == null) return
|
|
680
|
+
const link = await deps.db.unlinkGithubAccount(s.session.id, id, Date.now())
|
|
681
|
+
if (link == null) { sendJson(res, 404, { error: 'no-account' }); return }
|
|
682
|
+
await deps.avatarStore.delete(link.id).catch((err) => { console.warn('managed: linked avatar removal failed:', err) })
|
|
683
|
+
await activity(deps, s.user, 'access', `unlinked GitHub account @${link.login}`)
|
|
684
|
+
await sendProfile(res, deps, s.user.id)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function profileMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
688
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
689
|
+
if (s != null && !roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
690
|
+
return s
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function sendProfile(res: ServerResponse, deps: ManagedHttpDeps, userId: string): Promise<void> {
|
|
694
|
+
const profile = await deps.db.getProfile(userId)
|
|
695
|
+
if (profile == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
696
|
+
sendJson(res, 200, { profile })
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// { name?, avatar? }, each a linked account's id or null; at least one.
|
|
700
|
+
function parseProfileChoice(body: unknown): ProfileChoice | null {
|
|
701
|
+
if (body == null || typeof body !== 'object' || Array.isArray(body)) return null
|
|
702
|
+
const choice: ProfileChoice = {}
|
|
703
|
+
for (const key of ['name', 'avatar'] as const) {
|
|
704
|
+
if (!Object.hasOwn(body, key)) continue
|
|
705
|
+
const value = (body as Record<string, unknown>)[key]
|
|
706
|
+
if (value !== null && (typeof value !== 'string' || !isBlobId(value))) return null
|
|
707
|
+
choice[key] = value
|
|
708
|
+
}
|
|
709
|
+
return Object.keys(choice).length > 0 ? choice : null
|
|
710
|
+
}
|
|
711
|
+
|
|
531
712
|
// POST /api/admin/set-role — an admin sets ANOTHER user's role. Admin-only, and
|
|
532
713
|
// refused for the caller's own id so an admin can't drop their own admin (keeps
|
|
533
714
|
// at least one admin).
|
|
@@ -587,16 +768,19 @@ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
587
768
|
// Who am I? While an admin views as another user, that user, plus the admin
|
|
588
769
|
// as `viewer`. A view that has ended (with the admin's session or role, or the
|
|
589
770
|
// viewed account) is cleared, returning to the own session. Null signed out.
|
|
771
|
+
// `dependencyReview` where they publish dependency reviews to some team.
|
|
590
772
|
async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: string | undefined): Promise<{ body: ManagedSessionInfo | null; headers: Record<string, string> }> {
|
|
591
773
|
const now = Date.now()
|
|
592
774
|
let s = await readSession(config, db, cookie, now)
|
|
593
775
|
const ended = s == null && viewToken(config, cookie) != null
|
|
594
776
|
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
777
|
+
const reviews = s != null && (await db.listDependencyReviewTeams(s.user.id)).length > 0
|
|
595
778
|
return {
|
|
596
779
|
body: s && {
|
|
597
780
|
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
598
781
|
csrfToken: s.session.csrfToken,
|
|
599
782
|
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
783
|
+
...(reviews ? { dependencyReview: true as const } : {}),
|
|
600
784
|
},
|
|
601
785
|
headers: ended ? { 'set-cookie': clearViewCookie(config) } : {},
|
|
602
786
|
}
|
|
@@ -644,9 +828,13 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
|
|
|
644
828
|
let tokenMissing = false
|
|
645
829
|
let repositories
|
|
646
830
|
if (scope === 'connected') {
|
|
831
|
+
const reviews = await deps.db.listRepoDependencyReviews()
|
|
832
|
+
const reviewTeams = await deps.db.listRepoReviewTeams()
|
|
647
833
|
repositories = allRows.map((r) => ({
|
|
648
834
|
id: r.repoId, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl,
|
|
649
835
|
installed: r.installationId != null, selected: r.active, active: r.active,
|
|
836
|
+
dependencyReview: reviews.get(r.repoId) ?? { enabled: false, required: 1, since: null, lockfiles: EVERY_LOCKFILE },
|
|
837
|
+
reviewTeams: reviewTeams.get(r.repoId) ?? [],
|
|
650
838
|
}))
|
|
651
839
|
} else {
|
|
652
840
|
const showAll = scope === 'installed' && url.searchParams.get('showAll') === 'true'
|
|
@@ -676,6 +864,7 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
|
|
|
676
864
|
inactiveCount: allRows.filter((r) => !r.active).length,
|
|
677
865
|
total,
|
|
678
866
|
tokenMissing,
|
|
867
|
+
...deps.reviewedDependencies ? { reviewedDependencies: true } : {},
|
|
679
868
|
})
|
|
680
869
|
}
|
|
681
870
|
|
|
@@ -810,6 +999,39 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
810
999
|
await selectRepository(res, deps, s.user, repoId, cookie)
|
|
811
1000
|
}
|
|
812
1001
|
|
|
1002
|
+
// POST /api/admin/repositories/dependency-review — a repository's dependency
|
|
1003
|
+
// review (repository-dependency-reviews.ts): `enabled`, the published reviews
|
|
1004
|
+
// each version its pull requests' lockfiles lock needs (`required`, 1 to
|
|
1005
|
+
// MAX_REQUIRED_REVIEWS), and the commit whose lockfiles' versions need none
|
|
1006
|
+
// (`since`, a full commit id, or null; where the host registered reviewed
|
|
1007
|
+
// dependencies, which replace it, none, `400 since-unsupported`, the one
|
|
1008
|
+
// stored kept as it is). Answers the setting then. Admin, CSRF.
|
|
1009
|
+
async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1010
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
1011
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1012
|
+
if (s == null) return
|
|
1013
|
+
let body: Record<string, unknown> | null
|
|
1014
|
+
try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1015
|
+
const repoId = body?.['repoId']
|
|
1016
|
+
const enabled = body?.['enabled']
|
|
1017
|
+
const required = body?.['required']
|
|
1018
|
+
const since = body?.['since'] ?? null
|
|
1019
|
+
// The lockfiles it holds to, kept as they are where none is sent.
|
|
1020
|
+
const lockfiles = body?.['lockfiles'] === undefined ? undefined : scopeOf(body['lockfiles'])
|
|
1021
|
+
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof enabled !== 'boolean'
|
|
1022
|
+
|| typeof required !== 'number' || !Number.isSafeInteger(required) || required < 1 || required > MAX_REQUIRED_REVIEWS
|
|
1023
|
+
|| (since !== null && (typeof since !== 'string' || !SINCE_COMMIT.test(since))) || lockfiles === null) {
|
|
1024
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
1025
|
+
}
|
|
1026
|
+
if (since !== null && deps.reviewedDependencies) { sendJson(res, 400, { error: 'since-unsupported' }); return }
|
|
1027
|
+
const repo = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
|
|
1028
|
+
const setting = repo ? await deps.db.setRepoDependencyReview(repoId, { enabled, required, ...deps.reviewedDependencies ? {} : { since: since as string | null },
|
|
1029
|
+
...lockfiles ? { lockfiles } : {} }) : null
|
|
1030
|
+
if (!repo || !setting) { sendJson(res, 404, { error: 'no-repository' }); return }
|
|
1031
|
+
await activity(deps, s.user, 'repository', enabled ? 'enabled dependency review' : 'disabled dependency review', { repo: repo.fullName })
|
|
1032
|
+
sendJson(res, 200, { ok: true, dependencyReview: setting })
|
|
1033
|
+
}
|
|
1034
|
+
|
|
813
1035
|
async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
|
|
814
1036
|
const ids = new Set<string>()
|
|
815
1037
|
for (const report of reports) {
|
|
@@ -1249,8 +1471,10 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
|
|
|
1249
1471
|
|
|
1250
1472
|
// Listing connected repository names uses managed access only. Verify GitHub
|
|
1251
1473
|
// access after selection, before returning any refs or source directory data.
|
|
1474
|
+
// Any team member (view or above) lists those their teams reach, for the code
|
|
1475
|
+
// viewer as for Create a bundle, whose builds stay admin|manage.
|
|
1252
1476
|
async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1253
|
-
const s = await
|
|
1477
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1254
1478
|
if (!s) return
|
|
1255
1479
|
sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
|
|
1256
1480
|
}
|
|
@@ -1289,25 +1513,54 @@ function scopeRepositoryContents(contents: RepositoryContents | null, virtualEnt
|
|
|
1289
1513
|
} : {}) }
|
|
1290
1514
|
}
|
|
1291
1515
|
|
|
1292
|
-
//
|
|
1293
|
-
|
|
1516
|
+
// A browse's repository, revision and directory, as its query names them;
|
|
1517
|
+
// null, answered, where it names none.
|
|
1518
|
+
function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
|
|
1294
1519
|
const repoId = Number(query.get('repoId'))
|
|
1295
1520
|
const normalized = normalizeTeamPath(query.get('path') ?? '')
|
|
1296
1521
|
const ref = query.get('ref') ?? ''
|
|
1297
1522
|
if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
|
|
1298
|
-
sendJson(res, 400, { error: 'bad-request' }); return
|
|
1523
|
+
sendJson(res, 400, { error: 'bad-request' }); return null
|
|
1299
1524
|
}
|
|
1525
|
+
return { repoId, ref, path: normalized.path ?? '' }
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// The repository `repoId` as the reader may browse it at `path`: one of their
|
|
1529
|
+
// bundle repositories, and what their grants show of that directory
|
|
1530
|
+
// (`virtualEntries`, null where they reach all of it: scopedDirectory). Null,
|
|
1531
|
+
// answered, where it is none of theirs. Every team member (view or above)
|
|
1532
|
+
// reads the repositories their teams reach; the reader then checks their own
|
|
1533
|
+
// GitHub read permission (repository-browser.ts), on each read.
|
|
1534
|
+
async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
|
|
1535
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1536
|
+
if (!s) return null
|
|
1537
|
+
const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
|
|
1538
|
+
if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
|
|
1539
|
+
const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
|
|
1540
|
+
return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// The repository as it was authorized, but for the shared default branch hint,
|
|
1544
|
+
// which a read may refresh meanwhile: that changes none of its identity,
|
|
1545
|
+
// credentials or managed grants.
|
|
1546
|
+
const sameRepository = (current: SelectedRepo, authorized: SelectedRepo) =>
|
|
1547
|
+
JSON.stringify({ ...current, cachedDefaultBranch: authorized.cachedDefaultBranch }) === JSON.stringify(authorized)
|
|
1548
|
+
|
|
1549
|
+
function sendGithubError(res: ServerResponse, err: GithubApiError) {
|
|
1550
|
+
sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// Recheck repository/path grants after upstream reads, before returning names.
|
|
1554
|
+
async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
|
|
1555
|
+
const asked = repositoryQuery(res, query)
|
|
1556
|
+
if (!asked) return
|
|
1557
|
+
const { repoId, ref } = asked
|
|
1300
1558
|
const withDefault = refs && query.get('withDefault') === 'true'
|
|
1301
|
-
const path = refs ? '' :
|
|
1559
|
+
const path = refs ? '' : asked.path
|
|
1302
1560
|
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 }
|
|
1561
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1562
|
+
if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1563
|
+
return access
|
|
1311
1564
|
}
|
|
1312
1565
|
const access = await authorize()
|
|
1313
1566
|
if (!access) return
|
|
@@ -1329,23 +1582,783 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1329
1582
|
await reader.recheckAccess()
|
|
1330
1583
|
const current = await authorize()
|
|
1331
1584
|
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 ? {
|
|
1585
|
+
// This request (or another reader) may have refreshed the shared hint.
|
|
1586
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1587
|
+
sendJson(res, 200, refs ? { ...listed, tagCommits, ...(withDefault ? {
|
|
1338
1588
|
defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
|
|
1339
1589
|
} : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
|
|
1340
1590
|
} catch (err) {
|
|
1341
|
-
if (err instanceof GithubApiError) {
|
|
1342
|
-
|
|
1591
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1592
|
+
throw err
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// GET /api/admin/repositories/code: the files of a directory of a repository
|
|
1597
|
+
// at a revision, for the code viewer (repository-code.ts), read as browsing
|
|
1598
|
+
// it is, with the reader's own GitHub access, and only where their grants
|
|
1599
|
+
// reach the whole directory: one they reach only the ancestor of is none to
|
|
1600
|
+
// read. Its tree's listing says first whether its files fit, before its
|
|
1601
|
+
// tarball is asked for; the reader's access is checked again once it is
|
|
1602
|
+
// read, before any of it is sent. The load stays held until this response is
|
|
1603
|
+
// written or abandoned, and a reader who stops reading it gives it up after
|
|
1604
|
+
// a while.
|
|
1605
|
+
async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1606
|
+
const asked = repositoryQuery(res, query)
|
|
1607
|
+
if (!asked) return
|
|
1608
|
+
const { repoId, ref, path } = asked
|
|
1609
|
+
const authorize = async () => {
|
|
1610
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1611
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1612
|
+
return access
|
|
1613
|
+
}
|
|
1614
|
+
const access = await authorize()
|
|
1615
|
+
if (!access) return
|
|
1616
|
+
try {
|
|
1617
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1618
|
+
const commit = await reader.commit(ref)
|
|
1619
|
+
const repo = access.repo.fullName
|
|
1620
|
+
const client = createClient({ token: reader.readToken() })
|
|
1621
|
+
// A directory the commit has none at is none to read, as browsing it says.
|
|
1622
|
+
const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1623
|
+
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
|
|
1624
|
+
}))
|
|
1625
|
+
// Access still held, and the same repository, before anything read is sent.
|
|
1626
|
+
const fenced = async () => {
|
|
1627
|
+
await reader.recheckAccess()
|
|
1628
|
+
const current = await authorize()
|
|
1629
|
+
if (!current) return false
|
|
1630
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
|
|
1631
|
+
return true
|
|
1632
|
+
}
|
|
1633
|
+
// Too large names the commit it was read at, for History to read.
|
|
1634
|
+
if (!repositoryCodeFits(await repositoryTreeFiles(repo, reader.readToken(), tree))) {
|
|
1635
|
+
if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
|
|
1343
1636
|
return
|
|
1344
1637
|
}
|
|
1638
|
+
if (res.destroyed) return
|
|
1639
|
+
const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
|
|
1640
|
+
res.once('close', load.release)
|
|
1641
|
+
const bytes = await load.result
|
|
1642
|
+
if (!(await fenced()) || res.destroyed) return
|
|
1643
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
1644
|
+
res.writeHead(200, {
|
|
1645
|
+
'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
|
|
1646
|
+
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
1647
|
+
})
|
|
1648
|
+
writeResponse(res, bytes)
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
if (res.headersSent || res.destroyed) return
|
|
1651
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1652
|
+
if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
|
|
1653
|
+
throw err
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// GET /api/admin/repositories/history: what the code viewer's History tab
|
|
1658
|
+
// draws (repository-history.ts), read as browsing is, with the reader's own
|
|
1659
|
+
// GitHub access, within their grants as the code is. From a revision (`ref`),
|
|
1660
|
+
// a page at a time (`page`), the commits touching `path` where it names a
|
|
1661
|
+
// directory, with the branches and tags naming commits on the first page; or,
|
|
1662
|
+
// with `all`, across the repository's most recently updated branches, which
|
|
1663
|
+
// needs a GitHub token. A whole repository's history, from a revision or all
|
|
1664
|
+
// branches, is read from commits a page each, kept by the commit each starts
|
|
1665
|
+
// at, and those kept glued on (historyFrom); a later page reads on from the
|
|
1666
|
+
// commits `from` names. The branches and tags naming commits are kept as an
|
|
1667
|
+
// issue's page is (keptAnswer). Access is checked again once it is read,
|
|
1668
|
+
// before any of it is sent, with the app's users its authors are
|
|
1669
|
+
// (github-users.ts).
|
|
1670
|
+
async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1671
|
+
const asked = repositoryQuery(res, query)
|
|
1672
|
+
if (!asked) return
|
|
1673
|
+
const { repoId, ref, path } = asked
|
|
1674
|
+
const page = Number(query.get('page') ?? '1')
|
|
1675
|
+
const all = query.get('all') === 'true'
|
|
1676
|
+
// Every branch's heads read now, not as kept: the client asks so where
|
|
1677
|
+
// they miss the commit shown, as a branch moved since may.
|
|
1678
|
+
const fresh = query.get('fresh') === 'true'
|
|
1679
|
+
// A whole repository's later pages read on from where those before stop:
|
|
1680
|
+
// all branches', always; a revision's, where the client names them.
|
|
1681
|
+
const from = query.get('from')
|
|
1682
|
+
const starts = from === null ? null : historyStarts(from)
|
|
1683
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
|
|
1684
|
+
|| (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1685
|
+
const authorize = async () => {
|
|
1686
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1687
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1688
|
+
return access
|
|
1689
|
+
}
|
|
1690
|
+
const access = await authorize()
|
|
1691
|
+
if (!access) return
|
|
1692
|
+
let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
|
|
1693
|
+
try {
|
|
1694
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1695
|
+
const repo = access.repo.fullName
|
|
1696
|
+
const token = reader.readToken()
|
|
1697
|
+
// With a token, as GraphQL reads them.
|
|
1698
|
+
const pages = historyPages(deps, repoId, token !== null)
|
|
1699
|
+
const quiet = <T>(fallback: T) => (err: unknown) => {
|
|
1700
|
+
if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
|
|
1701
|
+
return fallback
|
|
1702
|
+
}
|
|
1703
|
+
// Open pull heads do not depend on the commit history. Start them with
|
|
1704
|
+
// it, handling failures immediately even if the history fails first.
|
|
1705
|
+
if (token && path === '' && page === 1) {
|
|
1706
|
+
readingPulls = keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null))
|
|
1707
|
+
}
|
|
1708
|
+
let body: object
|
|
1709
|
+
if (all) {
|
|
1710
|
+
if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
|
|
1711
|
+
// What a later page reads on from, the client knows; whether one may
|
|
1712
|
+
// follow, the deepest page says.
|
|
1713
|
+
// Asked for afresh, those kept answered as just read: how long till
|
|
1714
|
+
// they may be read again (`retryIn`), for the client to ask then.
|
|
1715
|
+
const heads = starts ? null : await keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token), undefined, fresh)
|
|
1716
|
+
const retryIn = heads && fresh && heads.kept ? heads.at + REPOSITORY_READ_FLOOR_MS - Date.now() : 0
|
|
1717
|
+
body = starts ? { all: true, page, commits: (await historyFrom(repo, token, starts, pages)).commits, more: page < MAX_HISTORY_PAGE }
|
|
1718
|
+
: { all: true, ...await repositoryAllHistory(repo, token, heads!.value, pages), ...retryIn > 0 ? { retryIn } : {} }
|
|
1719
|
+
} else {
|
|
1720
|
+
const commit = await reader.commit(ref)
|
|
1721
|
+
// The branches and tags naming commits: with a token, every branch All
|
|
1722
|
+
// branches reads and the latest tags, kept for both; without, GitHub's
|
|
1723
|
+
// first page of each.
|
|
1724
|
+
const naming = () => token
|
|
1725
|
+
? keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token)).then(({ value }) => ({
|
|
1726
|
+
branches: value.heads.map(({ name, sha }) => ({ name, sha })), tags: value.tags }))
|
|
1727
|
+
: keptAnswer(deps, `${repoId}\0refs`, () => repositoryRefTips(repo, token)).then(answer => answer.value)
|
|
1728
|
+
const tips = page === 1 ? naming() : null
|
|
1729
|
+
// A whole repository's from its commit, or on from those `from` names
|
|
1730
|
+
// (a page by its number where none are, as GitHub lists them); a
|
|
1731
|
+
// directory's by its number.
|
|
1732
|
+
const history = path !== '' || (page > 1 && !starts) ? repositoryHistory(repo, token, commit, path, page)
|
|
1733
|
+
: historyFrom(repo, token, starts ?? [commit], pages).then(read => ({ commits: read.commits, more: (starts !== null || read.more) && page < MAX_HISTORY_PAGE }))
|
|
1734
|
+
const [read, named] = await Promise.all([history, tips])
|
|
1735
|
+
body = { commit, path, page, ...read, ...named }
|
|
1736
|
+
}
|
|
1737
|
+
// The pull requests squashed into a whole repository's commits, drawn as
|
|
1738
|
+
// the merges they were, and on its first page the open ones' heads,
|
|
1739
|
+
// naming commits: with a token, as far as GitHub answers them, the
|
|
1740
|
+
// history sent all the same where it doesn't.
|
|
1741
|
+
const listed = (body as { commits: HistoryCommit[] }).commits
|
|
1742
|
+
const [pulls, openPulls] = token && path === '' ? await Promise.all([
|
|
1743
|
+
squashedPulls(repo, token, listed, historySquashes(deps, repoId)).catch(quiet<SquashedPull[]>([])),
|
|
1744
|
+
readingPulls,
|
|
1745
|
+
]) : [[], null]
|
|
1746
|
+
await reader.recheckAccess()
|
|
1747
|
+
const current = await authorize()
|
|
1748
|
+
if (!current) return
|
|
1749
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1750
|
+
// Each commit as the tab draws it, its co-authors with it: what Pulse
|
|
1751
|
+
// counts of it (its lines, its author's time) not sent.
|
|
1752
|
+
const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
|
|
1753
|
+
({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
|
|
1754
|
+
sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
|
|
1755
|
+
...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...openPulls?.length ? { openPulls } : {} }))
|
|
1756
|
+
} catch (err) {
|
|
1757
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1758
|
+
throw err
|
|
1759
|
+
} finally {
|
|
1760
|
+
// A failed history must not release the request's database lease while
|
|
1761
|
+
// its independent pull read is still writing to the cache.
|
|
1762
|
+
await readingPulls
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// GET /api/admin/repositories/changes: the files each commit `shas` names
|
|
1767
|
+
// changes, as the code viewer's Replay plays them (repository-changes.ts),
|
|
1768
|
+
// within `path`, each commit's kept whole by the commit (historyChanges);
|
|
1769
|
+
// read as Replay's reads are (readForReplay).
|
|
1770
|
+
async function handleRepositoryChanges(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1771
|
+
const shas = changesAsked(query.get('shas'))
|
|
1772
|
+
if (!shas) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1773
|
+
await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
|
|
1774
|
+
const changes = await repositoryChanges(fullName, token, shas, historyChanges(deps, repoId))
|
|
1775
|
+
return { path, changes: [...changes].map(([sha, read]) => read ? { sha, ...changesWithin(read, path) } : { sha, files: null, more: false }) }
|
|
1776
|
+
})
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// GET /api/admin/repositories/tree: the files commit `commit` holds within
|
|
1780
|
+
// `path` and their bytes (`sizes`), which Replay starts from and the Treemap
|
|
1781
|
+
// of a directory too large to open draws (repository-changes.ts
|
|
1782
|
+
// directoryFiles: the directory's own tree where the commit's is cut short),
|
|
1783
|
+
// kept whole by the commit and the rules it is read by (historyTreeStore,
|
|
1784
|
+
// TREES_READ); none (null) for one GitHub has none of. Read as Replay's
|
|
1785
|
+
// reads are (readForReplay).
|
|
1786
|
+
async function handleRepositoryTree(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1787
|
+
const commit = query.get('commit') ?? ''
|
|
1788
|
+
if (!/^[a-f\d]{40}$/u.test(commit)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1789
|
+
const store = historyTreeStore<CommitTree>(deps.db, deps.config.debug)
|
|
1790
|
+
await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
|
|
1791
|
+
const kept = { read: async (sha: string) => {
|
|
1792
|
+
const value = (await store.read(`${repoId}\0${TREES_READ}\0${sha}`))?.value
|
|
1793
|
+
return value && Array.isArray(value.files) && Array.isArray(value.sizes) && value.sizes.length === value.files.length ? value : null
|
|
1794
|
+
}, write: (sha: string, tree: CommitTree) => store.write(`${repoId}\0${TREES_READ}\0${sha}`, tree, Date.now()) }
|
|
1795
|
+
const tree = await directoryFiles(fullName, token, commit, path, kept)
|
|
1796
|
+
return { commit, path, ...tree ?? { files: null, sizes: null, cut: false } }
|
|
1797
|
+
})
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// What Replay reads of repository `repoId` within `path` (`read`, with its
|
|
1801
|
+
// name and the reader's token), as the history is read: with the reader's
|
|
1802
|
+
// own GitHub access, within their grants as the code is, a directory they
|
|
1803
|
+
// reach all of, and sent once access is checked again.
|
|
1804
|
+
async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams,
|
|
1805
|
+
read: (fullName: string, token: string | null, repoId: number, path: string) => Promise<object>): Promise<void> {
|
|
1806
|
+
const asked = repositoryQuery(res, query)
|
|
1807
|
+
if (!asked) return
|
|
1808
|
+
const { repoId, path } = asked
|
|
1809
|
+
const authorize = async () => {
|
|
1810
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1811
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1812
|
+
return access
|
|
1813
|
+
}
|
|
1814
|
+
const access = await authorize()
|
|
1815
|
+
if (!access) return
|
|
1816
|
+
try {
|
|
1817
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1818
|
+
const body = await read(access.repo.fullName, reader.readToken(), repoId, path)
|
|
1819
|
+
await reader.recheckAccess()
|
|
1820
|
+
const current = await authorize()
|
|
1821
|
+
if (!current) return
|
|
1822
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1823
|
+
sendJson(res, 200, body)
|
|
1824
|
+
} catch (err) {
|
|
1825
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1826
|
+
throw err
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// The files repository `repoId`'s commits change, kept by each commit
|
|
1831
|
+
// (historyChangeStore, repository-changes.ts repositoryChanges).
|
|
1832
|
+
function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
|
|
1833
|
+
const store = historyChangeStore<CommitChanges>(deps.db, deps.config.debug)
|
|
1834
|
+
const keyOf = (sha: string) => `${repoId}\0${sha}`
|
|
1835
|
+
return {
|
|
1836
|
+
async read(shas) {
|
|
1837
|
+
const kept = await store.readMany(shas.map(keyOf))
|
|
1838
|
+
return new Map(shas.flatMap(sha => {
|
|
1839
|
+
const value = kept.get(keyOf(sha))?.value
|
|
1840
|
+
return value && Array.isArray(value.files) ? [[sha, value] as const] : []
|
|
1841
|
+
}))
|
|
1842
|
+
},
|
|
1843
|
+
write: (sha, changes) => store.write(keyOf(sha), changes, Date.now()),
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// How long what readWholeRepository read is answered as kept, unread again;
|
|
1848
|
+
// and how long one read is answered as kept even where it is asked for afresh.
|
|
1849
|
+
const REPOSITORY_READ_FRESH_MS = 2 * 60_000
|
|
1850
|
+
const REPOSITORY_READ_FLOOR_MS = 10_000
|
|
1851
|
+
// The repositories' reads being read again, behind a kept answer or as asked
|
|
1852
|
+
// for afresh, by key: one at a time, those asking meanwhile answered by it.
|
|
1853
|
+
const repositoryRereads = new Map<string, Promise<unknown>>()
|
|
1854
|
+
// A read GitHub refused, not failed: its credential or the GitHub App's
|
|
1855
|
+
// permission for it withdrawn (Issues, Pull requests, Checks), the
|
|
1856
|
+
// repository or what was asked of it gone. What was kept of it goes too.
|
|
1857
|
+
const refusedRead = (err: unknown) => err instanceof GithubApiError
|
|
1858
|
+
&& (err.status === 401 || err.status === 403 || err.status === 404 || err.message === 'github-status-403')
|
|
1859
|
+
|
|
1860
|
+
// What `read` answers of a repository, kept by `key` (its id first) for every
|
|
1861
|
+
// reader (repositoryReadStore), each one's access checked as they ask before
|
|
1862
|
+
// this: answered as kept for REPOSITORY_READ_FRESH_MS, then, while it is
|
|
1863
|
+
// kept, answered at once and read again behind it for the next to ask, the
|
|
1864
|
+
// read tracked (serverless, its invocation kept alive till it settles,
|
|
1865
|
+
// deps.waitUntil); `kept` where it was, or where reading it again
|
|
1866
|
+
// failed as `keepThrough` lets it. One asked for afresh (`fresh`) is read
|
|
1867
|
+
// now, unless it was read REPOSITORY_READ_FLOOR_MS since, what was kept
|
|
1868
|
+
// answered where that read fails. One GitHub refuses to read again is no
|
|
1869
|
+
// longer kept: the next to ask is refused as GitHub refused it. `at` is when
|
|
1870
|
+
// what it answers was read.
|
|
1871
|
+
async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>,
|
|
1872
|
+
keepThrough: (err: unknown) => boolean = () => false, fresh = false): Promise<{ value: T; kept: boolean; at: number }> {
|
|
1873
|
+
const store = repositoryReadStore(deps.db, deps.config.debug)
|
|
1874
|
+
// One kept longer than a day, not yet removed (pruneKept removes them as
|
|
1875
|
+
// others are written), is none.
|
|
1876
|
+
const stored = await store.read(key)
|
|
1877
|
+
const held = stored && Date.now() - stored.at < REPOSITORY_READ_KEPT_MS ? stored : null
|
|
1878
|
+
// Read, and kept as read when it was, which it is said read at.
|
|
1879
|
+
const readKept = async () => {
|
|
1880
|
+
try {
|
|
1881
|
+
const value = await read()
|
|
1882
|
+
const at = Date.now()
|
|
1883
|
+
await store.write(key, value, at)
|
|
1884
|
+
return { value, at }
|
|
1885
|
+
} catch (err) {
|
|
1886
|
+
if (refusedRead(err)) await store.remove(key)
|
|
1887
|
+
throw err
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
const reread = () => {
|
|
1891
|
+
let reading = repositoryRereads.get(key)
|
|
1892
|
+
if (!reading) {
|
|
1893
|
+
reading = readKept().finally(() => repositoryRereads.delete(key))
|
|
1894
|
+
repositoryRereads.set(key, reading)
|
|
1895
|
+
}
|
|
1896
|
+
return reading as Promise<{ value: T; at: number }>
|
|
1897
|
+
}
|
|
1898
|
+
const age = held ? Date.now() - held.at : Infinity
|
|
1899
|
+
const now = fresh && age >= REPOSITORY_READ_FLOOR_MS
|
|
1900
|
+
if (held && !now) {
|
|
1901
|
+
if (age >= REPOSITORY_READ_FRESH_MS && !repositoryRereads.has(key)) {
|
|
1902
|
+
// Its failure leaves the kept answer sent until it is a day old, so it is said.
|
|
1903
|
+
const behind = reread().catch((err: unknown) => { console.warn('managed: repository read failed:', err) })
|
|
1904
|
+
deps.track(behind)
|
|
1905
|
+
deps.waitUntil?.(behind)
|
|
1906
|
+
}
|
|
1907
|
+
return { value: held.value as T, kept: true, at: held.at }
|
|
1908
|
+
}
|
|
1909
|
+
try {
|
|
1910
|
+
return { ...now ? await reread() : await readKept(), kept: false }
|
|
1911
|
+
} catch (err) {
|
|
1912
|
+
if (held && ((now && !refusedRead(err)) || keepThrough(err))) return { value: held.value as T, kept: true, at: held.at }
|
|
1345
1913
|
throw err
|
|
1346
1914
|
}
|
|
1347
1915
|
}
|
|
1348
1916
|
|
|
1917
|
+
// The pages of repository `repoId`'s history kept (historyPageStore), each by
|
|
1918
|
+
// the commit it starts at (repository-history.ts historyFrom) and the rules
|
|
1919
|
+
// its co-authors were read by (commit-coauthors.ts COAUTHORS_READ), those
|
|
1920
|
+
// telling each commit's co-authors (kept since they are read), and with
|
|
1921
|
+
// `graphql`, only those read with GraphQL, telling its lines and the accounts
|
|
1922
|
+
// its co-authors' addresses are (a page GitHub's REST API listed tells
|
|
1923
|
+
// neither); the rest read again.
|
|
1924
|
+
function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): HistoryPages {
|
|
1925
|
+
const store = historyPageStore<HistoryCommit[]>(deps.db, deps.config.debug)
|
|
1926
|
+
const keyOf = (top: string) => `${repoId}\0${COAUTHORS_READ}\0${top}`
|
|
1927
|
+
const told = (tops: string[], kept: Map<string, { value: HistoryCommit[] }>) => new Map(tops.flatMap(top => {
|
|
1928
|
+
const page = kept.get(keyOf(top))?.value
|
|
1929
|
+
return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
|
|
1930
|
+
? [[top, page] as const] : []
|
|
1931
|
+
}))
|
|
1932
|
+
return {
|
|
1933
|
+
read: async tops => told(tops, await store.readMany(tops.map(keyOf))),
|
|
1934
|
+
async readFirst(first, then) {
|
|
1935
|
+
const kept = await store.readFirst(first.map(keyOf), then.map(keyOf))
|
|
1936
|
+
return { first: kept.first, pages: told(kept.first ? first : then, kept.found) }
|
|
1937
|
+
},
|
|
1938
|
+
write: (top, commits) => store.write(keyOf(top), commits, Date.now()),
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// The pull requests squashed into repository `repoId`'s commits, kept by
|
|
1943
|
+
// each commit and the rules their commits' co-authors were read by
|
|
1944
|
+
// (COAUTHORS_READ), null for one none was (repository-history.ts
|
|
1945
|
+
// squashedPulls); one kept before its commits' co-authors were read, read
|
|
1946
|
+
// again.
|
|
1947
|
+
function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
|
|
1948
|
+
const store = historySquashStore<SquashedPull | null>(deps.db, deps.config.debug)
|
|
1949
|
+
const keyOf = (sha: string) => `${repoId}\0${COAUTHORS_READ}\0${sha}`
|
|
1950
|
+
return {
|
|
1951
|
+
async read(shas) {
|
|
1952
|
+
const kept = await store.readMany(shas.map(keyOf))
|
|
1953
|
+
return new Map(shas.flatMap(sha => {
|
|
1954
|
+
const entry = kept.get(keyOf(sha))
|
|
1955
|
+
return entry && (entry.value === null || (Array.isArray(entry.value.commits) && entry.value.commits.every(commit => Array.isArray(commit.coauthors))))
|
|
1956
|
+
? [[sha, entry.value] as const] : []
|
|
1957
|
+
}))
|
|
1958
|
+
},
|
|
1959
|
+
write: (sha, pull) => store.write(keyOf(sha), pull, Date.now()),
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// What the whole of repository `repoId` gives in GraphQL (`read`, with its
|
|
1964
|
+
// name and a token), read as browsing is, with the reader's own GitHub
|
|
1965
|
+
// access: only by a reader whose grants reach all of it, with a token, and
|
|
1966
|
+
// sent once access is checked again after it is read; what `key` names kept
|
|
1967
|
+
// (keptAnswer), sent as kept, its reader's access checked as they ask; with
|
|
1968
|
+
// `readAt`, when it was read from GitHub, one kept as old as it was kept, so
|
|
1969
|
+
// that a reader holding two reads of the same can tell the later.
|
|
1970
|
+
async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
|
|
1971
|
+
read: (fullName: string, token: string) => Promise<object>, key: string | null = null, readAt = false): Promise<void> {
|
|
1972
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
1973
|
+
const { value, kept, at } = key === null ? { value: await read(fullName, token), kept: false, at: Date.now() }
|
|
1974
|
+
: await keptAnswer(deps, `${repoId}\0${key}`, () => read(fullName, token))
|
|
1975
|
+
return { value: readAt ? { ...value, readAt: at } : value, kept }
|
|
1976
|
+
})
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// What `answer` gives of the whole of repository `repoId` (with its name and
|
|
1980
|
+
// a token), as readWholeRepository reads it: sent at once where it was kept,
|
|
1981
|
+
// else once access is checked again, with the app's users it names
|
|
1982
|
+
// (github-users.ts), never kept with it; or what `unread` tells of it, the
|
|
1983
|
+
// app's alone, once the app's access is checked, GitHub then never asked.
|
|
1984
|
+
async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
|
|
1985
|
+
answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
|
|
1986
|
+
unread: () => Promise<object | null> = () => Promise.resolve(null)): Promise<void> {
|
|
1987
|
+
const authorize = async () => {
|
|
1988
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, '')
|
|
1989
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1990
|
+
return access
|
|
1991
|
+
}
|
|
1992
|
+
const access = await authorize()
|
|
1993
|
+
if (!access) return
|
|
1994
|
+
const told = await unread()
|
|
1995
|
+
if (told) { sendJson(res, 200, await withAppUsers(deps.db, told)); return }
|
|
1996
|
+
try {
|
|
1997
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1998
|
+
const token = reader.readToken()
|
|
1999
|
+
if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
|
|
2000
|
+
const answered = await answer(access.repo.fullName, token, access.user)
|
|
2001
|
+
if (answered.kept) { sendJson(res, 200, await withAppUsers(deps.db, answered.value)); return }
|
|
2002
|
+
await reader.recheckAccess()
|
|
2003
|
+
const current = await authorize()
|
|
2004
|
+
if (!current) return
|
|
2005
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
2006
|
+
sendJson(res, 200, await withAppUsers(deps.db, answered.value))
|
|
2007
|
+
} catch (err) {
|
|
2008
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
2009
|
+
throw err
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
|
|
2014
|
+
// requests tabs show (repository-issues.ts): `kind`'s open or closed
|
|
2015
|
+
// (`state`), a page at a time, on from the cursor `after` names. They are
|
|
2016
|
+
// the whole repository's (readWholeRepository).
|
|
2017
|
+
async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2018
|
+
const repoId = Number(query.get('repoId'))
|
|
2019
|
+
const kind = query.get('kind')
|
|
2020
|
+
const state = query.get('state') ?? 'open'
|
|
2021
|
+
const after = query.get('after')
|
|
2022
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
|
|
2023
|
+
|| (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2024
|
+
await readWholeRepository(res, deps, cookie, repoId, async (fullName, token) => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }),
|
|
2025
|
+
`issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`, true)
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// GET /api/admin/repositories/agents: what the code viewer's Agents tab
|
|
2029
|
+
// reconstructs agents' sessions from (repository-agents.ts): a page
|
|
2030
|
+
// of pull requests on from the cursor `pulls` names, and of the default
|
|
2031
|
+
// branch's commits on from `commits`, the first page, naming neither, with
|
|
2032
|
+
// issues and branches. They are the whole repository's (readWholeRepository).
|
|
2033
|
+
async function handleRepositoryAgents(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2034
|
+
const repoId = Number(query.get('repoId'))
|
|
2035
|
+
const cursors = { pulls: query.get('pulls'), commits: query.get('commits') }
|
|
2036
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || (cursors.pulls !== null && !ISSUES_CURSOR.test(cursors.pulls))
|
|
2037
|
+
|| (cursors.commits !== null && !COMMITS_CURSOR.test(cursors.commits))) {
|
|
2038
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
2039
|
+
}
|
|
2040
|
+
await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryAgents(fullName, token, cursors),
|
|
2041
|
+
`agents\0${AGENTS_READ_VERSION}\0${cursors.pulls ?? ''}\0${cursors.commits ?? ''}`)
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// GET /api/admin/repositories/item: the issue or pull request numbered
|
|
2045
|
+
// `number`, as the code viewer's popup for it shows it (repository-issues.ts
|
|
2046
|
+
// repositoryItem). It is the whole repository's (readWholeRepository).
|
|
2047
|
+
async function handleRepositoryItem(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2048
|
+
const repoId = Number(query.get('repoId'))
|
|
2049
|
+
const number = itemNumber(query)
|
|
2050
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2051
|
+
await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryItem(fullName, token, number), `item\0${ISSUES_READ_VERSION}\0${number}`, true)
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// The issue's or pull request's number a request names, as GraphQL takes one
|
|
2055
|
+
// (an Int, at most 2³¹ − 1); null for none.
|
|
2056
|
+
function itemNumber(query: URLSearchParams): number | null {
|
|
2057
|
+
const number = Number(query.get('number'))
|
|
2058
|
+
return /^[1-9]\d{0,9}$/u.test(query.get('number') ?? '') && number <= 2 ** 31 - 1 ? number : null
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// GET /api/admin/repositories/pull-dependencies: the package versions pull
|
|
2062
|
+
// request `number`'s lockfiles add (pull-dependencies.ts), what its base's and head's
|
|
2063
|
+
// commits tell kept by them (pullDependencyKept), its `number` with them.
|
|
2064
|
+
// They are the whole repository's (answerWholeRepository), the commits they
|
|
2065
|
+
// are of kept by its number as its popup's item is, its head moving with each
|
|
2066
|
+
// push, what they tell kept by them alone, not again. With `review=1`,
|
|
2067
|
+
// as an open one's are asked, where the repository reviews dependencies,
|
|
2068
|
+
// each version's review (pull-dependency-reviews.ts), read afresh as each
|
|
2069
|
+
// asks, reviews being published meanwhile; its baseline kept, where the host
|
|
2070
|
+
// registered no reviewed dependencies, which are asked in its place.
|
|
2071
|
+
// Up to MAX_ASKED_PULLS pull requests asked together (`numbers=1,2,3`) are
|
|
2072
|
+
// answered together, as many read at once as PULLS_AT_ONCE, `{ pulls }`,
|
|
2073
|
+
// each's answer, else `{ number, error }` where GitHub fails it, with one
|
|
2074
|
+
// check of its access before and one after, but where each was kept.
|
|
2075
|
+
const MAX_ASKED_PULLS = 20
|
|
2076
|
+
const PULLS_AT_ONCE = 3
|
|
2077
|
+
async function handleRepositoryPullDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2078
|
+
const repoId = Number(query.get('repoId'))
|
|
2079
|
+
const review = query.get('review') === '1'
|
|
2080
|
+
const listed = query.get('numbers')
|
|
2081
|
+
const numbers = listed === null ? [itemNumber(query)] : listed.split(',').map(text => itemNumber(new URLSearchParams({ number: text })))
|
|
2082
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || numbers.length > MAX_ASKED_PULLS || numbers.some(number => number === null) || new Set(numbers).size < numbers.length
|
|
2083
|
+
|| !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2084
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
|
|
2085
|
+
if (listed === null) return pullDependencyAnswer(deps, fullName, token, user, repoId, numbers[0]!, review)
|
|
2086
|
+
const answers = await eachOf(numbers as number[], PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, review).catch((err: unknown) => {
|
|
2087
|
+
// GitHub asked, though it failed: its access asked again after.
|
|
2088
|
+
if (err instanceof GithubApiError) return { value: { number, error: err.message }, kept: false }
|
|
2089
|
+
throw err
|
|
2090
|
+
}))
|
|
2091
|
+
return { value: { pulls: answers.map(({ value }) => value) }, kept: answers.every(({ kept }) => kept) }
|
|
2092
|
+
})
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// Pull request `number`'s answer, as the endpoint gives it, and whether all
|
|
2096
|
+
// of it was kept, GitHub asked for none of it.
|
|
2097
|
+
async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, number: number, review: boolean) {
|
|
2098
|
+
const kept = pullDependencyKept(deps, repoId)
|
|
2099
|
+
const reading: { told?: PullDependencies } = {}
|
|
2100
|
+
const answered = await keptAnswer(deps, `${repoId}\0pull-commits\0${PULL_DEPENDENCIES_READ}\0${number}`, async () => {
|
|
2101
|
+
const { commits, told } = await pullDependenciesAt(fullName, token, number, kept)
|
|
2102
|
+
reading.told = told
|
|
2103
|
+
return { commits }
|
|
2104
|
+
})
|
|
2105
|
+
// What its commits tell, as read, else as kept by them, else, no longer
|
|
2106
|
+
// kept, read again.
|
|
2107
|
+
const held = reading.told ?? (typeof answered.value.commits === 'string' ? await kept.read(answered.value.commits) : null)
|
|
2108
|
+
const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept)).told }
|
|
2109
|
+
// Where its review is on, those of lockfiles it leaves out told to none,
|
|
2110
|
+
// whoever asks.
|
|
2111
|
+
const setting = await deps.db.repoDependencyReview(repoId)
|
|
2112
|
+
const scoped = setting?.enabled ? heldDependencies(pulled, setting.lockfiles) : pulled
|
|
2113
|
+
const now = review ? await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(scoped)) : null
|
|
2114
|
+
// Its review by the lockfiles they were told by, though they have changed
|
|
2115
|
+
// since; turned off meanwhile, all of them, as where it is off.
|
|
2116
|
+
const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
|
|
2117
|
+
const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
|
|
2118
|
+
const reviewed = deps.reviewedDependencies ?? null
|
|
2119
|
+
const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
|
|
2120
|
+
// Its comments read for reviews, where the host reads them, while it is
|
|
2121
|
+
// open (pull-comment-reviews.ts reads whether it is with them).
|
|
2122
|
+
const parse = reviewed?.parseComments
|
|
2123
|
+
const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
|
|
2124
|
+
// The comment the host formats of the reader's reviews of its versions, for its copy button.
|
|
2125
|
+
const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
|
|
2126
|
+
return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
|
|
2127
|
+
...formatted ? { formatted } : {} }, kept: answered.kept && held !== null }
|
|
2128
|
+
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// GET /api/admin/repositories/branch-dependencies: what the lockfiles of the
|
|
2132
|
+
// repository's default branch lock that wants review, where its review is in
|
|
2133
|
+
// effect for the reader (`review`, else none, told without asking GitHub
|
|
2134
|
+
// where it reviews none), as a team's Dependencies tab shows it: its head, as
|
|
2135
|
+
// its pulse reads it, kept a while; every lockfile there its review holds to
|
|
2136
|
+
// (lockfile-scope.ts, commit-lockfiles.ts) as one directory, its versions as
|
|
2137
|
+
// a pull request's others are (pull-dependency-reviews.ts), each left unread
|
|
2138
|
+
// a directory of its own failing the review, as a pull request's are. Kept,
|
|
2139
|
+
// its access not asked again, where its head was kept and its lockfiles and
|
|
2140
|
+
// baseline held, GitHub asked for none of them.
|
|
2141
|
+
async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2142
|
+
const repoId = Number(query.get('repoId'))
|
|
2143
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2144
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
|
|
2145
|
+
const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
|
|
2146
|
+
const head = headRead.value.sha
|
|
2147
|
+
if (!head) return { value: { head: null, review: null }, kept: false }
|
|
2148
|
+
const asked: GithubAsked = { github: !headRead.kept }
|
|
2149
|
+
// Those its review holds to alone read, as many as are read of them.
|
|
2150
|
+
const setting = await deps.db.repoDependencyReview(repoId)
|
|
2151
|
+
if (!setting?.enabled) return { value: { head, review: null }, kept: false }
|
|
2152
|
+
const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path), asked)
|
|
2153
|
+
const { packages, paths } = lockedIn(read)
|
|
2154
|
+
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) },
|
|
2155
|
+
...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
|
|
2156
|
+
const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
|
|
2157
|
+
if (!now) return { value: { head, review: null }, kept: !asked.github }
|
|
2158
|
+
// Told by the lockfiles it held to as they were read, though they have
|
|
2159
|
+
// changed since.
|
|
2160
|
+
const state = { ...now, lockfiles: setting.lockfiles }
|
|
2161
|
+
const reviewed = deps.reviewedDependencies ?? null
|
|
2162
|
+
const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
|
|
2163
|
+
: await baselineFor(fullName, token, repoId, state, baselineKept(deps), asked)
|
|
2164
|
+
return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: !asked.github }
|
|
2165
|
+
}, async () => (await deps.db.repoDependencyReview(repoId))?.enabled ? null : { review: null })
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
|
|
2169
|
+
// default branch its dependency review reads (commit-lockfiles.ts), by their
|
|
2170
|
+
// paths, with their formats, at its head, as its pulse reads it, for its
|
|
2171
|
+
// settings to name those it holds to (lockfile-scope.ts).
|
|
2172
|
+
async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2173
|
+
const repoId = Number(query.get('repoId'))
|
|
2174
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2175
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2176
|
+
const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
|
|
2177
|
+
return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head)).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
|
|
2178
|
+
})
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// The app's users `accounts` are, by their logins in lower case, of those
|
|
2182
|
+
// who publish reviews to a team counting for repository `repoId`.
|
|
2183
|
+
async function repoReviewers(deps: ManagedHttpDeps, repoId: number, accounts: { login: string; githubId: number | null }[]) {
|
|
2184
|
+
const users = await deps.db.listUsersByAccount(accounts)
|
|
2185
|
+
const counted = await deps.db.listRepoReviewers(repoId, [...new Set(Object.values(users).map(user => user.id))])
|
|
2186
|
+
return new Map(Object.entries(users).flatMap(([login, user]) => counted.has(user.id) ? [[login.toLowerCase(), user.id] as const] : []))
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// The versions repository lockfiles locked at the commits their dependencies
|
|
2190
|
+
// are reviewed since, kept by each (reviewBaselineStore).
|
|
2191
|
+
function baselineKept(deps: ManagedHttpDeps): ReviewBaselineStore {
|
|
2192
|
+
const store = reviewBaselineStore(deps.db, deps.config.debug)
|
|
2193
|
+
return {
|
|
2194
|
+
async read(key) {
|
|
2195
|
+
const value = (await store.read(key))?.value
|
|
2196
|
+
const strings = (list: unknown) => Array.isArray(list) && list.every(item => typeof item === 'string')
|
|
2197
|
+
return strings(value?.versions) && strings(value?.unread) ? value : null
|
|
2198
|
+
},
|
|
2199
|
+
write: (key, baseline) => store.write(key, baseline, Date.now()),
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// What repository `repoId`'s pull requests' lockfiles add, kept by the
|
|
2204
|
+
// commits of each's base and head and the rules they were told by
|
|
2205
|
+
// (PULL_DEPENDENCIES_READ, pullDependencyStore).
|
|
2206
|
+
function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependencyStore {
|
|
2207
|
+
const store = pullDependencyStore<PullDependencies>(deps.db, deps.config.debug)
|
|
2208
|
+
const keyOf = (commits: string) => `${repoId}\0${PULL_DEPENDENCIES_READ}\0${commits}`
|
|
2209
|
+
return {
|
|
2210
|
+
async read(commits) {
|
|
2211
|
+
const value = (await store.read(keyOf(commits)))?.value
|
|
2212
|
+
return value && Array.isArray(value.lockfiles) ? value : null
|
|
2213
|
+
},
|
|
2214
|
+
write: (commits, told) => store.write(keyOf(commits), told, Date.now()),
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
|
|
2219
|
+
// (repository-pulse.ts): the default branch's year, counted from its commits
|
|
2220
|
+
// (pulseCommits), and the pull requests and issues of the last `weeks`
|
|
2221
|
+
// weeks, each kept apart. A year not read whole yet answers how many of its
|
|
2222
|
+
// commits are (`reading`), or of the pull requests merged in it (`pulls`);
|
|
2223
|
+
// one GitHub's statistics count, while it is still computing them,
|
|
2224
|
+
// `pending`, for the client to ask again. They are the whole repository's
|
|
2225
|
+
// (readWholeRepository).
|
|
2226
|
+
async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2227
|
+
const repoId = Number(query.get('repoId'))
|
|
2228
|
+
const weeks = Number(query.get('weeks') ?? '4')
|
|
2229
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2230
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2231
|
+
const [period, year] = await Promise.all([
|
|
2232
|
+
keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => repositoryActivity(fullName, token, weeks)),
|
|
2233
|
+
pulseCommits(deps, repoId, fullName, token),
|
|
2234
|
+
])
|
|
2235
|
+
return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
|
|
2236
|
+
pending: year.pending }, kept: period.kept && year.kept }
|
|
2237
|
+
})
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
// The default branch's year (repository-pulse.ts commitStats), counted from
|
|
2241
|
+
// its commits as the Commits tab keeps them, with their lines
|
|
2242
|
+
// (yearCommits): kept by the head it was read from (repositoryReadStore),
|
|
2243
|
+
// that head kept as an issue's page is; its commits merged from an agent's
|
|
2244
|
+
// pull request told by those merged (agentMerges), kept as read, the rest
|
|
2245
|
+
// read on by the next answer as the year's commits are, and read again as
|
|
2246
|
+
// an answer kept is, a label changing whose a pull request is without
|
|
2247
|
+
// moving the head, the year counted again where what they bring changed.
|
|
2248
|
+
// A year not read whole yet is how many of its commits are (`reading`), or
|
|
2249
|
+
// of the pull requests merged (`pulls`), beside the year last counted from
|
|
2250
|
+
// an earlier head over the same weeks, where one is kept, so that a head
|
|
2251
|
+
// moved on shows the year as it was till it is counted again; one past
|
|
2252
|
+
// YEAR_COMMITS is GitHub's statistics' (repositoryStats, repositoryLines),
|
|
2253
|
+
// kept together as an issue's page is and through GitHub computing them
|
|
2254
|
+
// again, `pending` while it first computes them, the year last counted
|
|
2255
|
+
// beside it so too.
|
|
2256
|
+
// A day as a pulse's items are narrowed by (YYYY-MM-DD, UTC), when it starts;
|
|
2257
|
+
// a GitHub login, as many of them as PULSE_AUTHORS.
|
|
2258
|
+
const PULSE_DAY = /^\d{4}-\d{2}-\d{2}$/u
|
|
2259
|
+
const GITHUB_LOGIN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?:\[bot\])?$/iu
|
|
2260
|
+
const PULSE_AUTHORS = 10
|
|
2261
|
+
const pulseDay = (value: string | null) => {
|
|
2262
|
+
const at = value !== null && PULSE_DAY.test(value) ? Date.parse(`${value}T00:00:00Z`) : Number.NaN
|
|
2263
|
+
return Number.isSafeInteger(at) ? at : null
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
// GET /api/admin/repositories/pulse/items: the Pulse tab's pull requests and
|
|
2267
|
+
// issues narrowed to a picked day or week (`from` to `to`, else on from
|
|
2268
|
+
// `from`) and the GitHub accounts a picked contributor committed as
|
|
2269
|
+
// (`authors`), as repositoryActivity reads them, kept apart for each.
|
|
2270
|
+
async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2271
|
+
const repoId = Number(query.get('repoId'))
|
|
2272
|
+
const weeks = Number(query.get('weeks') ?? '4')
|
|
2273
|
+
const [from, to] = [pulseDay(query.get('from')), query.has('to') ? pulseDay(query.get('to')) : null]
|
|
2274
|
+
const logins = (query.get('authors') ?? '').split(',').filter(Boolean)
|
|
2275
|
+
// Each once, whatever its case, as GitHub matches logins.
|
|
2276
|
+
const authors = [...new Set(logins.map(login => login.toLowerCase()))].toSorted()
|
|
2277
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks) || from === null || (query.has('to') && (to === null || to < from))
|
|
2278
|
+
|| authors.length > PULSE_AUTHORS || !logins.every(login => GITHUB_LOGIN.test(login))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2279
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2280
|
+
const narrowing = { from, to, authors }
|
|
2281
|
+
const items = await keptAnswer(deps, `${repoId}\0pulse-items\0${weeks}\0${from}\0${to ?? ''}\0${narrowing.authors.join(',')}`,
|
|
2282
|
+
() => repositoryActivity(fullName, token, weeks, Date.now(), globalThis.fetch, narrowing))
|
|
2283
|
+
return { value: { weeks, ...items.value }, kept: items.kept }
|
|
2284
|
+
})
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
|
|
2288
|
+
const first = pulseSince(52, Date.now())
|
|
2289
|
+
const head = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
|
|
2290
|
+
if (!head.value.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
|
|
2291
|
+
const store = repositoryReadStore(deps.db, deps.config.debug)
|
|
2292
|
+
const key = `${repoId}\0pulse-year\0${PULSE_YEAR_VERSION}\0${head.value.sha}\0${first}`
|
|
2293
|
+
const lastKey = `${repoId}\0pulse-year-last`
|
|
2294
|
+
const mergesKey = `${repoId}\0pulse-merges\0${PULSE_YEAR_VERSION}`
|
|
2295
|
+
const last = async () => {
|
|
2296
|
+
const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
|
|
2297
|
+
return kept?.first === first ? kept.stats : null
|
|
2298
|
+
}
|
|
2299
|
+
const sha = head.value.sha
|
|
2300
|
+
// What tells whose the year's commits are, kept as read: the agents' pull
|
|
2301
|
+
// requests merged (agentMerges), and the commits that release the
|
|
2302
|
+
// repository (`releases`, release-commits.ts releaseCommits).
|
|
2303
|
+
type Attribution = AgentMerges & { releases?: string[] }
|
|
2304
|
+
const keptAttribution = async () => ((await store.read(mergesKey))?.value as Attribution | undefined) ?? null
|
|
2305
|
+
const attribution = async (kept: Attribution | null): Promise<Attribution> => {
|
|
2306
|
+
const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept), releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
|
|
2307
|
+
return { ...merges, releases }
|
|
2308
|
+
}
|
|
2309
|
+
// The year counted from its `commits` as `read` tells whose they are and
|
|
2310
|
+
// which were pushed directly, the branch followed through those it was
|
|
2311
|
+
// read on past (`passed`, yearCommits) too; kept by its head and as the
|
|
2312
|
+
// year last counted.
|
|
2313
|
+
const count = async ({ commits, passed }: { commits: HistoryCommit[]; passed: HistoryCommit[] }, read: Attribution) => {
|
|
2314
|
+
const walked = [...commits, ...passed]
|
|
2315
|
+
const stats = commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha))
|
|
2316
|
+
await Promise.all([store.write(key, stats, Date.now()), store.write(lastKey, { first, stats }, Date.now())])
|
|
2317
|
+
return stats
|
|
2318
|
+
}
|
|
2319
|
+
// A year kept by its head, what tells whose its commits are read again as
|
|
2320
|
+
// an answer kept is (keptAnswer), a label or a tag changing it without
|
|
2321
|
+
// moving the head: counted again from its pages kept where what agents'
|
|
2322
|
+
// pull requests bring, or the releases, changed, or a read of the pull
|
|
2323
|
+
// requests left unfinished is finished; the year as kept answered
|
|
2324
|
+
// meanwhile, and where reading them fails.
|
|
2325
|
+
const relabelled = async (stats: PulseStats) => {
|
|
2326
|
+
const recounted: { stats?: PulseStats } = {}
|
|
2327
|
+
await keptAnswer(deps, mergesKey, async () => {
|
|
2328
|
+
const kept = await keptAttribution()
|
|
2329
|
+
const read = await attribution(kept)
|
|
2330
|
+
const same = kept && !kept.next && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join()
|
|
2331
|
+
if (read.next || same) return read
|
|
2332
|
+
const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
|
|
2333
|
+
if (year.complete) recounted.stats = await count(year, read)
|
|
2334
|
+
return read
|
|
2335
|
+
}, () => true).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
|
|
2336
|
+
return recounted.stats ?? stats
|
|
2337
|
+
}
|
|
2338
|
+
const held = (await store.read(key))?.value as PulseStats | { tooMany: true } | undefined
|
|
2339
|
+
if (held && !('tooMany' in held)) return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
|
|
2340
|
+
if (!held) {
|
|
2341
|
+
const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
|
|
2342
|
+
if (year.complete) {
|
|
2343
|
+
const read = await attribution(await keptAttribution())
|
|
2344
|
+
await store.write(mergesKey, read, Date.now())
|
|
2345
|
+
if (read.next) return { stats: await last(), reading: null, pulls: read.next.read, pending: false, kept: false }
|
|
2346
|
+
return { stats: await count(year, read), reading: null, pulls: null, pending: false, kept: false }
|
|
2347
|
+
}
|
|
2348
|
+
if (!year.tooMany) return { stats: await last(), reading: year.read, pulls: null, pending: false, kept: false }
|
|
2349
|
+
await store.write(key, { tooMany: true }, Date.now())
|
|
2350
|
+
}
|
|
2351
|
+
// Its commits and lines read together, kept as one: never the one GitHub
|
|
2352
|
+
// has counted since a push beside the other kept from before it.
|
|
2353
|
+
const computing = (err: unknown) => err instanceof StatsPending
|
|
2354
|
+
const counted = await keptAnswer(deps, `${repoId}\0pulse-stats`, async () => {
|
|
2355
|
+
const [stats, lines] = await Promise.all([repositoryStats(fullName, token), repositoryLines(fullName, token, first)])
|
|
2356
|
+
return withLines(stats, lines)
|
|
2357
|
+
}, computing).catch((err: unknown) => { if (computing(err)) return null; throw err })
|
|
2358
|
+
if (counted) return { stats: counted.value, reading: null, pulls: null, pending: false, kept: counted.kept }
|
|
2359
|
+
return { stats: await last(), reading: null, pulls: null, pending: true, kept: false }
|
|
2360
|
+
}
|
|
2361
|
+
|
|
1349
2362
|
function prebuildBundle(deps: ManagedHttpDeps, id: string) {
|
|
1350
2363
|
if (!deps.bundleCache || deps.config.serverless) return
|
|
1351
2364
|
deps.track((async () => {
|
|
@@ -1420,9 +2433,13 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
|
|
|
1420
2433
|
// (repository-browser.ts), and that access checked again once it is read,
|
|
1421
2434
|
// before any of it is told; packages as the npm viewer's admins read them, in
|
|
1422
2435
|
// 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
|
|
2436
|
+
// their scope, and each lockfile's asked of npm's advisories at once; each
|
|
2437
|
+
// repository's Dependabot alerts, open or dismissed, read with the App's
|
|
2438
|
+
// installation token where the admin's own access to it lets them see them on
|
|
2439
|
+
// GitHub (dependabot-access.ts); and its GitHub Actions, the references they
|
|
2440
|
+
// make resolved with the admin's own GitHub token (github-actions.ts). The
|
|
2441
|
+
// reader leaving, losing their admin role or
|
|
2442
|
+
// the server shutting down stops it.
|
|
1426
2443
|
async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1427
2444
|
const s = await readAdminSession(res, deps, cookie)
|
|
1428
2445
|
if (!s) return
|
|
@@ -1435,7 +2452,8 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1435
2452
|
const controller = new AbortController()
|
|
1436
2453
|
res.on('close', () => { controller.abort() })
|
|
1437
2454
|
res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
|
|
1438
|
-
const
|
|
2455
|
+
const user = await repositoryBrowserUser(deps, s.user.id)
|
|
2456
|
+
const browser = createRepositoryBrowser(deps.config, user)
|
|
1439
2457
|
const scopes = new Map<string, Promise<boolean>>()
|
|
1440
2458
|
const teamScoped = (name: string) => {
|
|
1441
2459
|
const scope = npmPackageScope(name)
|
|
@@ -1460,8 +2478,15 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1460
2478
|
lockfiles: async () => (await upstreamGithub(() => client.findRepoLockfiles({ repo: repo.fullName, sha: commit }))).lockfiles,
|
|
1461
2479
|
file: path => upstreamGithub(() => client.getRepoFile({ repo: repo.fullName, path, ref: commit })),
|
|
1462
2480
|
recheck: async () => { await reader.recheckAccess() },
|
|
2481
|
+
// As GitHub has them now, where the admin's own access lets them see them.
|
|
2482
|
+
dependabot: () => reader.dependabotAlerts(),
|
|
2483
|
+
workflows: () => reader.directory(WORKFLOWS_DIR, commit).then(({ entries }) => entries.filter(entry => entry.type === 'file' && /\.ya?ml$/iu.test(entry.name)).map(entry => entry.path),
|
|
2484
|
+
(error: unknown) => { if (error instanceof GithubApiError && error.status === 404) return []; throw error }),
|
|
1463
2485
|
}
|
|
1464
2486
|
},
|
|
2487
|
+
actions: githubActionsSource(user.token),
|
|
2488
|
+
actionFiles: scanReadStore(deps.db, 'action-files', SCAN_READ_KEPT_MS, deps.config.debug),
|
|
2489
|
+
actionLines: scanReadStore(deps.db, 'actions', RISK_KEPT_MS, deps.config.debug),
|
|
1465
2490
|
teamScoped,
|
|
1466
2491
|
// Each package's downloads readied before it is assessed
|
|
1467
2492
|
// (scan-downloads.ts), so that none waits on npm's downloads API; a
|
|
@@ -2244,7 +3269,7 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2244
3269
|
if (outputBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
|
|
2245
3270
|
parts.push(suffix)
|
|
2246
3271
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
2247
|
-
|
|
3272
|
+
writeResponseParts(res, parts, outputBytes)
|
|
2248
3273
|
}
|
|
2249
3274
|
|
|
2250
3275
|
// The server selects the complete published workspace for classification.
|
|
@@ -2274,24 +3299,23 @@ async function visibleFindingIds(deps: ManagedHttpDeps, user: StoredUser, report
|
|
|
2274
3299
|
}
|
|
2275
3300
|
const rec = await deps.db.getReport(reportId)
|
|
2276
3301
|
if (rec == null) return new Set()
|
|
2277
|
-
|
|
2278
|
-
|
|
3302
|
+
return reportFindingIds(deps, rec, await repositoryName(deps, rec.repoId))
|
|
3303
|
+
}
|
|
3304
|
+
// A whole report's finding ids, kept by its content, name and repository,
|
|
3305
|
+
// the one read least recently dropped first.
|
|
3306
|
+
async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256: string; filename: string }, github: string | null): Promise<Set<string>> {
|
|
3307
|
+
const key = JSON.stringify([rec.id, rec.sha256, rec.filename, { github }])
|
|
2279
3308
|
let cache = visibleIdsCaches.get(deps)
|
|
2280
3309
|
if (cache == null) { cache = new Map(); visibleIdsCaches.set(deps, cache) }
|
|
2281
3310
|
const hit = cache.get(key)
|
|
2282
|
-
if (hit != null) return hit
|
|
2283
|
-
const
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
if (report == null) return ids
|
|
2291
|
-
for (const f of report.findings) {
|
|
2292
|
-
const id = (f as { id?: unknown }).id
|
|
2293
|
-
if (typeof id === 'string' && id !== '') ids.add(id)
|
|
2294
|
-
}
|
|
3311
|
+
if (hit != null) { cache.delete(key); cache.set(key, hit); return hit }
|
|
3312
|
+
const bytes = await deps.reportStore.get(rec.id)
|
|
3313
|
+
if (bytes == null) return new Set()
|
|
3314
|
+
const { data } = readManagedReport(bytes.toString('utf8'), rec.filename)
|
|
3315
|
+
if (data == null) return new Set()
|
|
3316
|
+
// The ids the team workspace derives (teamFindingIds), from the one parse.
|
|
3317
|
+
const ids = await reportIds(data)
|
|
3318
|
+
ids.delete('')
|
|
2295
3319
|
while (cache.size >= VISIBLE_IDS_CACHE_MAX) {
|
|
2296
3320
|
const oldest = cache.keys().next().value
|
|
2297
3321
|
if (oldest === undefined) break
|
|
@@ -2702,8 +3726,12 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
|
|
|
2702
3726
|
// Null for none.
|
|
2703
3727
|
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
|
|
2704
3728
|
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2705
|
-
|
|
2706
|
-
|
|
3729
|
+
return s == null ? null : npmReaderOf(deps, s.user, names)
|
|
3730
|
+
}
|
|
3731
|
+
// The same of `user`, as a session the request read just now tells them.
|
|
3732
|
+
async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
|
|
3733
|
+
if (!roleAtLeast(user.role, 'view')) return null
|
|
3734
|
+
return { role: user.role, scopes: new Set(npmScopesCount(user.role, names) ? await deps.db.listUserNpmScopes(user.id) : []), userId: user.id }
|
|
2707
3735
|
}
|
|
2708
3736
|
|
|
2709
3737
|
// What a registry read found, or a 404 for the package (handleNpm's catch
|
|
@@ -2713,27 +3741,34 @@ function found<T>(value: T | null | undefined): T {
|
|
|
2713
3741
|
return value
|
|
2714
3742
|
}
|
|
2715
3743
|
|
|
3744
|
+
// Version documents a list of reviews reads at once: each holds a version
|
|
3745
|
+
// document's room in what npm's reads may hold at once (npm-packages.ts),
|
|
3746
|
+
// which refuses past thirty-two of them.
|
|
3747
|
+
const REVIEW_READS_AT_ONCE = 8
|
|
3748
|
+
|
|
2716
3749
|
// Lists must enforce the same target and base access as the review itself,
|
|
2717
|
-
// including permission changes while the registry requests are in flight
|
|
2718
|
-
|
|
3750
|
+
// including permission changes while the registry requests are in flight;
|
|
3751
|
+
// first as `session`, the one the request read just now, where given.
|
|
3752
|
+
async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[],
|
|
3753
|
+
session?: { id: string; role: NpmReader['role'] }): Promise<T[] | null> {
|
|
2719
3754
|
const controller = new AbortController()
|
|
2720
3755
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2721
3756
|
res.on('close', onClose)
|
|
2722
3757
|
try {
|
|
2723
|
-
const
|
|
3758
|
+
const names = reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []])
|
|
3759
|
+
const reader = session ? await npmReaderOf(deps, session, names) : await npmReader(deps, cookie, names)
|
|
2724
3760
|
if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2725
|
-
const documents = new Map<string, Promise<NpmVersionDocument | null>>()
|
|
2726
3761
|
// Public versions' documents as the viewer keeps them (npm-kept.ts), a
|
|
2727
|
-
// day: a list asks npm for none it read
|
|
2728
|
-
|
|
2729
|
-
const
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
}
|
|
2734
|
-
const
|
|
2735
|
-
|
|
2736
|
-
])))
|
|
3762
|
+
// day, all read of the database at once: a list asks npm for none it read
|
|
3763
|
+
// within it. Each version once, as many at once as REVIEW_READS_AT_ONCE.
|
|
3764
|
+
const versionsOf = (review: T) => [[review.package, review.version], ...review.basePackage ? [[review.basePackage, review.baseVersion!]] : []] as [string, string][]
|
|
3765
|
+
const asked = new Map(reviews.flatMap(versionsOf).map(([name, version]) => [`${name}\0${version}`, [name, version] as const]))
|
|
3766
|
+
const store = npmVersionStore(deps.db, deps.config.debug)
|
|
3767
|
+
const held = await store.readMany?.([...asked.values()].map(([name, version]) => ({ name, version }))) ?? new Map()
|
|
3768
|
+
const kept: NpmVersionStore = { read: (name, version) => Promise.resolve(held.get(`${name}@${version}`) ?? null), write: store.write }
|
|
3769
|
+
const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
|
|
3770
|
+
const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
|
|
3771
|
+
const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
|
|
2737
3772
|
const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
|
|
2738
3773
|
if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2739
3774
|
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
@@ -2846,7 +3881,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2846
3881
|
try {
|
|
2847
3882
|
const reader = await npmReader(deps, cookie, [context.package, ...context.basePackage ? [context.basePackage] : []])
|
|
2848
3883
|
if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2849
|
-
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
3884
|
+
const kept = readTogether(npmVersionStore(deps.db, deps.config.debug), [{ name, version }, ...context.basePackage ? [{ name: context.basePackage, version: context.baseVersion! }] : []])
|
|
2850
3885
|
const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
|
|
2851
3886
|
const [doc, base] = await Promise.all([read(name, version), context.basePackage ? read(context.basePackage, context.baseVersion!) : null])
|
|
2852
3887
|
if (!doc || (context.basePackage && !base)) { sendJson(res, 404, { error: 'package-not-found' }); return }
|
|
@@ -2881,8 +3916,8 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2881
3916
|
if (!change) {
|
|
2882
3917
|
const allBases = params.get('allBases') === 'true'
|
|
2883
3918
|
const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
|
|
2884
|
-
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) })
|
|
3919
|
+
const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows, { id: current.userId, role: current.role }) : rows
|
|
3920
|
+
if (reviews) sendJson(res, 200, { reviews: await withFormatted(deps.reviewedDependencies ?? null, current.userId, reviews), teams: await deps.db.listDependencyReviewTeams(current.userId) })
|
|
2886
3921
|
return
|
|
2887
3922
|
}
|
|
2888
3923
|
const checked = await checkMutation(req, res, deps, cookie)
|
|
@@ -2892,7 +3927,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2892
3927
|
sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
|
|
2893
3928
|
}
|
|
2894
3929
|
const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
|
|
2895
|
-
sendJson(res, 200, { review })
|
|
3930
|
+
sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
|
|
2896
3931
|
} catch (err) {
|
|
2897
3932
|
if (controller.signal.aborted) return
|
|
2898
3933
|
if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
|
|
@@ -2916,7 +3951,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2916
3951
|
if (!isNpmPackageName(name) || !isNpmPackageName(otherName) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
|
|
2917
3952
|
|| (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
|
|
2918
3953
|
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
|
|
2919
|
-
|| (path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
|
|
3954
|
+
|| ((path === NPM_COMPARISON_PATH || path === NPM_DELTA_PATH) && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
|
|
2920
3955
|
sendJson(res, 400, { error: 'bad-package' }); return
|
|
2921
3956
|
}
|
|
2922
3957
|
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
@@ -2986,7 +4021,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2986
4021
|
await reply(doc.private, { name, downloads, github })
|
|
2987
4022
|
return
|
|
2988
4023
|
}
|
|
2989
|
-
|
|
4024
|
+
// The version it is compared with, where its route names one, read
|
|
4025
|
+
// beside it, the two read of those kept at once.
|
|
4026
|
+
const against: [string, string, boolean] | null = (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)') || (path === NPM_DELTA_PATH && otherSpec !== '(empty)')
|
|
4027
|
+
|| ((path === NPM_TYPE_ONLY_PATH || path === NPM_RENAME_DIFF_PATH || (path === NPM_PRETTY_PATH && !params.has('path'))) && otherSpec !== '')
|
|
4028
|
+
? [name, otherSpec, privileged]
|
|
4029
|
+
: path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' ? [otherName, otherSpec, canReadPrivateNpm(reader, otherName)] : null
|
|
4030
|
+
const keptTogether = against ? readTogether(keptVersions, [{ name, version: spec }, { name: against[0], version: against[1] }]) : keptVersions
|
|
4031
|
+
const otherAsked = against && readNpmVersion(against[0], against[1], against[2], controller.signal, keptTogether)
|
|
4032
|
+
otherAsked?.catch(() => {})
|
|
4033
|
+
const otherDoc = async () => found(await otherAsked!)
|
|
4034
|
+
const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptTogether))
|
|
2990
4035
|
if (spec === 'latest') latest = Promise.resolve(doc)
|
|
2991
4036
|
const repo = githubRepoOf(doc), sha = bundleCommitHash(doc.manifest['gitHead'])
|
|
2992
4037
|
// What the viewer asks about a version beside its files, by its route,
|
|
@@ -3009,7 +4054,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3009
4054
|
// new and however few download it where a team lists its scope, its own
|
|
3010
4055
|
// packages, as a new dependency on a package of such a scope is.
|
|
3011
4056
|
if (path === NPM_RISK_PATH) {
|
|
3012
|
-
const old = otherSpec === '' || otherSpec === '(empty)' ? null :
|
|
4057
|
+
const old = otherSpec === '' || otherSpec === '(empty)' ? null : await otherDoc()
|
|
3013
4058
|
const scope = npmPackageScope(name)
|
|
3014
4059
|
const teamScoped = scope !== null && await deps.db.hasTeamNpmScope(scope)
|
|
3015
4060
|
const assessment = await npmRisk(doc, otherSpec === '' ? 'pick' : old, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped, fromEmpty: otherSpec === '(empty)',
|
|
@@ -3020,13 +4065,27 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3020
4065
|
return
|
|
3021
4066
|
}
|
|
3022
4067
|
if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
|
|
4068
|
+
// The lines it changes against `otherVersion`, or `(empty)`, its whole
|
|
4069
|
+
// package's, and those but for declaration files changed in types alone
|
|
4070
|
+
// (npm-delta.ts), as a list of versions wanting reviews tells them.
|
|
4071
|
+
if (path === NPM_DELTA_PATH) {
|
|
4072
|
+
const old = otherSpec === '(empty)' ? null : await otherDoc()
|
|
4073
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
4074
|
+
const side = (version: NpmVersionDocument) => ({ integrity: version.dist.integrity, files: async () => {
|
|
4075
|
+
const load = loadNpmFiles(version)
|
|
4076
|
+
try { return await load.result } finally { load.release() }
|
|
4077
|
+
} })
|
|
4078
|
+
const delta = await npmDelta(old ? side(old) : null, side(doc), npmDeltaStore<NpmDelta>(deps.db, deps.config.debug))
|
|
4079
|
+
await reply(doc.private || old?.private === true, { name, version: doc.version, otherVersion: old?.version ?? null, ...delta })
|
|
4080
|
+
return
|
|
4081
|
+
}
|
|
3023
4082
|
// Its files paired with those of `otherVersion` (of `otherName`, else the
|
|
3024
4083
|
// same package), as Compare and Review pair them, with what their
|
|
3025
4084
|
// Overview reads beside them (comparison.ts): the version shown against
|
|
3026
4085
|
// the one compared with, or `(empty)`, none, as Review reads a first
|
|
3027
4086
|
// version. Read of both, each package's access checked again once worked out.
|
|
3028
4087
|
if (path === NPM_COMPARISON_PATH) {
|
|
3029
|
-
const old = otherSpec === '(empty)' ? null :
|
|
4088
|
+
const old = otherSpec === '(empty)' ? null : await otherDoc()
|
|
3030
4089
|
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
3031
4090
|
const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
|
|
3032
4091
|
let body
|
|
@@ -3064,7 +4123,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3064
4123
|
// and those that changed in types alone (type-only.ts).
|
|
3065
4124
|
if (path === NPM_PRETTY_PATH || path === NPM_TYPE_ONLY_PATH || (path === NPM_RENAME_DIFF_PATH && !diffed)) {
|
|
3066
4125
|
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
3067
|
-
const other = otherSpec === '' ? null :
|
|
4126
|
+
const other = otherSpec === '' ? null : await otherDoc()
|
|
3068
4127
|
const sides = other && await deps.prettyCache.npmSides(doc, other)
|
|
3069
4128
|
const lines = path === NPM_RENAME_DIFF_PATH ? renamedPairs(sides!, !doc.private && !other!.private)
|
|
3070
4129
|
: path === NPM_TYPE_ONLY_PATH ? typeOnlyFiles(sides!) : sides ? compared(sides) : await deps.prettyCache.npmAll(doc)
|
|
@@ -3082,7 +4141,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3082
4141
|
if (diffed) {
|
|
3083
4142
|
const { prettyCache } = deps
|
|
3084
4143
|
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
3085
|
-
const other =
|
|
4144
|
+
const other = await otherDoc()
|
|
3086
4145
|
const diff = await renameDiffOf(diffed, async () => [await prettyText(await prettyCache.npm(doc, diffed.base)),
|
|
3087
4146
|
await prettyText(await prettyCache.npm(other, diffed.other))], !doc.private && !other.private)
|
|
3088
4147
|
if (!(await recheck(doc.private || other.private))) { diff.stream.destroy(); return }
|
|
@@ -3102,18 +4161,20 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3102
4161
|
// viewer's own request to meet. Its whole document, read for its
|
|
3103
4162
|
// advisories' version list and the time each version was published
|
|
3104
4163
|
// (npmPublishTime), is read only where registry reads leave room for
|
|
3105
|
-
// readers'
|
|
3106
|
-
|
|
4164
|
+
// readers'; a public version's time is then kept in the database
|
|
4165
|
+
// (npm-kept.ts), to come with the version on any instance.
|
|
4166
|
+
const published = download ? undefined : npmPublishTime(doc, npmPublishTimeStore(deps.db, deps.config.debug))
|
|
4167
|
+
if (published) {
|
|
3107
4168
|
const quiet = (asked: Promise<unknown>) => { asked.catch(() => {}) }
|
|
3108
4169
|
quiet(npmDownloads(name))
|
|
3109
4170
|
quiet(latestDoc().then(newest => newest && githubFigures(newest)))
|
|
3110
4171
|
quiet(readNpmVersions(name, privileged, controller.signal, { times: true, spare: true })
|
|
3111
|
-
.then(versions => versions && npmAdvisories(name, versions.versions, advisoryOptions)))
|
|
4172
|
+
.then(versions => versions && Promise.all([published.keep(), npmAdvisories(name, versions.versions, advisoryOptions)])))
|
|
3112
4173
|
quiet(tagsOf())
|
|
3113
4174
|
quiet(socketOf())
|
|
3114
4175
|
quiet(provenanceOf())
|
|
3115
4176
|
}
|
|
3116
|
-
const load =
|
|
4177
|
+
const load = published ? loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha), published.read) : loadNpmTarball(doc)
|
|
3117
4178
|
res.once('close', load.release)
|
|
3118
4179
|
const bytes = await load.result
|
|
3119
4180
|
if (!(await recheck(doc.private))) return
|
|
@@ -3207,6 +4268,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3207
4268
|
if (path === CALLBACK_PATH) {
|
|
3208
4269
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3209
4270
|
try {
|
|
4271
|
+
if (isLinkOAuthCallback(config, url.searchParams, cookie)) {
|
|
4272
|
+
if (viewing) { sendViewOnly(res); return }
|
|
4273
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
4274
|
+
if (!s) return
|
|
4275
|
+
const result = await linkOAuthCallback(config, db, s.session, url.searchParams, cookie, { avatarStore })
|
|
4276
|
+
if (result.linked != null) await activity(deps, s.user, 'access', `linked GitHub account @${result.linked}`)
|
|
4277
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
4278
|
+
res.end(); return
|
|
4279
|
+
}
|
|
3210
4280
|
if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
|
|
3211
4281
|
if (viewing) { sendViewOnly(res); return }
|
|
3212
4282
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
@@ -3234,6 +4304,17 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3234
4304
|
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
3235
4305
|
res.end(); return
|
|
3236
4306
|
}
|
|
4307
|
+
if (path === LINK_LOGIN_PATH) {
|
|
4308
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4309
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
4310
|
+
if (!s) return
|
|
4311
|
+
const result = linkLoginRedirect(config, s.session)
|
|
4312
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
4313
|
+
res.end(); return
|
|
4314
|
+
}
|
|
4315
|
+
if (path === PROFILE_PATH) { await handleProfile(req, res, deps, cookie); return }
|
|
4316
|
+
const unlink = /^\/api\/auth\/profile\/accounts\/([a-f\d-]{36})$/iu.exec(path)
|
|
4317
|
+
if (unlink) { await handleUnlinkAccount(req, res, deps, cookie, unlink[1]!); return }
|
|
3237
4318
|
if (path === SESSION_PATH) {
|
|
3238
4319
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3239
4320
|
const { body, headers } = await currentSession(config, db, cookie)
|
|
@@ -3274,12 +4355,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3274
4355
|
if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
|
|
3275
4356
|
// Cached avatar by user id, served same-origin (the page CSP forbids the
|
|
3276
4357
|
// 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.
|
|
4358
|
+
// switch never serves a stale avatar. Workspace access is required. A user's
|
|
4359
|
+
// is that of the account chosen on their profile; `own` asks for the
|
|
4360
|
+
// sign-in account's. A linked account's is served by its own id.
|
|
3278
4361
|
if (path.startsWith(AVATAR_PREFIX)) {
|
|
3279
4362
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3280
4363
|
const s = await readSession(config, db, cookie, Date.now())
|
|
3281
4364
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3282
|
-
|
|
4365
|
+
const id = path.slice(AVATAR_PREFIX.length)
|
|
4366
|
+
if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
|
|
4367
|
+
await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
|
|
3283
4368
|
return
|
|
3284
4369
|
}
|
|
3285
4370
|
// Admin: list users (admin-only).
|
|
@@ -3320,8 +4405,57 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3320
4405
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3321
4406
|
await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
|
|
3322
4407
|
}
|
|
4408
|
+
if (path === REPO_CODE_PATH) {
|
|
4409
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4410
|
+
await handleRepositoryCode(res, deps, cookie, url.searchParams); return
|
|
4411
|
+
}
|
|
4412
|
+
if (path === REPO_HISTORY_PATH) {
|
|
4413
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4414
|
+
await handleRepositoryHistory(res, deps, cookie, url.searchParams); return
|
|
4415
|
+
}
|
|
4416
|
+
if (path === REPO_CHANGES_PATH) {
|
|
4417
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4418
|
+
await handleRepositoryChanges(res, deps, cookie, url.searchParams); return
|
|
4419
|
+
}
|
|
4420
|
+
if (path === REPO_TREE_PATH) {
|
|
4421
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4422
|
+
await handleRepositoryTree(res, deps, cookie, url.searchParams); return
|
|
4423
|
+
}
|
|
4424
|
+
if (path === REPO_ISSUES_PATH) {
|
|
4425
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4426
|
+
await handleRepositoryIssues(res, deps, cookie, url.searchParams); return
|
|
4427
|
+
}
|
|
4428
|
+
if (path === REPO_AGENTS_PATH) {
|
|
4429
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4430
|
+
await handleRepositoryAgents(res, deps, cookie, url.searchParams); return
|
|
4431
|
+
}
|
|
4432
|
+
if (path === REPO_ITEM_PATH) {
|
|
4433
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4434
|
+
await handleRepositoryItem(res, deps, cookie, url.searchParams); return
|
|
4435
|
+
}
|
|
4436
|
+
if (path === REPO_PULL_DEPENDENCIES_PATH) {
|
|
4437
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4438
|
+
await handleRepositoryPullDependencies(res, deps, cookie, url.searchParams); return
|
|
4439
|
+
}
|
|
4440
|
+
if (path === REPO_BRANCH_DEPENDENCIES_PATH) {
|
|
4441
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4442
|
+
await handleRepositoryBranchDependencies(res, deps, cookie, url.searchParams); return
|
|
4443
|
+
}
|
|
4444
|
+
if (path === REPO_LOCKFILES_PATH) {
|
|
4445
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4446
|
+
await handleRepositoryLockfiles(res, deps, cookie, url.searchParams); return
|
|
4447
|
+
}
|
|
4448
|
+
if (path === REPO_PULSE_PATH) {
|
|
4449
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4450
|
+
await handleRepositoryPulse(res, deps, cookie, url.searchParams); return
|
|
4451
|
+
}
|
|
4452
|
+
if (path === REPO_PULSE_ITEMS_PATH) {
|
|
4453
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4454
|
+
await handleRepositoryPulseItems(res, deps, cookie, url.searchParams); return
|
|
4455
|
+
}
|
|
3323
4456
|
if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
|
|
3324
4457
|
if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
|
|
4458
|
+
if (path === REPO_DEPENDENCY_REVIEW_PATH) { await handleRepositoryDependencyReview(req, res, deps, cookie); return }
|
|
3325
4459
|
if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
|
|
3326
4460
|
if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
|
|
3327
4461
|
// Reports: list / upload on the exact path, download / delete per-id on the
|
|
@@ -3512,8 +4646,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3512
4646
|
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
3513
4647
|
}
|
|
3514
4648
|
const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
|
|
3515
|
-
const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
|
|
3516
|
-
if (visible) sendJson(res, 200, { review: visible[0] ?? null })
|
|
4649
|
+
const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [], s.user)
|
|
4650
|
+
if (visible) sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, s.user.id, visible))[0] ?? null })
|
|
3517
4651
|
return
|
|
3518
4652
|
}
|
|
3519
4653
|
if (path === '/api/npm/reviews/discard') {
|
|
@@ -3542,8 +4676,28 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3542
4676
|
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
3543
4677
|
}
|
|
3544
4678
|
const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
3545
|
-
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
|
|
3546
|
-
if (reviews) sendJson(res, 200, { ...page, reviews })
|
|
4679
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
|
|
4680
|
+
if (reviews) sendJson(res, 200, { ...page, reviews: await withFormatted(deps.reviewedDependencies ?? null, s.user.id, reviews) })
|
|
4681
|
+
return
|
|
4682
|
+
}
|
|
4683
|
+
// Those published to a team, of one who reads them there, as its
|
|
4684
|
+
// Dependencies tab lists them, fifty a page.
|
|
4685
|
+
if (path === '/api/npm/reviews/team') {
|
|
4686
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4687
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
4688
|
+
if (!s) return
|
|
4689
|
+
const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
|
|
4690
|
+
if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
|
|
4691
|
+
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
4692
|
+
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
4693
|
+
}
|
|
4694
|
+
const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
4695
|
+
if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
4696
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
|
|
4697
|
+
if (!reviews) return
|
|
4698
|
+
// Its access asked again, as it may have been taken while npm was read.
|
|
4699
|
+
if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
4700
|
+
sendJson(res, 200, { ...page, reviews })
|
|
3547
4701
|
return
|
|
3548
4702
|
}
|
|
3549
4703
|
if (path === '/api/npm/reviews') {
|
|
@@ -3564,7 +4718,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3564
4718
|
// Feeds lease a connection per poll, never across streaming sleeps. This
|
|
3565
4719
|
// also covers capability-authenticated feeds handled by public-workspace.
|
|
3566
4720
|
const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
|
|
3567
|
-
const serve = () => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)
|
|
4721
|
+
const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
|
|
3568
4722
|
const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
|
|
3569
4723
|
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
3570
4724
|
console.warn('managed: request handler error:', err)
|