@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,100 @@
|
|
|
1
|
+
// The comment a host formats of one's own dependency reviews, for a copy
|
|
2
|
+
// button to give (reviewed-dependencies.ts `formatDependencyReviews`): of each
|
|
3
|
+
// of one's reviews, as their lists and the review page show them
|
|
4
|
+
// (`withFormatted`), and of those of the versions an open pull request adds
|
|
5
|
+
// (`formattedPull`). It is given one's own published reviews alone, never
|
|
6
|
+
// another's, a draft or a revoked one, however it is asked (`formatOwn`, the
|
|
7
|
+
// only caller of the hook); and is told of none where it answers nothing, or
|
|
8
|
+
// fails, which is logged.
|
|
9
|
+
import type { DependencyReview, DependencyReviewStore } from './dependency-reviews.ts'
|
|
10
|
+
import { type AddedPackage, type PullDependencies, eachOf } from './pull-dependencies.ts'
|
|
11
|
+
import type { PublishedReview, RepoReviewState } from './repository-dependency-reviews.ts'
|
|
12
|
+
import type { DependencyReviewEntry, ReviewedDependencies } from './reviewed-dependencies.ts'
|
|
13
|
+
|
|
14
|
+
const FORMAT_AT_ONCE = 8
|
|
15
|
+
// The longest comment told, past which none is.
|
|
16
|
+
const MAX_FORMATTED = 1_000_000
|
|
17
|
+
// The reviews of a pull request's versions read at once, those past them
|
|
18
|
+
// read after, every one formatted.
|
|
19
|
+
const REVIEWS_AT_ONCE = 200
|
|
20
|
+
|
|
21
|
+
// `review` as the hook is given it: each version with the integrity and
|
|
22
|
+
// shasum the review keeps of the tarball it read, and the version it is of
|
|
23
|
+
// with the patch `locked`, a pull request's lockfile, locks it with, where it
|
|
24
|
+
// is one's.
|
|
25
|
+
function entryOf(review: DependencyReview, locked?: AddedPackage | undefined): DependencyReviewEntry {
|
|
26
|
+
return { ecosystem: 'npm', old: review.basePackage === null ? null : { name: review.basePackage, version: review.baseVersion ?? '', integrity: review.baseIntegrity ?? '',
|
|
27
|
+
...review.baseShasum ? { shasum: review.baseShasum } : {} },
|
|
28
|
+
new: { name: review.package, version: review.version, integrity: review.integrity, ...review.shasum ? { shasum: review.shasum } : {},
|
|
29
|
+
...locked?.patchHash ? { patchHash: locked.patchHash } : {} },
|
|
30
|
+
status: review.status!, coverage: review.coverage === 'full' ? 'full' : 'partial', comment: review.comment,
|
|
31
|
+
files: review.entries.filter(entry => entry.status !== null || entry.comment.trim() !== '').map(entry => ({ path: entry.file, status: entry.status, comment: entry.comment })),
|
|
32
|
+
reviewer: review.userLogin, publishedAt: review.publishedAt! }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Whether `review` is `user`'s own, published, not revoked, with its verdict.
|
|
36
|
+
const ownPublished = (review: DependencyReview, user: string) => review.user === user && review.publishedAt !== null && review.revokedAt === null && review.status !== null
|
|
37
|
+
|
|
38
|
+
// What the host formats of `reviews`, of them `user`'s own published ones
|
|
39
|
+
// alone, and the pull request they are of the versions of, where it is
|
|
40
|
+
// one's; none where it formats none of them.
|
|
41
|
+
async function formatOwn(reviewed: ReviewedDependencies | null, user: string, reviews: { review: DependencyReview; locked?: AddedPackage | undefined }[],
|
|
42
|
+
pullRequest?: { repository: string; number: number }): Promise<string | null> {
|
|
43
|
+
const format = reviewed?.formatDependencyReviews
|
|
44
|
+
const own = reviews.filter(({ review }) => ownPublished(review, user))
|
|
45
|
+
if (!format || own.length === 0) return null
|
|
46
|
+
try {
|
|
47
|
+
const text = await format({ reviews: own.map(({ review, locked }) => entryOf(review, locked)), ...pullRequest ? { pullRequest } : {} })
|
|
48
|
+
return typeof text === 'string' && text.trim() !== '' && text.length <= MAX_FORMATTED ? text : null
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.warn('reviewed dependencies: failed to format dependency reviews', error instanceof Error ? error.message : error)
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// `reviews`, each of `user`'s own with the comment the host formats of it
|
|
56
|
+
// (`formatted`), where it formats one; another's never asked of.
|
|
57
|
+
export function withFormatted<T extends DependencyReview>(reviewed: ReviewedDependencies | null, user: string, reviews: T[]): Promise<(T & { formatted?: string })[]> {
|
|
58
|
+
if (!reviewed?.formatDependencyReviews) return Promise.resolve(reviews)
|
|
59
|
+
return eachOf(reviews, FORMAT_AT_ONCE, async review => {
|
|
60
|
+
const formatted = ownPublished(review, user) ? await formatOwn(reviewed, user, [{ review }]) : null
|
|
61
|
+
return formatted ? { ...review, formatted } : review
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Those of `reviews` `user` published of the versions `told` adds, by their
|
|
66
|
+
// ids, with each version as its lockfile locks it: of its version, of an
|
|
67
|
+
// integrity it is locked with and of its patch, whole or compared with a
|
|
68
|
+
// version it was.
|
|
69
|
+
function ownOfPull(told: PullDependencies, reviews: PublishedReview[], user: string): Map<string, AddedPackage> {
|
|
70
|
+
// Theirs by the version they are of, each version probed once.
|
|
71
|
+
const theirs = new Map<string, PublishedReview[]>()
|
|
72
|
+
for (const review of reviews) {
|
|
73
|
+
if (review.id === undefined || review.reviewer !== user) continue
|
|
74
|
+
const key = `${review.package}@${review.version}`
|
|
75
|
+
theirs.set(key, [...theirs.get(key) ?? [], review])
|
|
76
|
+
}
|
|
77
|
+
const mine = new Map<string, AddedPackage>()
|
|
78
|
+
if (theirs.size === 0) return mine
|
|
79
|
+
for (const pkg of told.lockfiles.flatMap(dir => 'added' in dir ? dir.added : [])) {
|
|
80
|
+
const hashes = new Set((pkg.integrity ?? '').split(/\s+/u).filter(Boolean))
|
|
81
|
+
for (const review of theirs.get(`${pkg.name}@${pkg.version}`) ?? []) {
|
|
82
|
+
if (!hashes.has(review.integrity) || (review.patchHash ?? '') !== (pkg.patchHash ?? '')) continue
|
|
83
|
+
if (review.basePackage === null || (review.basePackage === pkg.name && (pkg.was ?? []).includes(review.baseVersion ?? ''))) mine.set(review.id!, pkg)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return mine
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The comment the host formats of `user`'s reviews of the versions pull
|
|
90
|
+
// request `number` of `repository` adds (`told`), those its review's state
|
|
91
|
+
// tells of, published to a team counting for it; none where it has none.
|
|
92
|
+
export async function formattedPull(reviewed: ReviewedDependencies | null, db: Pick<DependencyReviewStore, 'listOwnDependencyReviewsById'>, user: string,
|
|
93
|
+
told: PullDependencies, state: RepoReviewState | null, pullRequest: { repository: string; number: number }): Promise<string | null> {
|
|
94
|
+
if (!reviewed?.formatDependencyReviews || !state) return null
|
|
95
|
+
const mine = ownOfPull(told, state.reviews, user)
|
|
96
|
+
if (mine.size === 0) return null
|
|
97
|
+
const ids = [...mine.keys()], reviews: DependencyReview[] = []
|
|
98
|
+
for (let at = 0; at < ids.length; at += REVIEWS_AT_ONCE) reviews.push(...await db.listOwnDependencyReviewsById(user, ids.slice(at, at + REVIEWS_AT_ONCE)))
|
|
99
|
+
return formatOwn(reviewed, user, reviews.map(review => ({ review, locked: mine.get(review.id) })), pullRequest)
|
|
100
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { ManagedMutationError } from './management.ts'
|
|
3
|
+
import type { ManagedSql } from './sql.ts'
|
|
4
|
+
import { REVIEW_VERDICTS, type ReviewSummary, type ReviewVerdict, allowedReviewVerdicts, needsPackageJsonReview, summarizeReview } from '../common/dependency-reviews.js'
|
|
5
|
+
|
|
6
|
+
export interface ReviewContext {
|
|
7
|
+
ecosystem: 'npm'; package: string; version: string; integrity: string
|
|
8
|
+
basePackage: string | null; baseVersion: string | null; baseIntegrity: string | null
|
|
9
|
+
}
|
|
10
|
+
// The shasums (sha1s, in hex) of the tarballs a review reads, its version's
|
|
11
|
+
// and its base's, where npm tells them: each held, with its integrity, to the
|
|
12
|
+
// tarball its reviewer was shown, as a review is written (http.ts).
|
|
13
|
+
export interface ReviewedTarballs { shasum: string | null; baseShasum: string | null }
|
|
14
|
+
export interface ReviewInput { file: string; comment: string; status: ReviewVerdict | null }
|
|
15
|
+
export interface ReviewEntry extends ReviewInput { id: string; time: number }
|
|
16
|
+
export interface ReviewTeam { id: string; name: string }
|
|
17
|
+
export interface DependencyReview extends ReviewContext, ReviewedTarballs, ReviewSummary {
|
|
18
|
+
id: string; user: string | null; userLogin: string; userName: string | null; time: number; revision: number
|
|
19
|
+
publishedAt: number | null; revokedAt: number | null; entries: ReviewEntry[]; teams: ReviewTeam[]; files: string[] | null
|
|
20
|
+
nonTextFiles: string[] | null
|
|
21
|
+
status: ReviewVerdict | null; comment: string
|
|
22
|
+
}
|
|
23
|
+
export type ReviewChange = { action: 'save'; reviewId: string | null; revision: number | null; entryId: string | null; entry: ReviewInput }
|
|
24
|
+
| { action: 'start'; reviewId: null; revision: null }
|
|
25
|
+
| { action: 'publish'; reviewId: string; revision: number; teamIds: string[]; files: string[]; nonTextFiles: string[]; final: { status: ReviewVerdict; comment: string } | null }
|
|
26
|
+
| { action: 'continue'; reviewId: string; revision: number }
|
|
27
|
+
| { action: 'revoke'; reviewId: string; revision: number }
|
|
28
|
+
export interface DependencyReviewStore {
|
|
29
|
+
listDependencyReviews(context: ReviewContext, user: string, allBases?: boolean): Promise<DependencyReview[]>
|
|
30
|
+
getLatestOwnDependencyReview(user: string, target: Pick<ReviewContext, 'ecosystem' | 'package' | 'version' | 'integrity'>): Promise<DependencyReview | null>
|
|
31
|
+
listOwnDependencyReviews(user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null }>
|
|
32
|
+
// `user`'s own reviews of `ids`, none another's.
|
|
33
|
+
listOwnDependencyReviewsById(user: string, ids: string[]): Promise<DependencyReview[]>
|
|
34
|
+
// The reviews published to team `teamId`, revoked ones too, the latest
|
|
35
|
+
// published first, a page on from `before` (when it was published, and
|
|
36
|
+
// its id), for one who reads them there (reviewsIn); null for another.
|
|
37
|
+
// In one statement.
|
|
38
|
+
listTeamDependencyReviews(teamId: string, user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null } | null>
|
|
39
|
+
listDependencyReviewTeams(user: string): Promise<ReviewTeam[]>
|
|
40
|
+
listDependencyReviewEnabledTeams(): Promise<string[]>
|
|
41
|
+
setTeamDependencyReview(sessionId: string, teamId: string, enabled: boolean): Promise<boolean>
|
|
42
|
+
discardDependencyReview(sessionId: string, reviewId: string, revision: number): Promise<void>
|
|
43
|
+
// Written with the shasums of the tarballs read (`tarballs`), which a
|
|
44
|
+
// draft keeps as it is begun, saved and published; none where not told.
|
|
45
|
+
changeDependencyReview(context: ReviewContext, sessionId: string, change: ReviewChange, now: number, tarballs?: ReviewedTarballs): Promise<DependencyReview>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// An unpublished review belongs only to its author. Publication freezes all
|
|
49
|
+
// entries together and attaches that snapshot to its selected teams. Continuing
|
|
50
|
+
// makes a new review; published entries and their timestamps never change.
|
|
51
|
+
export const DEPENDENCY_REVIEW_SCHEMA = `
|
|
52
|
+
CREATE TABLE IF NOT EXISTS managed_dependency_review (
|
|
53
|
+
id TEXT PRIMARY KEY,
|
|
54
|
+
user_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
|
|
55
|
+
user_login TEXT NOT NULL, ecosystem TEXT NOT NULL,
|
|
56
|
+
package TEXT NOT NULL, version TEXT NOT NULL, integrity TEXT NOT NULL,
|
|
57
|
+
base_package TEXT NOT NULL, base_version TEXT NOT NULL, base_integrity TEXT NOT NULL,
|
|
58
|
+
time INTEGER NOT NULL, revision INTEGER NOT NULL, published_at INTEGER, revoked_at INTEGER, files TEXT,
|
|
59
|
+
status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), comment TEXT NOT NULL DEFAULT '',
|
|
60
|
+
shasum TEXT NOT NULL DEFAULT '', base_shasum TEXT NOT NULL DEFAULT ''
|
|
61
|
+
) STRICT;
|
|
62
|
+
CREATE INDEX IF NOT EXISTS managed_dependency_review_context_idx
|
|
63
|
+
ON managed_dependency_review(ecosystem, package, version, integrity, base_package, base_version, base_integrity, time);
|
|
64
|
+
CREATE UNIQUE INDEX IF NOT EXISTS managed_dependency_review_unpublished_idx
|
|
65
|
+
ON managed_dependency_review(user_id, ecosystem, package, version, integrity, base_package, base_version, base_integrity)
|
|
66
|
+
WHERE published_at IS NULL;
|
|
67
|
+
CREATE INDEX IF NOT EXISTS managed_dependency_review_author_idx ON managed_dependency_review(user_id, time, id);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS managed_dependency_file_review (
|
|
69
|
+
id TEXT PRIMARY KEY, review_id TEXT NOT NULL REFERENCES managed_dependency_review(id) ON DELETE CASCADE,
|
|
70
|
+
file TEXT NOT NULL, comment TEXT NOT NULL,
|
|
71
|
+
status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), time INTEGER NOT NULL
|
|
72
|
+
) STRICT;
|
|
73
|
+
CREATE INDEX IF NOT EXISTS managed_dependency_file_review_idx ON managed_dependency_file_review(review_id);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS managed_dependency_review_team (
|
|
75
|
+
review_id TEXT NOT NULL REFERENCES managed_dependency_review(id) ON DELETE CASCADE,
|
|
76
|
+
team_id TEXT NOT NULL REFERENCES managed_team(id) ON DELETE CASCADE,
|
|
77
|
+
PRIMARY KEY (review_id, team_id)
|
|
78
|
+
) STRICT;
|
|
79
|
+
CREATE INDEX IF NOT EXISTS managed_dependency_review_team_idx ON managed_dependency_review_team(team_id, review_id);
|
|
80
|
+
`
|
|
81
|
+
// The columns added since the table was, for a database that has it already.
|
|
82
|
+
export const DEPENDENCY_REVIEW_COLUMNS = [['shasum', "TEXT NOT NULL DEFAULT ''"], ['base_shasum', "TEXT NOT NULL DEFAULT ''"]] as const
|
|
83
|
+
const values = (c: ReviewContext) => [c.ecosystem, c.package, c.version, c.integrity, c.basePackage ?? '', c.baseVersion ?? '', c.baseIntegrity ?? '']
|
|
84
|
+
const targetWhere = 'ecosystem = ? AND package = ? AND version = ? AND integrity = ?'
|
|
85
|
+
const contextWhere = `${targetWhere} AND base_package = ? AND base_version = ? AND base_integrity = ?`
|
|
86
|
+
// Its author by the login they reviewed as, and by the name the app's user
|
|
87
|
+
// has now (null once they are none, or have none), as the lists show them.
|
|
88
|
+
const columns = `id, user_id AS user, user_login AS userLogin, (SELECT n.name FROM managed_user n WHERE n.id = user_id) AS userName, ecosystem, package, version, integrity,
|
|
89
|
+
NULLIF(base_package, '') AS basePackage, NULLIF(base_version, '') AS baseVersion, NULLIF(base_integrity, '') AS baseIntegrity,
|
|
90
|
+
NULLIF(shasum, '') AS shasum, NULLIF(base_shasum, '') AS baseShasum,
|
|
91
|
+
time, revision, published_at AS publishedAt, revoked_at AS revokedAt, files, status, comment`
|
|
92
|
+
const conflict = () => { throw new ManagedMutationError(409, 'review-conflict') }
|
|
93
|
+
const shasums = (tarballs: ReviewedTarballs | undefined) => [tarballs?.shasum ?? '', tarballs?.baseShasum ?? '']
|
|
94
|
+
|
|
95
|
+
// Who reviews dependencies in team `t`, user `u` as the query joins them: an
|
|
96
|
+
// admin, in any team; a member who manages, in theirs; a member who triages,
|
|
97
|
+
// in theirs whose dependencies they may see (`view_dependencies`). Publishing
|
|
98
|
+
// (`publishing`) needs the team to review dependencies; reading what was
|
|
99
|
+
// published there, not, so what it published stays read once it stops. A
|
|
100
|
+
// hidden team is no one's.
|
|
101
|
+
export const reviewsIn = (publishing: boolean) => `t.hidden = 0${publishing ? ' AND t.dependency_review = 1' : ''} AND (u.role = 'admin' OR EXISTS (
|
|
102
|
+
SELECT 1 FROM managed_team_user m WHERE m.team_id = t.id AND m.user_id = u.id
|
|
103
|
+
AND (u.role = 'manage' OR (u.role = 'triage' AND m.view_dependencies = 1))))`
|
|
104
|
+
|
|
105
|
+
// Fold legacy multi-note files into one text field without changing a frozen
|
|
106
|
+
// snapshot's stored rows. Continuing or saving uses one row per file.
|
|
107
|
+
function fileEntries(entries: ReviewEntry[]): ReviewEntry[] {
|
|
108
|
+
const files = new Map<string, ReviewEntry>(), verdicts = Object.keys(REVIEW_VERDICTS)
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (entry.status === null) continue
|
|
111
|
+
const current = files.get(entry.file)
|
|
112
|
+
if (!current) { files.set(entry.file, { ...entry }); continue }
|
|
113
|
+
current.comment = [current.comment, entry.comment].filter(Boolean).join('\n\n')
|
|
114
|
+
if (verdicts.indexOf(entry.status ?? '') > verdicts.indexOf(current.status ?? '')) current.status = entry.status
|
|
115
|
+
current.time = Math.max(current.time, entry.time)
|
|
116
|
+
}
|
|
117
|
+
return [...files.values()]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function actor(db: ManagedSql, sessionId: string, admin = false) {
|
|
121
|
+
const user = await db.prepare(`SELECT u.id, u.login, u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
|
|
122
|
+
WHERE s.id = ? AND s.expires_at > ? AND s.viewer_session IS NULL`).get(sessionId, Date.now()) as { id: string; login: string; role: string } | undefined
|
|
123
|
+
if (!user) throw new ManagedMutationError(401, 'unauthenticated')
|
|
124
|
+
if (!(admin ? ['admin'] : ['triage', 'manage', 'admin']).includes(user.role)) throw new ManagedMutationError(403, 'forbidden')
|
|
125
|
+
return user
|
|
126
|
+
}
|
|
127
|
+
// The teams `user` publishes reviews to (reviewsIn).
|
|
128
|
+
async function teams(db: ManagedSql, user: string): Promise<ReviewTeam[]> {
|
|
129
|
+
return await db.prepare(`SELECT t.id, t.name FROM managed_team t JOIN managed_user u ON u.id = ?
|
|
130
|
+
WHERE ${reviewsIn(true)} ORDER BY t.name, t.id`).all(user) as ReviewTeam[]
|
|
131
|
+
}
|
|
132
|
+
// A draft is begun or edited by one who publishes to some team; their own
|
|
133
|
+
// published review revoked, or published to more teams, as `publish` checks
|
|
134
|
+
// each, whoever they are now.
|
|
135
|
+
async function requireReviewer(db: ManagedSql, user: string) {
|
|
136
|
+
if ((await teams(db, user)).length === 0) throw new ManagedMutationError(403, 'review-access-required')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type ReviewRow = Omit<DependencyReview, 'files' | 'entries' | 'teams' | keyof ReviewSummary> & { files: string | null }
|
|
140
|
+
async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Promise<DependencyReview[]> {
|
|
141
|
+
const reviews = rows as ReviewRow[]
|
|
142
|
+
if (reviews.length === 0) return []
|
|
143
|
+
const ids = reviews.map(review => review.id), placeholders = ids.map(() => '?').join(',')
|
|
144
|
+
const entries = await db.prepare(`SELECT review_id AS reviewId, id, file, comment, status, time
|
|
145
|
+
FROM managed_dependency_file_review WHERE review_id IN (${placeholders}) ORDER BY time, id`).all(...ids) as (ReviewEntry & { reviewId: string })[]
|
|
146
|
+
const linked = await db.prepare(`SELECT rt.review_id AS reviewId, t.id, t.name FROM managed_dependency_review_team rt
|
|
147
|
+
JOIN managed_dependency_review r ON r.id = rt.review_id JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = ?
|
|
148
|
+
WHERE rt.review_id IN (${placeholders}) AND (r.user_id = u.id OR (${reviewsIn(false)}))
|
|
149
|
+
ORDER BY t.name, t.id`).all(user, ...ids) as (ReviewTeam & { reviewId: string })[]
|
|
150
|
+
// Each review's own, in their order, by its id.
|
|
151
|
+
const byReview = <T extends { reviewId: string }>(list: T[]) => {
|
|
152
|
+
const of = new Map<string, Omit<T, 'reviewId'>[]>()
|
|
153
|
+
for (const { reviewId, ...row } of list) {
|
|
154
|
+
const own = of.get(reviewId)
|
|
155
|
+
if (own) own.push(row)
|
|
156
|
+
else of.set(reviewId, [row])
|
|
157
|
+
}
|
|
158
|
+
return of
|
|
159
|
+
}
|
|
160
|
+
const [entriesOf, teamsOf] = [byReview(entries), byReview(linked)]
|
|
161
|
+
return reviews.map(review => expanded(review, (entriesOf.get(review.id) ?? []) as ReviewEntry[], (teamsOf.get(review.id) ?? []) as ReviewTeam[]))
|
|
162
|
+
}
|
|
163
|
+
// A review as its row, its entries and the teams its reader reads it in
|
|
164
|
+
// tell it: its files' snapshot, its entries a file's each (fileEntries), and
|
|
165
|
+
// what they sum up to.
|
|
166
|
+
function expanded(review: ReviewRow, entries: ReviewEntry[], readIn: ReviewTeam[]): DependencyReview {
|
|
167
|
+
const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
|
|
168
|
+
const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
|
|
169
|
+
const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
|
|
170
|
+
const ownEntries = fileEntries(entries)
|
|
171
|
+
return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: readIn }
|
|
172
|
+
}
|
|
173
|
+
// Rows gathered into one JSON array value (json_group_array): its text on
|
|
174
|
+
// SQLite, parsed already on Postgres, where none gather as NULL.
|
|
175
|
+
export const gathered = <T>(value: unknown): T[] => value == null ? [] : (typeof value === 'string' ? JSON.parse(value) : value) as T[]
|
|
176
|
+
|
|
177
|
+
async function publish(db: ManagedSql, current: DependencyReview | undefined, user: string, change: Extract<ReviewChange, { action: 'publish' }>, now: number,
|
|
178
|
+
tarballs: ReviewedTarballs | undefined) {
|
|
179
|
+
if (!current || current.revokedAt !== null) return conflict()
|
|
180
|
+
const allowed = new Set((await teams(db, user)).map(team => team.id))
|
|
181
|
+
const selected = [...new Set(change.teamIds)]
|
|
182
|
+
if (selected.length === 0 || selected.some(team => !allowed.has(team))) throw new ManagedMutationError(400, 'bad-review-teams')
|
|
183
|
+
const entries = await db.prepare('SELECT file, status FROM managed_dependency_file_review WHERE review_id = ?').all(current.id) as ReviewInput[]
|
|
184
|
+
if (current.publishedAt === null) {
|
|
185
|
+
if (!summarizeReview(entries, change.files, change.nonTextFiles).checkedFiles) throw new ManagedMutationError(400, 'no-reviewed-files')
|
|
186
|
+
if (!change.final || !allowedReviewVerdicts(entries.filter(entry => !change.nonTextFiles.includes(entry.file))).includes(change.final.status)) throw new ManagedMutationError(400, 'bad-final-verdict')
|
|
187
|
+
if (needsPackageJsonReview(entries, change.files, change.nonTextFiles)) throw new ManagedMutationError(400, 'package-json-not-reviewed')
|
|
188
|
+
await db.prepare(`UPDATE managed_dependency_review SET published_at = ?, files = ?, status = ?, comment = ?, shasum = ?, base_shasum = ?, revision = revision + 1
|
|
189
|
+
WHERE id = ?`).run(now, JSON.stringify({ paths: [...new Set(change.files)].toSorted(), nonTextFiles: [...new Set(change.nonTextFiles)].toSorted() }),
|
|
190
|
+
change.final.status, change.final.comment, ...shasums(tarballs), current.id)
|
|
191
|
+
} else {
|
|
192
|
+
if (change.final) return conflict()
|
|
193
|
+
await db.prepare('UPDATE managed_dependency_review SET revision = revision + 1 WHERE id = ?').run(current.id)
|
|
194
|
+
}
|
|
195
|
+
for (const team of selected) await db.prepare('INSERT INTO managed_dependency_review_team (review_id, team_id) VALUES (?, ?) ON CONFLICT DO NOTHING').run(current.id, team)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function listOwn(db: ManagedSql, user: string, before: { time: number; id: string } | null) {
|
|
199
|
+
const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE user_id = ?
|
|
200
|
+
${before ? 'AND (time < ? OR (time = ? AND id < ?))' : ''} ORDER BY time DESC, id DESC LIMIT 51`)
|
|
201
|
+
.all(user, ...before ? [before.time, before.time, before.id] : []) as DependencyReview[]
|
|
202
|
+
const page = rows.slice(0, 50)
|
|
203
|
+
const last = page.at(-1)
|
|
204
|
+
return { reviews: await expandReviews(db, page, user), next: rows.length > 50 && last ? { time: last.time, id: last.id } : null }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A version's reviews, of its base (`context`), or of any (`allBases`), that
|
|
208
|
+
// `user` reads: their own, and those published to a team they read in.
|
|
209
|
+
async function listContext(db: ManagedSql, context: ReviewContext, user: string, allBases: boolean) {
|
|
210
|
+
const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review r WHERE ${allBases ? targetWhere : contextWhere}
|
|
211
|
+
AND (user_id = ? OR (published_at IS NOT NULL AND EXISTS (
|
|
212
|
+
SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = ?
|
|
213
|
+
WHERE rt.review_id = r.id AND ${reviewsIn(false)})))
|
|
214
|
+
ORDER BY time, id`).all(...values(context).slice(0, allBases ? 4 : 7), user, user)
|
|
215
|
+
return expandReviews(db, rows, user)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A team's page of reviews in one statement: a row of its reader `v` where
|
|
219
|
+
// they read them there (reviewsIn), none for another, with each review on
|
|
220
|
+
// a row of its own, its entries and the teams `v` reads it in (as
|
|
221
|
+
// expandReviews reads them) each one JSON array.
|
|
222
|
+
async function listTeam(db: ManagedSql, teamId: string, user: string, before: { time: number; id: string } | null) {
|
|
223
|
+
const rows = await db.prepare(`WITH reader AS (
|
|
224
|
+
SELECT u.id AS id FROM managed_team t JOIN managed_user u ON u.id = ? WHERE t.id = ? AND ${reviewsIn(false)}
|
|
225
|
+
), page AS (
|
|
226
|
+
SELECT ${columns} FROM managed_dependency_review r JOIN managed_dependency_review_team rt ON rt.review_id = r.id
|
|
227
|
+
WHERE rt.team_id = ? AND r.published_at IS NOT NULL AND EXISTS (SELECT 1 FROM reader)
|
|
228
|
+
${before ? 'AND (r.published_at < ? OR (r.published_at = ? AND r.id < ?))' : ''}
|
|
229
|
+
ORDER BY r.published_at DESC, r.id DESC LIMIT 51
|
|
230
|
+
)
|
|
231
|
+
SELECT v.id AS reader, p.*,
|
|
232
|
+
(SELECT json_group_array(json_object('id', e.id, 'file', e.file, 'comment', e.comment, 'status', e.status, 'time', e.time) ORDER BY e.time, e.id)
|
|
233
|
+
FROM managed_dependency_file_review e WHERE e.review_id = p.id) AS entries,
|
|
234
|
+
(SELECT json_group_array(json_object('id', t.id, 'name', t.name) ORDER BY t.name, t.id)
|
|
235
|
+
FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = v.id
|
|
236
|
+
WHERE rt.review_id = p.id AND (p.user = u.id OR (${reviewsIn(false)}))) AS teams
|
|
237
|
+
FROM reader v LEFT JOIN page p ON 1 = 1
|
|
238
|
+
ORDER BY p.publishedAt DESC, p.id DESC`).all(user, teamId, teamId, ...before ? [before.time, before.time, before.id] : []) as
|
|
239
|
+
(ReviewRow & { reader: string; entries: unknown; teams: unknown })[]
|
|
240
|
+
if (rows.length === 0) return null
|
|
241
|
+
const reviews = rows.filter(row => row.id != null)
|
|
242
|
+
const page = reviews.slice(0, 50)
|
|
243
|
+
const last = page.at(-1)
|
|
244
|
+
return { reviews: page.map(({ reader: _reader, entries, teams: readIn, ...review }) => expanded(review, gathered(entries), gathered(readIn))),
|
|
245
|
+
next: reviews.length > 50 && last ? { time: Number(last.publishedAt), id: last.id } : null }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function saveFile(db: ManagedSql, reviewId: string, entry: ReviewInput, requestedId: string | null, now: number) {
|
|
249
|
+
const existing = await db.prepare(`SELECT id FROM managed_dependency_file_review
|
|
250
|
+
WHERE review_id = ? AND file = ? ORDER BY time, id`).all(reviewId, entry.file) as { id: string }[]
|
|
251
|
+
if (requestedId && !existing.some(row => row.id === requestedId)) return conflict()
|
|
252
|
+
if (entry.status === null) {
|
|
253
|
+
await db.prepare('DELETE FROM managed_dependency_file_review WHERE review_id = ? AND file = ?').run(reviewId, entry.file)
|
|
254
|
+
} else if (existing.length > 0) {
|
|
255
|
+
const entryId = existing[0]!.id
|
|
256
|
+
await db.prepare('UPDATE managed_dependency_file_review SET comment = ?, status = ?, time = ? WHERE id = ?')
|
|
257
|
+
.run(entry.comment, entry.status, now, entryId)
|
|
258
|
+
if (existing.length > 1) {
|
|
259
|
+
await db.prepare(`DELETE FROM managed_dependency_file_review
|
|
260
|
+
WHERE review_id = ? AND file = ? AND id <> ?`).run(reviewId, entry.file, entryId)
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
await db.prepare('INSERT INTO managed_dependency_file_review (id, review_id, file, comment, status, time) VALUES (?, ?, ?, ?, ?, ?)')
|
|
264
|
+
.run(randomUUID(), reviewId, entry.file, entry.comment, entry.status, now)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
|
|
269
|
+
const read = (id: string) => db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE id = ?`).get(id)
|
|
270
|
+
return {
|
|
271
|
+
listOwnDependencyReviews: (user, before) => listOwn(db, user, before),
|
|
272
|
+
async listOwnDependencyReviewsById(user, ids) {
|
|
273
|
+
if (ids.length === 0) return []
|
|
274
|
+
return expandReviews(db, await db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})
|
|
275
|
+
ORDER BY time, id`).all(user, ...ids), user)
|
|
276
|
+
},
|
|
277
|
+
listTeamDependencyReviews: (teamId, user, before) => listTeam(db, teamId, user, before),
|
|
278
|
+
async getLatestOwnDependencyReview(user, target) {
|
|
279
|
+
const row = await db.prepare(`SELECT ${columns} FROM managed_dependency_review
|
|
280
|
+
WHERE user_id = ? AND ecosystem = ? AND package = ? AND version = ? AND integrity = ? ORDER BY time DESC, id DESC LIMIT 1`)
|
|
281
|
+
.get(user, target.ecosystem, target.package, target.version, target.integrity)
|
|
282
|
+
return row ? (await expandReviews(db, [row], user))[0]! : null
|
|
283
|
+
},
|
|
284
|
+
async discardDependencyReview(sessionId, reviewId, revision) {
|
|
285
|
+
const user = await actor(db, sessionId)
|
|
286
|
+
const { changes } = await db.prepare(`DELETE FROM managed_dependency_review
|
|
287
|
+
WHERE id = ? AND user_id = ? AND revision = ? AND published_at IS NULL`).run(reviewId, user.id, revision)
|
|
288
|
+
if (Number(changes) !== 1) conflict()
|
|
289
|
+
},
|
|
290
|
+
listDependencyReviewEnabledTeams: async () => (await db.prepare('SELECT id FROM managed_team WHERE dependency_review = 1 ORDER BY id').all() as { id: string }[]).map(row => row.id),
|
|
291
|
+
async setTeamDependencyReview(sessionId, teamId, enabled) {
|
|
292
|
+
await actor(db, sessionId, true)
|
|
293
|
+
if (!await db.prepare('SELECT id FROM managed_team WHERE id = ?').get(teamId)) return false
|
|
294
|
+
await db.prepare('UPDATE managed_team SET dependency_review = ? WHERE id = ?').run(enabled ? 1 : 0, teamId)
|
|
295
|
+
return true
|
|
296
|
+
},
|
|
297
|
+
listDependencyReviewTeams: user => teams(db, user),
|
|
298
|
+
listDependencyReviews: (context, user, allBases = false) => listContext(db, context, user, allBases),
|
|
299
|
+
async changeDependencyReview(context, sessionId, change, now, tarballs) {
|
|
300
|
+
const user = await actor(db, sessionId)
|
|
301
|
+
if (change.action !== 'revoke' && change.action !== 'publish') await requireReviewer(db, user.id)
|
|
302
|
+
let id = change.reviewId
|
|
303
|
+
let current: DependencyReview | undefined
|
|
304
|
+
if (id) {
|
|
305
|
+
current = await db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE id = ? AND user_id = ? AND ${contextWhere}`)
|
|
306
|
+
.get(id, user.id, ...values(context)) as DependencyReview | undefined
|
|
307
|
+
if (!current || current.revision !== change.revision) return conflict()
|
|
308
|
+
}
|
|
309
|
+
const unpublished = () => db.prepare(`SELECT id FROM managed_dependency_review WHERE user_id = ? AND ${contextWhere} AND published_at IS NULL`)
|
|
310
|
+
.get(user.id, ...values(context)) as Promise<{ id: string } | undefined>
|
|
311
|
+
const create = async () => {
|
|
312
|
+
if (await unpublished()) return conflict()
|
|
313
|
+
const next = randomUUID()
|
|
314
|
+
await db.prepare(`INSERT INTO managed_dependency_review
|
|
315
|
+
(id, user_id, user_login, ecosystem, package, version, integrity, base_package, base_version, base_integrity, shasum, base_shasum, time, revision)
|
|
316
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(next, user.id, user.login, ...values(context), ...shasums(tarballs), now)
|
|
317
|
+
return next
|
|
318
|
+
}
|
|
319
|
+
if (change.action === 'start') {
|
|
320
|
+
id = await create()
|
|
321
|
+
} else if (change.action === 'continue') {
|
|
322
|
+
if (!current?.publishedAt) return conflict()
|
|
323
|
+
id = await create()
|
|
324
|
+
await db.prepare('UPDATE managed_dependency_review SET comment = ? WHERE id = ?').run(current.comment, id)
|
|
325
|
+
const entries = await db.prepare('SELECT id, file, comment, status, time FROM managed_dependency_file_review WHERE review_id = ? ORDER BY time, id').all(current.id) as ReviewEntry[]
|
|
326
|
+
for (const entry of fileEntries(entries)) {
|
|
327
|
+
await db.prepare(`INSERT INTO managed_dependency_file_review
|
|
328
|
+
(id, review_id, file, comment, status, time) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
329
|
+
.run(randomUUID(), id, entry.file, entry.comment, entry.status, entry.time)
|
|
330
|
+
}
|
|
331
|
+
} else if (change.action === 'revoke') {
|
|
332
|
+
if (!current || current.publishedAt === null || current.revokedAt !== null) return conflict()
|
|
333
|
+
await db.prepare('UPDATE managed_dependency_review SET revoked_at = ?, revision = revision + 1 WHERE id = ?').run(now, id)
|
|
334
|
+
} else if (change.action === 'publish') {
|
|
335
|
+
await publish(db, current, user.id, change, now, tarballs)
|
|
336
|
+
} else {
|
|
337
|
+
if (current?.publishedAt != null) return conflict()
|
|
338
|
+
if (change.entry.status === null && !change.entryId) throw new ManagedMutationError(400, 'bad-review')
|
|
339
|
+
if (id) await db.prepare('UPDATE managed_dependency_review SET time = ?, shasum = ?, base_shasum = ?, revision = revision + 1 WHERE id = ?').run(now, ...shasums(tarballs), id)
|
|
340
|
+
else { if (change.entryId) return conflict(); id = await create() }
|
|
341
|
+
await saveFile(db, id, change.entry, change.entryId, now)
|
|
342
|
+
}
|
|
343
|
+
return (await expandReviews(db, [await read(id!)], user.id))[0]!
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// GitHub accounts as GitHub's REST and GraphQL APIs name them, where a
|
|
2
|
+
// repository's activity names its authors (repository-history.ts and the
|
|
3
|
+
// Commits, Issues, Agents and Pulse tabs' readers).
|
|
4
|
+
import { githubGraphql } from './github-graphql.ts'
|
|
5
|
+
|
|
6
|
+
const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
|
|
7
|
+
|
|
8
|
+
// An account as GitHub names it: its login, else null, and its numeric id
|
|
9
|
+
// where GitHub names one with it.
|
|
10
|
+
export interface NamedAccount { login: string | null; id: number | null }
|
|
11
|
+
export function accountOf(account: { login?: unknown; id?: unknown } | null | undefined): NamedAccount {
|
|
12
|
+
const login = typeof account?.login === 'string' && LOGIN.test(account.login) ? account.login : null
|
|
13
|
+
const id = account?.id
|
|
14
|
+
return { login, id: login && Number.isSafeInteger(id) && (id as number) > 0 ? id as number : null }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// An actor (the author of an issue or a pull request, one who merged one) as
|
|
18
|
+
// GitHub's GraphQL API names it: its login, and its account's numeric id, a
|
|
19
|
+
// user's or a GitHub App's bot's.
|
|
20
|
+
export const ACTOR = 'login ... on User { databaseId } ... on Bot { databaseId }'
|
|
21
|
+
export type GraphAccount = { login?: unknown; databaseId?: unknown } | null | undefined
|
|
22
|
+
export const actorOf = (actor: GraphAccount) => accountOf({ login: actor?.login, id: actor?.databaseId })
|
|
23
|
+
|
|
24
|
+
// A commit's author's account, as GitHub's GraphQL API names it: the user
|
|
25
|
+
// GitHub knows its address for; else, as GitHub's REST API does, the account
|
|
26
|
+
// whose noreply address its address is, `<id>+<login>@users.noreply.github.com`:
|
|
27
|
+
// a GitHub App's bot's, which is no user (GraphQL's `user` is a User's, never
|
|
28
|
+
// a bot's). One by GitHub's older no-reply address, `<login>@users.noreply.github.com`,
|
|
29
|
+
// naming no id, its user looked up by its login (repository-history.ts linkNoreplyUsers).
|
|
30
|
+
const NOREPLY = /^([1-9]\d{0,11})\+([^@]+)@users\.noreply\.github\.com$/iu
|
|
31
|
+
export type GraphActor = { email?: unknown; user?: GraphAccount | null }
|
|
32
|
+
export function commitAccount(author: GraphActor | null | undefined): NamedAccount {
|
|
33
|
+
const user = actorOf(author?.user)
|
|
34
|
+
const noreply = user.id === null && typeof author?.email === 'string' ? NOREPLY.exec(author.email) : null
|
|
35
|
+
return noreply ? accountOf({ login: noreply[2], id: Number(noreply[1]) }) : user
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// GitHub's no-reply address of old, `<login>@users.noreply.github.com`, which
|
|
39
|
+
// names no id: GitHub links the commits it authors to the account of its
|
|
40
|
+
// login, as its REST API names them, but its GraphQL API names no user for it.
|
|
41
|
+
const NOREPLY_LOGIN = /^([A-Za-z\d][A-Za-z\d-]{0,38})@users\.noreply\.github\.com$/iu
|
|
42
|
+
const MAX_NOREPLY_LOGINS = 100
|
|
43
|
+
|
|
44
|
+
// Commits' authors and co-authors (`actors`), as GitHub's GraphQL API names
|
|
45
|
+
// them, those it names no user for by such an address named the user of its
|
|
46
|
+
// login, as GitHub links them: those logins, as many as MAX_NOREPLY_LOGINS,
|
|
47
|
+
// looked up in one query, where any is.
|
|
48
|
+
export async function linkNoreplyUsers(actors: (GraphActor | null | undefined)[], token: string, fetchImpl: typeof fetch): Promise<void> {
|
|
49
|
+
const unnamed = actors.flatMap(actor => {
|
|
50
|
+
const login = actor && !actor.user && typeof actor.email === 'string' ? NOREPLY_LOGIN.exec(actor.email)?.[1]?.toLowerCase() : undefined
|
|
51
|
+
return login ? [{ actor: actor!, login }] : []
|
|
52
|
+
})
|
|
53
|
+
const logins = [...new Set(unnamed.map(({ login }) => login))].slice(0, MAX_NOREPLY_LOGINS)
|
|
54
|
+
if (logins.length === 0) return
|
|
55
|
+
const query = `query(${logins.map((_, at) => `$l${at}: String!`).join(', ')}) { ${logins.map((_, at) => `u${at}: user(login: $l${at}) { login databaseId }`).join(' ')} }`
|
|
56
|
+
const data = await githubGraphql(query, Object.fromEntries(logins.map((login, at) => [`l${at}`, login])), token, fetchImpl,
|
|
57
|
+
error => error?.type === 'NOT_FOUND') as Record<string, GraphAccount> | null
|
|
58
|
+
const users = new Map(logins.map((login, at) => [login, data?.[`u${at}`] ?? null]))
|
|
59
|
+
for (const { actor, login } of unnamed) actor.user = users.get(login) ?? null
|
|
60
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// GitHub as a risk scan resolves actions with (risk ActionsSource), read with
|
|
2
|
+
// an admin's own token, or none for public repositories alone: what GitHub
|
|
3
|
+
// has none of reads as none, anything else is thrown as a GithubApiError.
|
|
4
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
5
|
+
import type { ActionsSource } from '../risk/index.js'
|
|
6
|
+
import { GithubApiError, upstreamGithub } from './github-errors.ts'
|
|
7
|
+
import { githubJson } from './github-app.ts'
|
|
8
|
+
|
|
9
|
+
// How many pages of 100 tags are listed; a repository with more is told by
|
|
10
|
+
// its tags one at a time.
|
|
11
|
+
const MAX_TAG_PAGES = 10
|
|
12
|
+
const BRANCHES_PAGE = 100
|
|
13
|
+
|
|
14
|
+
const api = (name: string, suffix: string) => `https://api.github.com/repos/${name.split('/').map(encodeURIComponent).join('/')}${suffix}`
|
|
15
|
+
|
|
16
|
+
// Whether upstream refused what it was asked before asking GitHub, as a ref
|
|
17
|
+
// git wouldn't take: its arguments' checks say what each must be.
|
|
18
|
+
const isRefused = (error: unknown) => error instanceof Error && error.name === 'AssertionError' && / must be /u.test(error.message)
|
|
19
|
+
|
|
20
|
+
// What `read` answers, null where GitHub has none (a 404), or upstream
|
|
21
|
+
// refused to ask.
|
|
22
|
+
async function orNone<T>(read: () => Promise<T>): Promise<T | null> {
|
|
23
|
+
try {
|
|
24
|
+
return await upstreamGithub(() => read().catch((error: unknown) => { throw isRefused(error) ? new GithubApiError(404, 'github-not-found') : error }))
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof GithubApiError && error.status === 404) return null
|
|
27
|
+
throw error
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RepoObject { full_name?: unknown; default_branch?: unknown; fork?: unknown; parent?: { full_name?: unknown }; archived?: unknown; private?: unknown }
|
|
32
|
+
|
|
33
|
+
export function githubActionsSource(token: string | null, fetchImpl: typeof fetch = globalThis.fetch): ActionsSource {
|
|
34
|
+
const client = createClient({ token })
|
|
35
|
+
return {
|
|
36
|
+
async repo(name) {
|
|
37
|
+
const info = await orNone(() => client.getRepo({ repo: name, followRedirect: true })) as RepoObject | null
|
|
38
|
+
if (!info) return null
|
|
39
|
+
if (typeof info.full_name !== 'string' || typeof info.default_branch !== 'string') throw new GithubApiError(502, 'github-malformed')
|
|
40
|
+
return { fullName: info.full_name, defaultBranch: info.default_branch, archived: info.archived === true, private: info.private !== false,
|
|
41
|
+
fork: info.fork === true ? (typeof info.parent?.full_name === 'string' ? info.parent.full_name : '') : null }
|
|
42
|
+
},
|
|
43
|
+
tags(name) {
|
|
44
|
+
// More than MAX_TAG_PAGES pages, which upstream refuses to list whole.
|
|
45
|
+
return upstreamGithub(() => client.listRepoTags({ repo: name, maxPages: MAX_TAG_PAGES })
|
|
46
|
+
.catch((error: unknown) => { if (error instanceof Error && /more than \d+ pages/u.test(error.message)) return null; throw error }))
|
|
47
|
+
},
|
|
48
|
+
tag: async (name, tag) => (await orNone(() => client.getRepoTag({ repo: name, tag })))?.oid ?? null,
|
|
49
|
+
branch: async (name, branch) => (await orNone(() => client.getRepoHead({ repo: name, branch })))?.oid ?? null,
|
|
50
|
+
async branches(name) {
|
|
51
|
+
const listed = await githubJson(api(name, `/branches?per_page=${BRANCHES_PAGE}`), token, fetchImpl)
|
|
52
|
+
if (!Array.isArray(listed)) throw new GithubApiError(502, 'github-malformed')
|
|
53
|
+
const branches = (listed as { name?: unknown; commit?: { sha?: unknown } }[]).flatMap(item => typeof item?.name === 'string'
|
|
54
|
+
&& typeof item.commit?.sha === 'string' && /^[\da-f]{40}$/u.test(item.commit.sha) ? [{ branch: item.name, oid: item.commit.sha }] : [])
|
|
55
|
+
return { branches, complete: listed.length < BRANCHES_PAGE }
|
|
56
|
+
},
|
|
57
|
+
// `base...sha`: `sha` is `base` or in its history where GitHub tells it
|
|
58
|
+
// behind or identical, a commit of a fork in the same network too.
|
|
59
|
+
async contains(name, base, sha) {
|
|
60
|
+
if (!/^[\da-f]{40}$/u.test(sha)) return null
|
|
61
|
+
let compared
|
|
62
|
+
try { compared = await githubJson(api(name, `/compare/${base.split('/').map(encodeURIComponent).join('/')}...${sha}?per_page=1`), token, fetchImpl) as { status?: unknown } } catch (error) {
|
|
63
|
+
if (error instanceof GithubApiError && (error.status === 404 || error.message === 'github-status-422')) return null
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
if (typeof compared?.status !== 'string') throw new GithubApiError(502, 'github-malformed')
|
|
67
|
+
return compared.status === 'behind' || compared.status === 'identical'
|
|
68
|
+
},
|
|
69
|
+
file: (name, path, commit) => orNone(() => client.getRepoFile({ repo: name, path, ref: commit })),
|
|
70
|
+
}
|
|
71
|
+
}
|