@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50
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/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +3 -0
- package/client/dependency-paths.js +36 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +115 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +357 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-metadata.d.ts +15 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +333 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +18 -0
- package/common/dependency-reviews.js +64 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +57 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/finding-detail.d.ts +2 -0
- package/common/managed/finding-detail.js +32 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +109 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +35 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +347 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +5 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +46 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +39 -0
- package/common/workspace-groups.js +176 -0
- package/out/activity.js +4 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2WDZPXVL.js +3 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4XMP7YVH.js +36 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-6S2U2UYG.js +232 -0
- package/out/chunk-75YLHBCM.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FC65TPUB.js +1 -0
- package/out/chunk-FKBXNUW4.js +1 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HTBYHSPG.js +1 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-PWFUOLZK.js +17 -0
- 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 +2 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TQJDYHVE.js +2 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-UK7RGJT6.js +3 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WCPJDAYA.js +10 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WYLQWYLA.js +1 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +33 -0
- package/out/client-sync.js +11 -17
- package/out/compare.js +35 -0
- package/out/dialogs.js +55 -0
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +14 -10
- package/out/scan.js +2 -0
- package/out/stasis.svg +20 -0
- package/out/terminal.js +366 -39
- package/out/view.css +1 -1
- package/out/view.js +19 -144
- package/package.json +362 -59
- 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/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +177 -0
- package/risk/files/runs-code.js +615 -0
- package/risk/files/script.js +118 -0
- package/risk/files/stamps.js +47 -0
- package/risk/index.d.ts +254 -0
- package/risk/index.js +11 -0
- package/risk/npm/assess.js +285 -0
- package/risk/npm/combined.js +68 -0
- package/risk/npm/compare.js +257 -0
- package/risk/npm/dependencies.js +172 -0
- package/risk/npm/diff.js +79 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +445 -0
- package/risk/npm/held.js +63 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +60 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +388 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +96 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +487 -0
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +50 -42
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +44 -13
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +19 -27
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +3 -3
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +60 -33
- package/{server → server-e2e}/static.ts +31 -21
- package/{server → server-e2e}/sync-handlers.ts +64 -30
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +258 -0
- package/server-managed/agent-merges.ts +109 -0
- package/server-managed/app-agents.ts +21 -0
- package/server-managed/avatar-store.ts +13 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +115 -0
- package/server-managed/bundle-build.ts +200 -0
- package/server-managed/bundle-cache.ts +211 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +119 -0
- package/server-managed/cache-storage.ts +20 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +47 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/commit-coauthors.ts +106 -0
- package/server-managed/commit-lockfiles.ts +73 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/config.ts +164 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1923 -0
- package/server-managed/db-neon.ts +458 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +142 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +100 -0
- package/server-managed/dependency-reviews.ts +346 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +415 -0
- package/server-managed/github-errors.ts +70 -0
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +140 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +238 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5329 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +192 -0
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +162 -0
- package/server-managed/locked-packages.ts +129 -0
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +78 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +194 -0
- package/server-managed/npm-insights.ts +449 -0
- package/server-managed/npm-kept.ts +215 -0
- package/server-managed/npm-loads.ts +264 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +454 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +285 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +75 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +434 -0
- package/server-managed/public-workspace.ts +238 -0
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +348 -0
- package/server-managed/pull-dependency-reviews.ts +328 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +39 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +279 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +85 -0
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +355 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +294 -0
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +215 -0
- package/server-managed/repository-dependency-reviews.ts +165 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-history.ts +432 -0
- package/server-managed/repository-issues.ts +195 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-pulls.ts +156 -0
- package/server-managed/repository-pulse.ts +460 -0
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +79 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +491 -0
- package/server-managed/repository-squashes.ts +168 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +150 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/session.ts +132 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +71 -0
- package/server-managed/sql-postgres.ts +34 -0
- package/server-managed/sql.ts +88 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +183 -0
- package/server-managed/storage-encryption.ts +290 -0
- package/server-managed/storage-maintenance.ts +259 -0
- package/server-managed/storage-payload.ts +121 -0
- package/server-managed/storage-stores.ts +91 -0
- package/server-managed/storage.ts +82 -0
- package/server-managed/team-app.ts +134 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +180 -0
- package/server-managed/team-npm-scopes.ts +106 -0
- package/server-managed/team-reports.ts +307 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +105 -0
- package/server-managed/uploads.ts +106 -0
- package/server-managed/upstream-cache.ts +243 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +169 -0
- package/server.ts +70 -0
- package/server/index.ts +0 -480
- package/server/lifecycle.ts +0 -208
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/auth.ts +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/objstore/sign.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/sse-session.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Reading a directory of a repository at a commit for the managed code viewer
|
|
2
|
+
// (GET /api/admin/repositories/code): its tree's tarball as GitHub archives
|
|
3
|
+
// it, verified against the tree's id and kept in upstream's disk cache as
|
|
4
|
+
// bundle builds keep theirs (getRepoTreeTarball), and its files as the npm
|
|
5
|
+
// viewer reads a package's (npm-packages.ts npmFileRows). Readers asking for
|
|
6
|
+
// the same directory meanwhile share its body, a load among the npm viewer's
|
|
7
|
+
// active ones (npm-loads.ts sharedLoad). The default branch's head's bodies
|
|
8
|
+
// are kept in byte storage, for the next to open them (repositoryCodeKept).
|
|
9
|
+
import { Buffer } from 'node:buffer'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import { encodeBrotli } from './brotli.ts'
|
|
12
|
+
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
13
|
+
import type { KeptStore } from './npm-kept.ts'
|
|
14
|
+
import { plainObject } from './npm-manifest.ts'
|
|
15
|
+
import { MAX_NPM_JSON_LENGTH, NpmPackageError, npmFileRows } from './npm-packages.ts'
|
|
16
|
+
import { type NpmLoad, loadPlaces, readTarballFiles, sharedLoad } from './npm-loads.ts'
|
|
17
|
+
|
|
18
|
+
// What a directory may hold, bounded apart from a package's (npm-packages.ts),
|
|
19
|
+
// as the viewer holds every file in memory: its files' count and bytes.
|
|
20
|
+
export const MAX_REPOSITORY_CODE_FILES = 80_000
|
|
21
|
+
export const MAX_REPOSITORY_CODE_BYTES = 256 * 1024 * 1024
|
|
22
|
+
// The tarball they come in, bounded in proportion as a package's is: its tar
|
|
23
|
+
// stream, headers and padding included, and its entries, as many again as
|
|
24
|
+
// files, as GitHub's has one for each directory too.
|
|
25
|
+
const REPOSITORY_TAR = { maxFiles: MAX_REPOSITORY_CODE_FILES, maxBytes: MAX_REPOSITORY_CODE_BYTES, maxEntries: 2 * MAX_REPOSITORY_CODE_FILES, maxTarBytes: 384 * 1024 * 1024 }
|
|
26
|
+
// What the files may take as the viewer's JSON, written as one string: short
|
|
27
|
+
// of the longest V8 holds (2^29 - 24 characters).
|
|
28
|
+
const MAX_REPOSITORY_CODE_JSON_LENGTH = 384 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
// Whether a directory's files fit, by GitHub's listing of its tree and every
|
|
31
|
+
// tree under it (repository-browser.ts repositoryTreeFiles): asked before
|
|
32
|
+
// its tarball is, which may run to hundreds of MiB. One GitHub cut short
|
|
33
|
+
// (null) has too many.
|
|
34
|
+
export function repositoryCodeFits(files: RepositoryCodeFiles | null): files is RepositoryCodeFiles {
|
|
35
|
+
return files !== null && files.files <= MAX_REPOSITORY_CODE_FILES && files.bytes <= MAX_REPOSITORY_CODE_BYTES
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A directory's files' count and bytes, as GitHub lists its tree.
|
|
39
|
+
export interface RepositoryCodeFiles { files: number; bytes: number }
|
|
40
|
+
|
|
41
|
+
// The directory read: the repository by its id and name, the commit, the
|
|
42
|
+
// directory (`''` for the root), its tree's id, and its files as listed,
|
|
43
|
+
// which weigh its load among the active ones.
|
|
44
|
+
export interface RepositoryCodeInput { repoId: number; repo: string; commit: string; path: string; tree: string; listed: RepositoryCodeFiles }
|
|
45
|
+
|
|
46
|
+
// A package's refusals, as a directory's: past the bounds, or an archive
|
|
47
|
+
// @preventive/archive refuses, as it does one holding a symlink that climbs
|
|
48
|
+
// past the repository's root, or an absolute one.
|
|
49
|
+
const DIRECTORY_ERRORS: Record<string, [number, string]> = { 'package-too-large': [413, 'directory-too-large'], 'bad-tarball': [422, 'directory-unreadable'] }
|
|
50
|
+
const directoryError = (err: unknown) => {
|
|
51
|
+
const as = err instanceof NpmPackageError ? DIRECTORY_ERRORS[err.message] : undefined
|
|
52
|
+
return as ? new NpmPackageError(...as) : err
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The directory as the viewer reads it, as brotli-encoded JSON: `{ repoId,
|
|
56
|
+
// repo, commit, path, tree, files }`, its files as npmFileRows gives them,
|
|
57
|
+
// by their paths in the directory, from `tarball`, the tree's, as upstream
|
|
58
|
+
// verifies it with its symlinks let climb `symlinksAbove` levels.
|
|
59
|
+
export function loadRepositoryCodeBody(input: RepositoryCodeInput, tarball: (symlinksAbove: number) => Promise<Uint8Array>): NpmLoad<Buffer> {
|
|
60
|
+
const { repoId, repo, commit, path, tree, listed } = input
|
|
61
|
+
// A symlink may reach the repository's root, as a monorepo package's
|
|
62
|
+
// LICENSE links the root's, and no further: the directory's depth, less
|
|
63
|
+
// the level the archive's root already stands above its top directory.
|
|
64
|
+
const symlinksAbove = path.split('/').length - 1
|
|
65
|
+
// Its places among the active loads, by its files as listed, hold their
|
|
66
|
+
// JSON too, which the listing cannot tell, as escaping may grow a text
|
|
67
|
+
// sixfold: as much as a package's may take for each.
|
|
68
|
+
const places = loadPlaces(listed)
|
|
69
|
+
const maxLength = Math.min(MAX_REPOSITORY_CODE_JSON_LENGTH, places * MAX_NPM_JSON_LENGTH)
|
|
70
|
+
return sharedLoad(`repository ${repoId} ${repo} ${commit} ${path} ${tree}`, async () => {
|
|
71
|
+
let files
|
|
72
|
+
try { files = npmFileRows(await readTarballFiles(await tarball(symlinksAbove), { ...REPOSITORY_TAR, symlinksAbove }), maxLength) }
|
|
73
|
+
catch (err) { throw directoryError(err) }
|
|
74
|
+
return encodeBrotli(Buffer.from(JSON.stringify({ repoId, repo, commit, path, tree, files })))
|
|
75
|
+
}, 'repository-busy', { places })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The form of the bodies kept, bumped as what a body holds changes
|
|
79
|
+
// (npmFileRows): one kept in another is none to read.
|
|
80
|
+
const KEPT_CODE_VERSION = 1
|
|
81
|
+
// The largest body kept. Source text runs, as brotli's JSON, to a third of
|
|
82
|
+
// its bytes or less, and a directory's binary files are named, not held, so
|
|
83
|
+
// this keeps one of some 200 MiB of text: nearly every directory that opens
|
|
84
|
+
// at all (MAX_REPOSITORY_CODE_BYTES). One past it would take as much byte
|
|
85
|
+
// storage as hundreds of ordinary directories, and hold a serverless
|
|
86
|
+
// invocation a while uploading it: it is sent as ever, and read again the
|
|
87
|
+
// next time.
|
|
88
|
+
export const MAX_KEPT_CODE_BYTES = 64 * 1024 * 1024
|
|
89
|
+
const COMMIT = /^[a-f\d]{40}$/u
|
|
90
|
+
|
|
91
|
+
// A directory as kept: the repository by its id and name, the commit and the
|
|
92
|
+
// directory, as its body names them; and the head they are kept at.
|
|
93
|
+
export type KeptCodeAt = Pick<RepositoryCodeInput, 'repoId' | 'repo' | 'commit' | 'path'>
|
|
94
|
+
type KeptCodeHead = Pick<KeptCodeAt, 'commit' | 'repo'>
|
|
95
|
+
// Under the repository, then the commit, so either goes by its prefix; the
|
|
96
|
+
// repository's name and the directory hashed, as a key's parts are names.
|
|
97
|
+
const keptKey = ({ repoId, repo, commit, path }: KeptCodeAt) =>
|
|
98
|
+
`${repoId}/${commit}/v${KEPT_CODE_VERSION}-${createHash('sha256').update(JSON.stringify([repo, path])).digest('hex')}.json.br`
|
|
99
|
+
|
|
100
|
+
// Bodies being kept, by their storage and key, each kept once; and each
|
|
101
|
+
// storage's own name among the active loads, as its reads are one of them.
|
|
102
|
+
const keeping = new WeakMap<CacheStorage, Set<string>>()
|
|
103
|
+
const storageNames = new WeakMap<CacheStorage, number>()
|
|
104
|
+
let storagesNamed = 0
|
|
105
|
+
|
|
106
|
+
// A body kept, read whole, where it is one MAX_KEPT_CODE_BYTES keeps.
|
|
107
|
+
async function readKept(storage: CacheStorage, key: string): Promise<Buffer | null> {
|
|
108
|
+
const { size, stream } = await storage.open(key)
|
|
109
|
+
try {
|
|
110
|
+
if (size !== null && size > MAX_KEPT_CODE_BYTES) return null
|
|
111
|
+
const parts: Buffer[] = []
|
|
112
|
+
let length = 0
|
|
113
|
+
for await (const part of stream) {
|
|
114
|
+
length += (part as Buffer).byteLength
|
|
115
|
+
if (length > MAX_KEPT_CODE_BYTES) return null
|
|
116
|
+
parts.push(Buffer.from(part as Buffer))
|
|
117
|
+
}
|
|
118
|
+
return Buffer.concat(parts, length)
|
|
119
|
+
} finally { stream.destroy() }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The directories of repositories' default branches' heads, as
|
|
123
|
+
// loadRepositoryCodeBody encodes them, kept in the managed byte storage
|
|
124
|
+
// (`storage`: Vercel Blob, else the filesystem, encrypted at rest where
|
|
125
|
+
// storage is: storage-payload.ts REPOSITORY_CODE_CACHE), so that one opened
|
|
126
|
+
// again, on any instance, asks GitHub for no tarball, and is neither unpacked
|
|
127
|
+
// nor encoded again; by the commit `heads` keeps for each repository, the
|
|
128
|
+
// head as last read (npm-kept.ts codeHeadStore). Most opens read the head:
|
|
129
|
+
// `/repo/<repo id>` opens it, and its route then names its commit, as do its
|
|
130
|
+
// directories opened from it and a reload. A read naming the default branch
|
|
131
|
+
// (`''`, or `heads/` and its name) takes its head as GitHub resolves it for
|
|
132
|
+
// the commit kept, the directories kept at any other removed (`own`ed,
|
|
133
|
+
// behind its answer); any other read, of a commit, a tag or another branch,
|
|
134
|
+
// reads and keeps a directory only where what it resolves to is the commit
|
|
135
|
+
// kept. Nothing kept is per reader, and each is read only once the reader's
|
|
136
|
+
// own access has named its commit. A body names what it was read of, so
|
|
137
|
+
// instances disagreeing on the head meanwhile, as at a push, only cost a
|
|
138
|
+
// directory read again: one kept for a head since moved on removes itself.
|
|
139
|
+
// The head is kept with the repository's name, which each body's key holds,
|
|
140
|
+
// so a repository renamed has what was kept under its name before removed
|
|
141
|
+
// as a read next names its head. A head is written only for a repository
|
|
142
|
+
// still `active` after it, as one removed meanwhile has its head removed
|
|
143
|
+
// after (remove). Byte storage, as the database, is an optimization: its
|
|
144
|
+
// failures read as a directory kept nowhere, and keep nothing.
|
|
145
|
+
export function repositoryCodeKept(storage: CacheStorage, heads: KeptStore<KeptCodeHead>, active: (repoId: number) => Promise<boolean>,
|
|
146
|
+
own: (work: Promise<unknown>) => void, debug = false) {
|
|
147
|
+
const warn = (what: string) => (error: unknown) => { if (debug) console.warn(`managed: kept repository code ${what} failed:`, error) }
|
|
148
|
+
const headOf = async (repoId: number): Promise<KeptCodeHead | null> => {
|
|
149
|
+
const kept: unknown = (await heads.read(String(repoId)))?.value
|
|
150
|
+
return plainObject(kept) && typeof kept['commit'] === 'string' && typeof kept['repo'] === 'string' ? { commit: kept['commit'], repo: kept['repo'] } : null
|
|
151
|
+
}
|
|
152
|
+
// What is kept of repository `repoId`: its head first, so that a body being
|
|
153
|
+
// kept meanwhile removes itself.
|
|
154
|
+
const remove = async (repoId: number) => {
|
|
155
|
+
await heads.remove(String(repoId))
|
|
156
|
+
await storage.delete(String(repoId))
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
// Whether a read of `ref` at repository `repoId`, by its name `repo`,
|
|
160
|
+
// resolved to `commit` with `defaultBranch` GitHub's, reads and keeps
|
|
161
|
+
// its directories.
|
|
162
|
+
async at({ repoId, repo, commit }: Omit<KeptCodeAt, 'path'>, ref: string, defaultBranch: string): Promise<boolean> {
|
|
163
|
+
if (!COMMIT.test(commit)) return false
|
|
164
|
+
const kept = await headOf(repoId)
|
|
165
|
+
const atKept = kept?.commit === commit && kept.repo === repo
|
|
166
|
+
if (ref !== '' && (defaultBranch === '' || ref !== `heads/${defaultBranch}`)) return atKept
|
|
167
|
+
if (!atKept) {
|
|
168
|
+
await heads.write(String(repoId), { commit, repo }, Date.now())
|
|
169
|
+
// One removed as its commit was read, its head removed before this
|
|
170
|
+
// wrote it again, keeps nothing, and has what is kept removed again.
|
|
171
|
+
if (!await active(repoId).catch(() => false)) {
|
|
172
|
+
await remove(repoId).catch(warn('removal'))
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
// Those kept at the head before, or, renamed, every one kept under
|
|
176
|
+
// its name before.
|
|
177
|
+
own(storage.delete(String(repoId), kept?.repo === repo ? commit : undefined).catch(warn('removal')))
|
|
178
|
+
}
|
|
179
|
+
return true
|
|
180
|
+
},
|
|
181
|
+
// The body kept of the directory `at` names, or null: a load among the
|
|
182
|
+
// active ones (npm-loads.ts sharedLoad), as one read from GitHub is, a
|
|
183
|
+
// place of them, as it holds no more than a package may
|
|
184
|
+
// (MAX_KEPT_CODE_BYTES), till each reader has released it; readers
|
|
185
|
+
// asking for it meanwhile share it, and none is read where every place
|
|
186
|
+
// is taken (`repository-busy`).
|
|
187
|
+
read(at: KeptCodeAt): NpmLoad<Buffer | null> {
|
|
188
|
+
const key = keptKey(at)
|
|
189
|
+
const name = storageNames.get(storage) ?? storageNames.set(storage, ++storagesNamed).get(storage)!
|
|
190
|
+
return sharedLoad(`repository kept ${name} ${key}`, () => readKept(storage, key).catch((error: unknown) => {
|
|
191
|
+
if (!(error instanceof CacheMissError)) warn('read')(error)
|
|
192
|
+
return null
|
|
193
|
+
}), 'repository-busy')
|
|
194
|
+
},
|
|
195
|
+
// The directory's body kept, behind its answer, where it is no larger
|
|
196
|
+
// than MAX_KEPT_CODE_BYTES; and removed again, with the rest of its
|
|
197
|
+
// commit's, where the head kept is another by then, as when it moved on
|
|
198
|
+
// or the repository was renamed or removed meanwhile.
|
|
199
|
+
keep(at: KeptCodeAt, body: Buffer): void {
|
|
200
|
+
const key = keptKey(at)
|
|
201
|
+
const kept = keeping.get(storage) ?? keeping.set(storage, new Set()).get(storage)!
|
|
202
|
+
if (body.byteLength > MAX_KEPT_CODE_BYTES || kept.has(key)) return
|
|
203
|
+
kept.add(key)
|
|
204
|
+
own((async () => {
|
|
205
|
+
await storage.put(key, body)
|
|
206
|
+
const head = await headOf(at.repoId)
|
|
207
|
+
if (head?.commit !== at.commit || head.repo !== at.repo) await storage.delete(`${at.repoId}/${at.commit}`)
|
|
208
|
+
})().catch(warn('write')).finally(() => kept.delete(key)))
|
|
209
|
+
},
|
|
210
|
+
// What is kept of repository `repoId`, removed once the repository is
|
|
211
|
+
// no longer active.
|
|
212
|
+
remove,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export type RepositoryCodeKept = ReturnType<typeof repositoryCodeKept>
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// A connected repository's dependency review, as an admin sets it, and the
|
|
2
|
+
// reviews that pass its pull requests' dependencies (pull-dependency-reviews.ts).
|
|
3
|
+
// Enabled, each package version its open pull requests' lockfiles lock needs
|
|
4
|
+
// as many published reviews as `required`; those its lockfiles locked at
|
|
5
|
+
// `since`, a commit (null for none), none; of the lockfiles it holds to
|
|
6
|
+
// (`lockfiles`, lockfile-scope.ts), every one by default. It is in effect only where a team
|
|
7
|
+
// that reviews dependencies has the repository, which is where reviews that
|
|
8
|
+
// count are published, and not revoked: each reviewer's latest of a version
|
|
9
|
+
// tells where they stand on it, a reject holding it unreviewed whatever
|
|
10
|
+
// others passed it till they accept it again; and a reviewer standing on
|
|
11
|
+
// accepting it passes it by a review of theirs that is full (every file
|
|
12
|
+
// checked; a partial review counts for nothing yet) and accepting (Accept,
|
|
13
|
+
// or Accept with nits).
|
|
14
|
+
import type { ManagedSql } from './sql.ts'
|
|
15
|
+
import { type LockfileScope, scopeText, storedScope } from './lockfile-scope.ts'
|
|
16
|
+
import { reviewsIn } from './dependency-reviews.ts'
|
|
17
|
+
import { type ReviewVerdict, summarizeReview } from '../common/dependency-reviews.js'
|
|
18
|
+
|
|
19
|
+
export const REPOSITORY_DEPENDENCY_REVIEW_COLUMNS = [['dependency_review', 'INTEGER NOT NULL DEFAULT 0'],
|
|
20
|
+
['dependency_reviews_required', 'INTEGER NOT NULL DEFAULT 1'], ['dependency_review_since', 'TEXT'], ['dependency_review_lockfiles', 'TEXT']] as const
|
|
21
|
+
export const MAX_REQUIRED_REVIEWS = 10
|
|
22
|
+
export const SINCE_COMMIT = /^[a-f\d]{40}$/u
|
|
23
|
+
|
|
24
|
+
export interface RepoDependencyReview { enabled: boolean; required: number; since: string | null; lockfiles: LockfileScope }
|
|
25
|
+
// A review published of the version it is of: its package, version and
|
|
26
|
+
// npm's integrity of it, the patch it is of (none here, the app reviewing no
|
|
27
|
+
// patch yet), the version it was compared with (none for a whole package's),
|
|
28
|
+
// who wrote it (`reviewer`, their id, else their login then), their login
|
|
29
|
+
// as it was then (`login`), its verdict, whether it checked every file
|
|
30
|
+
// (`full`), and when it was published.
|
|
31
|
+
// One made in a pull request's comments (`comment`) counts for it alone;
|
|
32
|
+
// one published here has its `id`.
|
|
33
|
+
export interface PublishedReview {
|
|
34
|
+
id?: string; package: string; version: string; integrity: string; patchHash?: string; basePackage: string | null; baseVersion: string | null
|
|
35
|
+
reviewer: string; login: string; status: ReviewVerdict; full: boolean; time: number; comment?: true
|
|
36
|
+
}
|
|
37
|
+
// A repository's review in effect for a reader: its settings, whether they
|
|
38
|
+
// publish reviews to one of the teams that count (`canReview`), and the
|
|
39
|
+
// published reviews of the packages asked.
|
|
40
|
+
export interface RepoReviewState extends Omit<RepoDependencyReview, 'enabled'> { canReview: boolean; reviews: PublishedReview[] }
|
|
41
|
+
|
|
42
|
+
// A package version whose published reviews are asked.
|
|
43
|
+
export interface ReviewedVersionKey { name: string; version: string }
|
|
44
|
+
|
|
45
|
+
export interface RepositoryDependencyReviewStore {
|
|
46
|
+
listRepoDependencyReviews(): Promise<Map<number, RepoDependencyReview>>
|
|
47
|
+
// One repository's, none where it isn't connected.
|
|
48
|
+
repoDependencyReview(repoId: number): Promise<RepoDependencyReview | null>
|
|
49
|
+
// Those of `repoIds` connected, by their ids, in one statement.
|
|
50
|
+
getRepoDependencyReviews(repoIds: number[]): Promise<Map<number, RepoDependencyReview>>
|
|
51
|
+
// The teams whose published reviews count for each repository (COUNTING_TEAM),
|
|
52
|
+
// by name.
|
|
53
|
+
listRepoReviewTeams(): Promise<Map<number, { id: string; name: string }[]>>
|
|
54
|
+
// Sets a repository's review, its commit and lockfiles too but where
|
|
55
|
+
// `since` or `lockfiles` is undefined, which keeps it; the setting then, or
|
|
56
|
+
// null for no repository.
|
|
57
|
+
setRepoDependencyReview(repoId: number, setting: Omit<RepoDependencyReview, 'since' | 'lockfiles'> & { since?: string | null; lockfiles?: LockfileScope }): Promise<RepoDependencyReview | null>
|
|
58
|
+
// Its review in effect for `user`, as `setting` tells it where read
|
|
59
|
+
// already, with the published reviews of `versions`.
|
|
60
|
+
repoDependencyReviewState(repoId: number, user: string, versions: ReviewedVersionKey[], setting?: RepoDependencyReview | null): Promise<RepoReviewState | null>
|
|
61
|
+
// Which of `users` publish reviews to a team counting for repository
|
|
62
|
+
// `repoId`, whose reviews its pull requests' comments make count.
|
|
63
|
+
listRepoReviewers(repoId: number, users: string[]): Promise<Set<string>>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type SettingRow = { repoId: number; enabled: number; required: number; since: string | null; lockfiles: string | null }
|
|
67
|
+
const setting = (row: SettingRow): RepoDependencyReview => ({ enabled: row.enabled === 1, required: row.required, since: row.since, lockfiles: storedScope(row.lockfiles) })
|
|
68
|
+
// Teams whose published reviews count for repository `?`: that review
|
|
69
|
+
// dependencies and have it, not hidden.
|
|
70
|
+
const COUNTING_TEAM = `t.hidden = 0 AND t.dependency_review = 1 AND EXISTS (
|
|
71
|
+
SELECT 1 FROM managed_team_repo tr WHERE tr.team_id = t.id AND tr.repo_id = ?)`
|
|
72
|
+
const VERSIONS_AT_ONCE = 250
|
|
73
|
+
|
|
74
|
+
// The published and unrevoked reviews of `versions` in teams counting for
|
|
75
|
+
// repository `repoId`, each with whether it is full, a couple of hundred
|
|
76
|
+
// versions a query: those of their packages' and versions' found, the
|
|
77
|
+
// versions asked alone kept, their files' reviews read of those alone.
|
|
78
|
+
async function publishedReviews(db: ManagedSql, repoId: number, versions: ReviewedVersionKey[]): Promise<PublishedReview[]> {
|
|
79
|
+
const found: PublishedReview[] = []
|
|
80
|
+
for (let at = 0; at < versions.length; at += VERSIONS_AT_ONCE) {
|
|
81
|
+
const asked = versions.slice(at, at + VERSIONS_AT_ONCE)
|
|
82
|
+
const keys = new Set(asked.map(({ name, version }) => `${name}@${version}`))
|
|
83
|
+
const [names, numbers] = [[...new Set(asked.map(({ name }) => name))], [...new Set(asked.map(({ version }) => version))]]
|
|
84
|
+
const rows = (await db.prepare(`SELECT r.id, r.user_id AS userId, r.user_login AS userLogin, r.package, r.version, r.integrity,
|
|
85
|
+
NULLIF(r.base_package, '') AS basePackage, NULLIF(r.base_version, '') AS baseVersion, r.files, r.status, r.published_at AS publishedAt
|
|
86
|
+
FROM managed_dependency_review r
|
|
87
|
+
WHERE r.ecosystem = 'npm' AND r.published_at IS NOT NULL AND r.revoked_at IS NULL AND r.status IS NOT NULL
|
|
88
|
+
AND r.package IN (${names.map(() => '?').join(',')}) AND r.version IN (${numbers.map(() => '?').join(',')})
|
|
89
|
+
AND EXISTS (SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id
|
|
90
|
+
WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, ...numbers, repoId) as
|
|
91
|
+
{ id: string; userId: string | null; userLogin: string; package: string; version: string; integrity: string
|
|
92
|
+
basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[])
|
|
93
|
+
.filter(row => keys.has(`${row.package}@${row.version}`))
|
|
94
|
+
if (rows.length === 0) continue
|
|
95
|
+
const entries = new Map<string, { file: string; status: ReviewVerdict | null }[]>()
|
|
96
|
+
for (const { reviewId, ...entry } of await db.prepare(`SELECT review_id AS reviewId, file, status FROM managed_dependency_file_review
|
|
97
|
+
WHERE review_id IN (${rows.map(() => '?').join(',')})`).all(...rows.map(row => row.id)) as { reviewId: string; file: string; status: ReviewVerdict | null }[]) {
|
|
98
|
+
const of = entries.get(reviewId)
|
|
99
|
+
if (of) of.push(entry)
|
|
100
|
+
else entries.set(reviewId, [entry])
|
|
101
|
+
}
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
const snapshot = row.files === null ? null : JSON.parse(row.files) as string[] | { paths: string[]; nonTextFiles: string[] }
|
|
104
|
+
// A snapshot of paths alone, before non-text files were told, can't be full.
|
|
105
|
+
const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
|
|
106
|
+
const nonText = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
|
|
107
|
+
const full = summarizeReview(entries.get(row.id) ?? [], files, nonText).coverage === 'full'
|
|
108
|
+
found.push({ id: row.id, package: row.package, version: row.version, integrity: row.integrity, basePackage: row.basePackage, baseVersion: row.baseVersion,
|
|
109
|
+
reviewer: row.userId ?? `login:${row.userLogin.toLowerCase()}`, login: row.userLogin, status: row.status, full, time: Number(row.publishedAt) })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return found
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDependencyReviewStore {
|
|
116
|
+
const columns = `repo_id AS repoId, dependency_review AS enabled, dependency_reviews_required AS required, dependency_review_since AS since,
|
|
117
|
+
dependency_review_lockfiles AS lockfiles`
|
|
118
|
+
const readSetting = async (repoId: number) => {
|
|
119
|
+
const row = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow | undefined
|
|
120
|
+
return row ? setting(row) : null
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
async listRepoDependencyReviews() {
|
|
124
|
+
const rows = await db.prepare(`SELECT ${columns} FROM managed_selected_repo`).all() as SettingRow[]
|
|
125
|
+
return new Map(rows.map(row => [row.repoId, setting(row)]))
|
|
126
|
+
},
|
|
127
|
+
repoDependencyReview: readSetting,
|
|
128
|
+
async getRepoDependencyReviews(repoIds) {
|
|
129
|
+
if (repoIds.length === 0) return new Map()
|
|
130
|
+
const rows = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id IN (${repoIds.map(() => '?').join(',')})`).all(...repoIds) as SettingRow[]
|
|
131
|
+
return new Map(rows.map(row => [row.repoId, setting(row)]))
|
|
132
|
+
},
|
|
133
|
+
async listRepoReviewTeams() {
|
|
134
|
+
const rows = await db.prepare(`SELECT DISTINCT tr.repo_id AS repoId, t.id AS id, t.name AS name FROM managed_team_repo tr
|
|
135
|
+
JOIN managed_team t ON t.id = tr.team_id WHERE t.hidden = 0 AND t.dependency_review = 1 ORDER BY t.name, t.id`).all() as { repoId: number; id: string; name: string }[]
|
|
136
|
+
const teams = new Map<number, { id: string; name: string }[]>()
|
|
137
|
+
for (const { repoId, id, name } of rows) teams.set(repoId, [...teams.get(repoId) ?? [], { id, name }])
|
|
138
|
+
return teams
|
|
139
|
+
},
|
|
140
|
+
async setRepoDependencyReview(repoId, { enabled, required, since, lockfiles }) {
|
|
141
|
+
const set: [string, unknown][] = [['dependency_review', enabled ? 1 : 0], ['dependency_reviews_required', required],
|
|
142
|
+
...since === undefined ? [] : [['dependency_review_since', since] as [string, unknown]],
|
|
143
|
+
...lockfiles === undefined ? [] : [['dependency_review_lockfiles', scopeText(lockfiles)] as [string, unknown]]]
|
|
144
|
+
const { changes } = await db.prepare(`UPDATE managed_selected_repo SET ${set.map(([column]) => `${column} = ?`).join(', ')} WHERE repo_id = ?`)
|
|
145
|
+
.run(...set.map(([, value]) => value), repoId)
|
|
146
|
+
if (Number(changes) === 0) return null
|
|
147
|
+
return setting(await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow)
|
|
148
|
+
},
|
|
149
|
+
async listRepoReviewers(repoId, users) {
|
|
150
|
+
if (users.length === 0) return new Set()
|
|
151
|
+
const rows = await db.prepare(`SELECT DISTINCT u.id FROM managed_user u JOIN managed_team t ON t.hidden = 0
|
|
152
|
+
WHERE u.id IN (${users.map(() => '?').join(',')}) AND ${reviewsIn(true)} AND ${COUNTING_TEAM}`).all(...users, repoId) as { id: string }[]
|
|
153
|
+
return new Set(rows.map(row => row.id))
|
|
154
|
+
},
|
|
155
|
+
async repoDependencyReviewState(repoId, user, versions, read) {
|
|
156
|
+
const now = read === undefined ? await readSetting(repoId) : read
|
|
157
|
+
if (!now?.enabled) return null
|
|
158
|
+
if (!await db.prepare(`SELECT 1 FROM managed_team t WHERE ${COUNTING_TEAM}`).get(repoId)) return null
|
|
159
|
+
const canReview = await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ?
|
|
160
|
+
WHERE ${reviewsIn(true)} AND ${COUNTING_TEAM}`).get(user, repoId) != null
|
|
161
|
+
const asked = [...new Map(versions.map(version => [`${version.name}@${version.version}`, version])).values()]
|
|
162
|
+
return { required: now.required, since: now.since, lockfiles: now.lockfiles, canReview, reviews: await publishedReviews(db, repoId, asked) }
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { ManagedConfig } from './config.ts'
|
|
3
|
+
import { GithubApiError, filterInstalledRepos, listInstalledRepos, listUserRepos } from './github-app.ts'
|
|
4
|
+
import type { ConnectedRepo, RepoListing } from './github-app.ts'
|
|
5
|
+
|
|
6
|
+
const CACHE_MS = 60_000
|
|
7
|
+
const MAX_ENTRIES = 100
|
|
8
|
+
interface Entry {
|
|
9
|
+
expires: number
|
|
10
|
+
pending: boolean
|
|
11
|
+
value: Promise<ConnectedRepo[]>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// One directory per HTTP handler. Searches/pages reuse discovery, but selected
|
|
15
|
+
// flags still come from the database on every request. User results are scoped
|
|
16
|
+
// to both the managed identity and its current GitHub credential. This cache is
|
|
17
|
+
// only for discovery; connecting a repo still verifies fresh installation access.
|
|
18
|
+
export class RepositoryDiscovery {
|
|
19
|
+
private readonly cache = new Map<string, Entry>()
|
|
20
|
+
private readonly config: ManagedConfig
|
|
21
|
+
private readonly fetchImpl: typeof fetch
|
|
22
|
+
private readonly now: () => number
|
|
23
|
+
|
|
24
|
+
constructor(config: ManagedConfig, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args), now: () => number = Date.now) {
|
|
25
|
+
this.config = config
|
|
26
|
+
this.fetchImpl = fetchImpl
|
|
27
|
+
this.now = now
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private read(key: string, load: () => Promise<ConnectedRepo[]>): Promise<ConnectedRepo[]> {
|
|
31
|
+
const cached = this.cache.get(key)
|
|
32
|
+
if (cached && (cached.pending || cached.expires > this.now())) return cached.value
|
|
33
|
+
this.cache.delete(key)
|
|
34
|
+
while (this.cache.size >= MAX_ENTRIES) this.cache.delete(this.cache.keys().next().value!)
|
|
35
|
+
const entry: Entry = { expires: 0, pending: true, value: Promise.resolve([]) }
|
|
36
|
+
entry.value = load().then(repos => {
|
|
37
|
+
entry.expires = this.now() + CACHE_MS
|
|
38
|
+
entry.pending = false
|
|
39
|
+
return repos
|
|
40
|
+
}, (err: unknown) => {
|
|
41
|
+
if (this.cache.get(key) === entry) this.cache.delete(key)
|
|
42
|
+
throw err
|
|
43
|
+
})
|
|
44
|
+
this.cache.set(key, entry)
|
|
45
|
+
return entry.value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async list(scope: 'installed' | 'public', userId: string, token: string | null, showAll = false, refresh = false): Promise<RepoListing> {
|
|
49
|
+
// Invalidate derived user results along with the installation catalogue.
|
|
50
|
+
// An older in-flight read cannot repopulate this map after refresh.
|
|
51
|
+
if (refresh) this.cache.clear()
|
|
52
|
+
const installed = () => this.read('installed', () => listInstalledRepos(this.config, this.fetchImpl))
|
|
53
|
+
if (scope === 'installed' && showAll) return { repositories: await installed(), tokenMissing: false }
|
|
54
|
+
const publicInstalled = async (): Promise<ConnectedRepo[]> => (await installed()).filter(repo => repo.visibility === 'public')
|
|
55
|
+
if (token == null) return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
|
|
56
|
+
const credential = createHash('sha256').update(token).digest('hex')
|
|
57
|
+
const key = JSON.stringify([scope, userId, credential])
|
|
58
|
+
try {
|
|
59
|
+
if (scope === 'installed') {
|
|
60
|
+
const repositories = await this.read(key, async () => filterInstalledRepos(this.config, await installed(), token, this.fetchImpl))
|
|
61
|
+
return { repositories, tokenMissing: false }
|
|
62
|
+
}
|
|
63
|
+
// Installations only deduplicate the public picker. Their failure must
|
|
64
|
+
// not block otherwise readable public repos or look like a stale login.
|
|
65
|
+
// Cache the two sources separately so failed installation discovery is
|
|
66
|
+
// retried without caching a degraded combined list for another minute.
|
|
67
|
+
const [userRepos, installedRepos] = await Promise.all([
|
|
68
|
+
this.read(key, () => listUserRepos(token)),
|
|
69
|
+
installed().catch((err: unknown) => {
|
|
70
|
+
console.warn('managed: installed repo deduplication failed:', err)
|
|
71
|
+
return []
|
|
72
|
+
}),
|
|
73
|
+
])
|
|
74
|
+
const installedIds = new Set(installedRepos.map(repo => repo.id))
|
|
75
|
+
const repositories = userRepos.filter(repo => repo.visibility === 'public' && !installedIds.has(repo.id))
|
|
76
|
+
return { repositories, tokenMissing: false }
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (!(err instanceof GithubApiError) || err.status !== 401) throw err
|
|
79
|
+
return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|