@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.51
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 +3 -0
- package/common/managed/finding-detail.js +66 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +7 -0
- package/common/managed/npm-packages.js +51 -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 +359 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +6 -0
- package/common/managed/team-app.js +47 -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-2K6UN3PC.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-6LNQTKAV.js +52 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7JBSSRD7.js +3 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FMXWXDR4.js +36 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HT2SSA3V.js +1 -0
- package/out/chunk-IPAH4LKF.js +232 -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-KOGNQDSJ.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-VDRXHIHN.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-WRAJVD35.js +1 -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 +364 -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 +23 -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 +76 -0
- package/server-managed/comparison.ts +52 -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 +468 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +143 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +98 -0
- package/server-managed/dependency-reviews.ts +364 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +376 -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 +136 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5503 -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/listed-activity.ts +39 -0
- package/server-managed/locked-packages.ts +152 -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 +225 -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-patch.ts +325 -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 +351 -0
- package/server-managed/pull-dependency-reviews.ts +334 -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 +448 -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 +166 -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 +212 -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 +149 -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 +319 -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,168 @@
|
|
|
1
|
+
// The pull requests squashed into a repository's commits, as the code
|
|
2
|
+
// viewer's Commits tab draws them (repository-history.ts), each looked up
|
|
3
|
+
// with GraphQL once and kept by its commit.
|
|
4
|
+
import { linkNoreplyUsers } from './github-accounts.ts'
|
|
5
|
+
import { type GraphqlError, githubGraphql } from './github-graphql.ts'
|
|
6
|
+
import { COMMIT_FRAGMENT, type GraphCommit, type HistoryCommit, commitActors, graphCommit, inBatches, variablesOf } from './repository-history.ts'
|
|
7
|
+
|
|
8
|
+
const SHA = /^[a-f\d]{40}$/u
|
|
9
|
+
|
|
10
|
+
// A pull request squashed into a commit of the default branch's history
|
|
11
|
+
// (`merge`): its number, the commit its head was at when merged (`head`,
|
|
12
|
+
// GitHub's own of it, which a branch reused or pushed over since never
|
|
13
|
+
// moves), and its own commits, the latest MAX_SQUASHED of them, `more`
|
|
14
|
+
// where it had more. Drawn as the merge it was, its commits on a lane of
|
|
15
|
+
// their own (repository-history.js).
|
|
16
|
+
export interface SquashedPull { merge: string; number: number; head: string; commits: HistoryCommit[]; more: boolean }
|
|
17
|
+
// The pull requests squashed into commits, kept by each commit (a merged pull
|
|
18
|
+
// request's commits never change); null for a commit none was.
|
|
19
|
+
export interface Squashes {
|
|
20
|
+
read(shas: string[]): Promise<Map<string, SquashedPull | null>>
|
|
21
|
+
write(sha: string, pull: SquashedPull | null): Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The pull requests merged as another read told them (http.ts pulseMerges,
|
|
25
|
+
// the Pulse's agent-merges.ts agentMerges): each's merge commit, its number
|
|
26
|
+
// where told (null where not), every one merged from `from` till `to` among
|
|
27
|
+
// them; a commit committed within, as a squash's or a rebase's is as it is
|
|
28
|
+
// merged, the merge commit of the one it names, or of none.
|
|
29
|
+
export interface KnownMerges { numbers: ReadonlyMap<string, number | null>; from: number; to: number }
|
|
30
|
+
// How far within `from` and `to` a commit is told by them, for the clocks
|
|
31
|
+
// of a merge and its commit.
|
|
32
|
+
const KNOWN_SKEW_MS = 5 * 60_000
|
|
33
|
+
|
|
34
|
+
const MAX_SQUASHED = 100
|
|
35
|
+
// Looked up of an answer's commits not yet kept, at most, the newest first;
|
|
36
|
+
// in queries of as many commits, or pull requests (their commits' lines
|
|
37
|
+
// counted, as a history's are), as many at once.
|
|
38
|
+
export const MAX_SQUASH_LOOKUPS = 500
|
|
39
|
+
const SQUASH_COMMITS = 100
|
|
40
|
+
const SQUASH_PULLS = 5
|
|
41
|
+
const SQUASH_PARALLEL = 4
|
|
42
|
+
|
|
43
|
+
// The merged pull request that brought each commit in, `c<n>` the `n`th's.
|
|
44
|
+
const mergedPullsQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
|
|
45
|
+
repository(owner: $owner, name: $name) {
|
|
46
|
+
${Array.from({ length: count }, (_, at) => `c${at}: object(oid: $o${at}) { ... on Commit { associatedPullRequests(first: 1) { nodes { number mergeCommit { oid } } } } }`).join('\n ')}
|
|
47
|
+
}
|
|
48
|
+
}`
|
|
49
|
+
// Each pull request's head when merged and its latest commits, `p<n>` the `n`th's.
|
|
50
|
+
const squashedQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('n', count, 'Int!')}) {
|
|
51
|
+
repository(owner: $owner, name: $name) {
|
|
52
|
+
${Array.from({ length: count }, (_, at) => `p${at}: pullRequest(number: $n${at}) { headRefOid commits(last: ${MAX_SQUASHED}) { totalCount nodes { commit { ...historyCommit } } } }`).join('\n ')}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
${COMMIT_FRAGMENT}`
|
|
56
|
+
|
|
57
|
+
type MergedPulls = { repository?: Record<string, { associatedPullRequests?: { nodes?: ({ number?: unknown; mergeCommit?: { oid?: unknown } | null } | null)[] } } | null> | null }
|
|
58
|
+
type SquashedPage = { repository?: Record<string, { headRefOid?: unknown; commits?: { totalCount?: unknown; nodes?: ({ commit?: GraphCommit | null } | null)[] } } | null> | null }
|
|
59
|
+
|
|
60
|
+
// A pull request or commit GitHub has none of, as one deleted: its field null.
|
|
61
|
+
const notFound = (error: GraphqlError) => error?.type === 'NOT_FOUND'
|
|
62
|
+
|
|
63
|
+
// Each repository's lookup of squashed pull requests going on, by its full
|
|
64
|
+
// name: one at a time, those asking meanwhile waiting on it, and their
|
|
65
|
+
// commits it doesn't look up looked up once it is done.
|
|
66
|
+
const lookingUp = new Map<string, Promise<void>>()
|
|
67
|
+
|
|
68
|
+
// Whether `promise` settles within `ms`, waited on that long at most.
|
|
69
|
+
const within = (promise: Promise<unknown>, ms: number) => ms === Number.POSITIVE_INFINITY ? promise
|
|
70
|
+
: new Promise<void>(resolve => {
|
|
71
|
+
const timer = setTimeout(resolve, ms)
|
|
72
|
+
void promise.finally(() => { clearTimeout(timer); resolve() })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// The pull requests squashed into `commits`, a merged pull request's merge
|
|
76
|
+
// commit of one parent, not one rebased (its commits copied onto the branch,
|
|
77
|
+
// told by their subjects), kept by each commit (`squashes`), those not yet
|
|
78
|
+
// kept looked up with GraphQL, as many as MAX_SQUASH_LOOKUPS an answer, each
|
|
79
|
+
// kept as it is found (lookUpSquashes), of those merges another read told
|
|
80
|
+
// (`known`), the pull request each was merged by not looked up. Answered
|
|
81
|
+
// with those kept once the
|
|
82
|
+
// repository's lookup is done, or `waitMs` on, that lookup going on behind:
|
|
83
|
+
// `behind`, one this started, for the caller to keep alive, and `pending`
|
|
84
|
+
// while one does. Another's lookup done within it, but not of all of these,
|
|
85
|
+
// those it left are looked up as this one's, within what is left of it.
|
|
86
|
+
export async function squashedPulls(fullName: string, token: string, commits: HistoryCommit[], squashes: Squashes,
|
|
87
|
+
waitMs = Number.POSITIVE_INFINITY, fetchImpl: typeof fetch = globalThis.fetch, known: KnownMerges | null = null)
|
|
88
|
+
: Promise<{ pulls: SquashedPull[]; pending: boolean; behind: Promise<void> | null }> {
|
|
89
|
+
const single = commits.filter(commit => commit.parents.length === 1)
|
|
90
|
+
const kept = await squashes.read(single.map(commit => commit.sha))
|
|
91
|
+
let unknown = single.filter(commit => !kept.has(commit.sha))
|
|
92
|
+
if (unknown.length === 0) return { pulls: single.flatMap(commit => kept.get(commit.sha) ?? []), pending: false, behind: null }
|
|
93
|
+
const listed = new Map(commits.map(commit => [commit.sha, commit]))
|
|
94
|
+
const deadline = Date.now() + waitMs
|
|
95
|
+
const found = new Map<string, SquashedPull | null>()
|
|
96
|
+
// Waited on, as long as is left, and those of `unknown` it kept found.
|
|
97
|
+
const settle = async (looking: Promise<void>) => {
|
|
98
|
+
await within(looking, Math.max(0, deadline - Date.now()))
|
|
99
|
+
for (const [sha, pull] of await squashes.read(unknown.map(commit => commit.sha))) found.set(sha, pull)
|
|
100
|
+
unknown = unknown.filter(commit => !found.has(commit.sha))
|
|
101
|
+
}
|
|
102
|
+
const another = lookingUp.get(fullName)
|
|
103
|
+
if (another) await settle(another)
|
|
104
|
+
let behind: Promise<void> | null = null
|
|
105
|
+
if (!another || (unknown.length > 0 && !lookingUp.has(fullName))) {
|
|
106
|
+
const lookup = lookUpSquashes(fullName, token, unknown.slice(0, MAX_SQUASH_LOOKUPS), listed, squashes, fetchImpl, known)
|
|
107
|
+
const done: Promise<void> = lookup.catch(() => {}).finally(() => { if (lookingUp.get(fullName) === done) lookingUp.delete(fullName) })
|
|
108
|
+
lookingUp.set(fullName, done)
|
|
109
|
+
behind = lookup
|
|
110
|
+
await settle(done)
|
|
111
|
+
}
|
|
112
|
+
return { pulls: single.flatMap(commit => kept.get(commit.sha) ?? found.get(commit.sha) ?? []), pending: lookingUp.has(fullName), behind }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The pull requests squashed into `unknown`, of the commits `listed`, looked
|
|
116
|
+
// up, each kept as it is found (squashedPulls); those committed when
|
|
117
|
+
// `known` tells every merge, by it: a merge commit's pull request, by its
|
|
118
|
+
// number, the rest none's.
|
|
119
|
+
async function lookUpSquashes(fullName: string, token: string, unknown: HistoryCommit[], listed: Map<string, HistoryCommit>, squashes: Squashes,
|
|
120
|
+
fetchImpl: typeof fetch, known: KnownMerges | null) {
|
|
121
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
122
|
+
const merged: { commit: HistoryCommit; number: number }[] = []
|
|
123
|
+
const asked: HistoryCommit[] = []
|
|
124
|
+
for (const commit of unknown) {
|
|
125
|
+
const told = known !== null && commit.committedAt !== null && commit.committedAt >= known.from + KNOWN_SKEW_MS && commit.committedAt <= known.to - KNOWN_SKEW_MS
|
|
126
|
+
const number = told ? known.numbers.get(commit.sha) : null
|
|
127
|
+
if (number === null) asked.push(commit)
|
|
128
|
+
else if (number === undefined) await squashes.write(commit.sha, null)
|
|
129
|
+
else merged.push({ commit, number })
|
|
130
|
+
}
|
|
131
|
+
await inBatches(asked, SQUASH_COMMITS, SQUASH_PARALLEL, async batch => {
|
|
132
|
+
const data = await githubGraphql(mergedPullsQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map((commit, at) => [`o${at}`, commit.sha])) },
|
|
133
|
+
token, fetchImpl, notFound) as MergedPulls
|
|
134
|
+
for (const [at, commit] of batch.entries()) {
|
|
135
|
+
const node = data?.repository?.[`c${at}`]
|
|
136
|
+
if (!node) continue
|
|
137
|
+
const pull = node.associatedPullRequests?.nodes?.[0]
|
|
138
|
+
const number = pull?.mergeCommit?.oid === commit.sha && Number.isSafeInteger(pull.number) && (pull.number as number) > 0 ? pull.number as number : null
|
|
139
|
+
if (number === null) await squashes.write(commit.sha, null)
|
|
140
|
+
else merged.push({ commit, number })
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
await inBatches(merged, SQUASH_PULLS, SQUASH_PARALLEL, async batch => {
|
|
144
|
+
const data = await githubGraphql(squashedQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map(({ number }, at) => [`n${at}`, number])) },
|
|
145
|
+
token, fetchImpl, notFound) as SquashedPage
|
|
146
|
+
await linkNoreplyUsers(Object.values(data?.repository ?? {}).flatMap(pull => (pull?.commits?.nodes ?? []).flatMap(item => commitActors(item?.commit))), token, fetchImpl)
|
|
147
|
+
for (const [at, { commit, number }] of batch.entries()) {
|
|
148
|
+
const node = data?.repository?.[`p${at}`]
|
|
149
|
+
if (!node) continue
|
|
150
|
+
const own = (node.commits?.nodes ?? []).flatMap(item => graphCommit(item?.commit) ?? [])
|
|
151
|
+
const head = typeof node.headRefOid === 'string' && SHA.test(node.headRefOid) ? node.headRefOid : own.at(-1)?.sha
|
|
152
|
+
// Fast-forwarded, its last commit the merge's own; or rebased, its
|
|
153
|
+
// commits, more than one, copied onto the branch: the merge and those
|
|
154
|
+
// below it on their first parents, by their subjects, its commits'
|
|
155
|
+
// (one squashed is told by its commit's title too, by default, and
|
|
156
|
+
// drawn as it was either way).
|
|
157
|
+
const below = [commit]
|
|
158
|
+
for (let next = listed.get(commit.parents[0] ?? ''); next && below.length < own.length; next = listed.get(next.parents[0] ?? '')) below.push(next)
|
|
159
|
+
const copies = own.toReversed().slice(0, below.length).every((item, step) => below[step]!.subject === item.subject)
|
|
160
|
+
// Its commits' copies as far as those listed go, but not all of them
|
|
161
|
+
// listed: told, and kept, once they are.
|
|
162
|
+
if (own.length > 1 && copies && below.length < own.length) continue
|
|
163
|
+
const rebased = own.length > 1 && copies
|
|
164
|
+
const squashed = head !== undefined && head !== commit.sha && own.length > 0 && !own.some(item => item.sha === commit.sha) && !rebased
|
|
165
|
+
await squashes.write(commit.sha, squashed ? { merge: commit.sha, number, head, commits: own, more: Number(node.commits?.totalCount) > own.length } : null)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Where a request's time goes, told back in its Server-Timing header, which
|
|
2
|
+
// DevTools shows beside the request: the database's statements and the time
|
|
3
|
+
// spent in its operations (sql.ts), new pooled connections and their
|
|
4
|
+
// handshakes (db-neon.ts), GitHub's calls (github-app.ts githubJson), and
|
|
5
|
+
// the whole, from the handler's start to its answer's head; an instance's
|
|
6
|
+
// first request marked cold. Counts and durations alone, nothing of what was
|
|
7
|
+
// asked; a streamed answer's head tells what was known as it was written.
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
9
|
+
import { ServerResponse } from 'node:http'
|
|
10
|
+
|
|
11
|
+
interface Timing {
|
|
12
|
+
started: number
|
|
13
|
+
cold: boolean
|
|
14
|
+
statements: number
|
|
15
|
+
db: number; dbMs: number
|
|
16
|
+
conn: number; connMs: number
|
|
17
|
+
gh: number; ghMs: number
|
|
18
|
+
}
|
|
19
|
+
const requests = new AsyncLocalStorage<Timing>()
|
|
20
|
+
let served = false
|
|
21
|
+
|
|
22
|
+
// `work` timed, its response's head carrying what it spent where; a test's
|
|
23
|
+
// stand-in for a response, not a ServerResponse, is left as it is.
|
|
24
|
+
export function runTimed<T>(res: ServerResponse, work: () => T): T {
|
|
25
|
+
const timing: Timing = { started: performance.now(), cold: !served, statements: 0, db: 0, dbMs: 0, conn: 0, connMs: 0, gh: 0, ghMs: 0 }
|
|
26
|
+
served = true
|
|
27
|
+
if (res instanceof ServerResponse) {
|
|
28
|
+
const writeHead = res.writeHead
|
|
29
|
+
res.writeHead = function (this: ServerResponse, ...args: Parameters<ServerResponse['writeHead']>) {
|
|
30
|
+
if (!this.headersSent) this.setHeader('server-timing', header(timing))
|
|
31
|
+
return writeHead.apply(this, args)
|
|
32
|
+
} as ServerResponse['writeHead']
|
|
33
|
+
}
|
|
34
|
+
return requests.run(timing, work)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// `work`, its time counted as `kind`'s where a request is timed.
|
|
38
|
+
export async function timed<T>(kind: 'db' | 'conn' | 'gh', work: () => Promise<T>): Promise<T> {
|
|
39
|
+
const timing = requests.getStore()
|
|
40
|
+
if (!timing) return work()
|
|
41
|
+
const start = performance.now()
|
|
42
|
+
try { return await work() } finally {
|
|
43
|
+
timing[kind]++
|
|
44
|
+
timing[`${kind}Ms`] += performance.now() - start
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A statement sent to the database, where a request is timed.
|
|
49
|
+
export function countStatement(): void {
|
|
50
|
+
const timing = requests.getStore()
|
|
51
|
+
if (timing) timing.statements++
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function header(timing: Timing): string {
|
|
55
|
+
const ms = (value: number) => value.toFixed(1)
|
|
56
|
+
return [
|
|
57
|
+
...timing.db > 0 || timing.statements > 0 ? [`db;dur=${ms(timing.dbMs)};desc="${timing.statements} statements"`] : [],
|
|
58
|
+
...timing.conn > 0 ? [`conn;dur=${ms(timing.connMs)}`] : [],
|
|
59
|
+
...timing.gh > 0 ? [`gh;dur=${ms(timing.ghMs)};desc="${timing.gh} calls"`] : [],
|
|
60
|
+
...timing.cold ? ['cold;desc="first request of this instance"'] : [],
|
|
61
|
+
`app;dur=${ms(performance.now() - timing.started)}`,
|
|
62
|
+
].join(', ')
|
|
63
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// The reviewed dependencies a host keeps beyond the app, as it registers
|
|
2
|
+
// them before the server is assembled (server.ts registerReviewedDependencies):
|
|
3
|
+
// an async method telling whether a dependency, as a lockfile locks it, is
|
|
4
|
+
// reviewed there (`lookup`), and, optionally, one reading the reviews of
|
|
5
|
+
// dependencies an open pull request's comments make (`parseComments`,
|
|
6
|
+
// pull-comment-reviews.ts), and one formatting one's own dependency reviews
|
|
7
|
+
// as a comment, for a copy button to give (`formatDependencyReviews`,
|
|
8
|
+
// dependency-review-comments.ts). Registered, they replace the commit a repository
|
|
9
|
+
// is reviewed since (pull-dependency-reviews.ts): a version passes by the
|
|
10
|
+
// reviews its teams publish, and those its pull request's comments make, as
|
|
11
|
+
// many as the repository requires, or by `lookup`'s saying so, and is
|
|
12
|
+
// otherwise unreviewed. A patched version's (pnpm's patchedDependencies)
|
|
13
|
+
// reviews are of its patch, its teams' and its comments' alike. Those
|
|
14
|
+
// comments' reviews count for the pull request alone, as it is open: the
|
|
15
|
+
// host's to keep, as it is merged, among those `lookup` tells of.
|
|
16
|
+
//
|
|
17
|
+
// Each version is asked of `lookup` once a request, as many at once as
|
|
18
|
+
// ASK_AT_ONCE, its answer kept in this instance KEPT_MS; one it fails to
|
|
19
|
+
// answer is unreviewed, and counted so (`unasked`).
|
|
20
|
+
import { type Lru, lru } from './lru.ts'
|
|
21
|
+
|
|
22
|
+
export type ReviewedLockfile = 'npm' | 'pnpm' | 'yarn1'
|
|
23
|
+
// A dependency as a lockfile locks it: npm's always with its integrity (one
|
|
24
|
+
// with none is never reviewed, nor asked of), its shasum where the lockfile
|
|
25
|
+
// has one (Yarn 1's, after a tarball's `#`), and the hash of its patch where
|
|
26
|
+
// it is patched (pnpm's).
|
|
27
|
+
export interface ReviewedDependency {
|
|
28
|
+
ecosystem: 'npm'
|
|
29
|
+
lockfile: ReviewedLockfile
|
|
30
|
+
name: string
|
|
31
|
+
version: string
|
|
32
|
+
integrity?: string
|
|
33
|
+
shasum?: string
|
|
34
|
+
patchHash?: string
|
|
35
|
+
}
|
|
36
|
+
export type ReviewVerdict = 'accept' | 'accept-with-nits' | 'request-changes' | 'reject'
|
|
37
|
+
// A comment of an open pull request's, as `parseComments` is given it: one
|
|
38
|
+
// on the pull request (`comment`), a review's own (`review`, whatever its
|
|
39
|
+
// state, a comment's alone too), or one on a line of its diff
|
|
40
|
+
// (`review-comment`); by its author's GitHub login; when it was written, and
|
|
41
|
+
// last edited (`editedAt`) where it was.
|
|
42
|
+
export interface ReviewComment {
|
|
43
|
+
kind: 'comment' | 'review' | 'review-comment'
|
|
44
|
+
id: string
|
|
45
|
+
author: string | null
|
|
46
|
+
body: string
|
|
47
|
+
state?: string
|
|
48
|
+
createdAt: string
|
|
49
|
+
editedAt?: string
|
|
50
|
+
url: string
|
|
51
|
+
}
|
|
52
|
+
// What `parseComments` reads: the pull request, its comments, and the
|
|
53
|
+
// dependencies: every one the lockfiles its review holds to add that can be
|
|
54
|
+
// reviewed and `lookup` doesn't pass, and the others still wanting reviews.
|
|
55
|
+
export interface ParseCommentsInput { repository: string; number: number; comments: ReviewComment[]; dependencies: ReviewedDependency[] }
|
|
56
|
+
// A review its comments make, by `user` (a GitHub login, one of their
|
|
57
|
+
// authors'), of each of `dependencies`, with its verdict, as of `time` (when
|
|
58
|
+
// it was made, an ISO date or milliseconds; that user's latest comment's
|
|
59
|
+
// where none is told).
|
|
60
|
+
export interface CommentReview {
|
|
61
|
+
user: string
|
|
62
|
+
time?: string | number
|
|
63
|
+
dependencies: { ecosystem?: 'npm'; name: string; version: string; integrity: string; patchHash?: string; status: ReviewVerdict }[]
|
|
64
|
+
}
|
|
65
|
+
// A version a dependency review is of, or compared it with: its package's
|
|
66
|
+
// name, its version, the integrity and, where npm tells one, the shasum of
|
|
67
|
+
// its tarball, each held to the tarball reviewed, and its patch's hash where
|
|
68
|
+
// it is known (pnpm's patched dependencies').
|
|
69
|
+
export interface ReviewedVersion { name: string; version: string; integrity: string; shasum?: string; patchHash?: string }
|
|
70
|
+
// One of one's own published dependency reviews, as `formatDependencyReviews`
|
|
71
|
+
// is given it: the version it is of (`new`) and the one it compared it with
|
|
72
|
+
// (`old`, none where it read its package whole); its verdict (`status`);
|
|
73
|
+
// whether it read every file (`coverage`, `full`, else `partial`); its final
|
|
74
|
+
// comment; each file's verdict and comment (`files`, of those with either);
|
|
75
|
+
// by whom (`reviewer`, their GitHub login), and when it was published.
|
|
76
|
+
export interface DependencyReviewEntry {
|
|
77
|
+
ecosystem: 'npm'
|
|
78
|
+
old: ReviewedVersion | null
|
|
79
|
+
new: ReviewedVersion
|
|
80
|
+
status: ReviewVerdict
|
|
81
|
+
coverage: 'full' | 'partial'
|
|
82
|
+
comment: string
|
|
83
|
+
files: { path: string; status: ReviewVerdict | null; comment: string }[]
|
|
84
|
+
reviewer: string
|
|
85
|
+
publishedAt: number
|
|
86
|
+
}
|
|
87
|
+
// What `formatDependencyReviews` formats: one's own reviews, never another's,
|
|
88
|
+
// and the pull request whose versions they are of, where its copy asks.
|
|
89
|
+
export interface FormatDependencyReviewsInput { reviews: DependencyReviewEntry[]; pullRequest?: { repository: string; number: number } }
|
|
90
|
+
export interface ReviewedDependencies {
|
|
91
|
+
lookup(dependency: ReviewedDependency): Promise<boolean>
|
|
92
|
+
parseComments?(input: ParseCommentsInput): Promise<CommentReview[]>
|
|
93
|
+
// The comment to copy, none where it is empty, null or undefined.
|
|
94
|
+
formatDependencyReviews?(input: FormatDependencyReviewsInput): Promise<string | null | undefined>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ASK_AT_ONCE = 16
|
|
98
|
+
const KEPT_MS = 60_000
|
|
99
|
+
|
|
100
|
+
let registered: ReviewedDependencies | null = null
|
|
101
|
+
let assembled = false
|
|
102
|
+
// Each lookup's answers, apart.
|
|
103
|
+
const answers = new WeakMap<ReviewedDependencies['lookup'], Lru<{ at: number; reviewed: boolean }>>()
|
|
104
|
+
|
|
105
|
+
// Registers the host's reviewed dependencies, once, before the server is
|
|
106
|
+
// assembled (init()): a server already assembled keeps what it was given.
|
|
107
|
+
export function registerReviewedDependencies(reviewed: ReviewedDependencies): void {
|
|
108
|
+
const optional = (method: unknown) => method === undefined || typeof method === 'function'
|
|
109
|
+
if (typeof reviewed?.lookup !== 'function' || !optional(reviewed.parseComments) || !optional(reviewed.formatDependencyReviews)) {
|
|
110
|
+
throw new TypeError('registerReviewedDependencies: expected { lookup, parseComments?, formatDependencyReviews? }, async methods')
|
|
111
|
+
}
|
|
112
|
+
if (assembled) throw new Error('registerReviewedDependencies: call it before init(), as the server takes them as it is assembled')
|
|
113
|
+
if (registered) throw new Error('registerReviewedDependencies: reviewed dependencies are registered already')
|
|
114
|
+
registered = { lookup: reviewed.lookup.bind(reviewed), ...reviewed.parseComments ? { parseComments: reviewed.parseComments.bind(reviewed) } : {},
|
|
115
|
+
...reviewed.formatDependencyReviews ? { formatDependencyReviews: reviewed.formatDependencyReviews.bind(reviewed) } : {} }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The reviewed dependencies registered, as a server takes them once it is
|
|
119
|
+
// assembled; none registers after.
|
|
120
|
+
export function assembledReviewedDependencies(): ReviewedDependencies | null {
|
|
121
|
+
assembled = true
|
|
122
|
+
return registered
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Which of `dependencies`, by their keys, `lookup` passes, and how many it
|
|
126
|
+
// failed to answer.
|
|
127
|
+
export async function askReviewed(lookup: ReviewedDependencies['lookup'], dependencies: Map<string, ReviewedDependency>): Promise<{ passed: Set<string>; unasked: number }> {
|
|
128
|
+
const passed = new Set<string>()
|
|
129
|
+
let unasked = 0
|
|
130
|
+
const pending = [...dependencies]
|
|
131
|
+
const kept = answers.get(lookup) ?? answers.set(lookup, lru(100_000)).get(lookup)!
|
|
132
|
+
await Promise.all(Array.from({ length: Math.min(ASK_AT_ONCE, pending.length) }, async () => {
|
|
133
|
+
for (let next = pending.pop(); next; next = pending.pop()) {
|
|
134
|
+
const [key, dependency] = next
|
|
135
|
+
const known = kept.get(key)
|
|
136
|
+
let answer = known && Date.now() - known.at < KEPT_MS ? known.reviewed : null
|
|
137
|
+
if (answer === null) {
|
|
138
|
+
try { answer = await lookup({ ...dependency }) === true } catch (error) {
|
|
139
|
+
unasked++
|
|
140
|
+
console.warn('reviewed dependencies: failed to answer', `${dependency.name}@${dependency.version}`, error instanceof Error ? error.message : error)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
kept.set(key, { at: Date.now(), reviewed: answer })
|
|
144
|
+
}
|
|
145
|
+
if (answer) passed.add(key)
|
|
146
|
+
}
|
|
147
|
+
}))
|
|
148
|
+
return { passed, unasked }
|
|
149
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Internal invalidation counters. Never expose these global values to clients:
|
|
2
|
+
// feeds still publish only the existing user/visibility-scoped revisions.
|
|
3
|
+
const catalogTables = ['managed_link_report', 'managed_team', 'managed_team_user', 'managed_team_repo', 'managed_report', 'managed_bundle', 'managed_selected_repo']
|
|
4
|
+
const annotationTables = ['managed_finding_triage', 'managed_finding_triage_event', 'managed_finding_comment', 'managed_finding_issue']
|
|
5
|
+
export function revisionSchema(postgres = false): string {
|
|
6
|
+
let sql = `CREATE TABLE IF NOT EXISTS managed_maintenance_lease (
|
|
7
|
+
id INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL
|
|
8
|
+
); CREATE TABLE IF NOT EXISTS managed_change_revision (
|
|
9
|
+
id INTEGER PRIMARY KEY, catalog INTEGER NOT NULL, annotations INTEGER NOT NULL
|
|
10
|
+
); INSERT INTO managed_change_revision VALUES (1, 0, 0) ON CONFLICT(id) DO NOTHING;`
|
|
11
|
+
for (const [column, tables] of [['catalog', catalogTables], ['annotations', annotationTables]] as const) {
|
|
12
|
+
const update = `UPDATE managed_change_revision SET ${column} = ${column} + 1 WHERE id = 1;`
|
|
13
|
+
if (postgres) {
|
|
14
|
+
sql += `CREATE OR REPLACE FUNCTION managed_${column}_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
15
|
+
BEGIN IF EXISTS (SELECT 1 FROM managed_revision_rows) THEN ${update} END IF; RETURN NULL; END $$;`
|
|
16
|
+
}
|
|
17
|
+
for (const table of tables) {
|
|
18
|
+
if (postgres) {
|
|
19
|
+
sql += `DROP TRIGGER IF EXISTS ${table}_revision ON ${table};`
|
|
20
|
+
for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
21
|
+
sql += `DROP TRIGGER IF EXISTS ${table}_revision_${event} ON ${table};
|
|
22
|
+
CREATE TRIGGER ${table}_revision_${event} AFTER ${event} ON ${table}
|
|
23
|
+
REFERENCING ${event === 'DELETE' ? 'OLD' : 'NEW'} TABLE AS managed_revision_rows
|
|
24
|
+
FOR EACH STATEMENT EXECUTE FUNCTION managed_${column}_changed();`
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
28
|
+
sql += `CREATE TRIGGER IF NOT EXISTS ${table}_revision_${event}
|
|
29
|
+
AFTER ${event} ON ${table} BEGIN ${update} END;`
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return sql
|
|
35
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A scan's packages' downloads, as risk asks them, readied before the
|
|
2
|
+
// packages are assessed (repository-risk.ts), so that no assessment waits on
|
|
3
|
+
// npm's downloads API, which answers some 40 lookups a minute: a package is
|
|
4
|
+
// given to the assessors once its downloads are kept (npm-risk.ts
|
|
5
|
+
// npmReadyScanDownloads), those that can be readied at once (kept already,
|
|
6
|
+
// and unscoped ones, asked in bulk) first, each lockfile's as it is read, and
|
|
7
|
+
// the rest (scoped ones, which npm answers only one by one) one at a time,
|
|
8
|
+
// at the pace npm allows, while the assessors go on with the others.
|
|
9
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
10
|
+
import { type Queue, queue } from './queue.ts'
|
|
11
|
+
|
|
12
|
+
export interface DownloadsSource {
|
|
13
|
+
// Readies those of `names` that can be at once, `ready` told each.
|
|
14
|
+
atOnce(names: string[], ready: (name: string) => void, signal: AbortSignal): Promise<void>
|
|
15
|
+
// Readies one on its own.
|
|
16
|
+
one(name: string, signal: AbortSignal): Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// How npm asking to be asked later is told (`busy`), and waited on: asked
|
|
20
|
+
// again after a wait, up to `retries` times running.
|
|
21
|
+
export interface DownloadsPacing { busy(error: unknown): boolean; retries: number; waitMs: number }
|
|
22
|
+
|
|
23
|
+
export interface ReadyingDownloads<T> {
|
|
24
|
+
// Packages to give once their downloads are ready.
|
|
25
|
+
add(items: T[]): void
|
|
26
|
+
// Once all added are given, or the scan stops.
|
|
27
|
+
close(): Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Readies the downloads of the packages added, each given (`give`) once its
|
|
31
|
+
// name's are, or once readying them fails otherwise than npm asking to wait,
|
|
32
|
+
// or npm keeps asking past `retries`: its assessment then asks its own.
|
|
33
|
+
export function readyingDownloads<T extends { name: string }>(source: DownloadsSource, signal: AbortSignal, give: (item: T) => void,
|
|
34
|
+
{ busy, retries, waitMs }: DownloadsPacing): ReadyingDownloads<T> {
|
|
35
|
+
const waiting = new Map<string, T[]>()
|
|
36
|
+
const atOnce: Queue<string[]> = queue(signal), oneByOne: Queue<string> = queue(signal)
|
|
37
|
+
const release = (name: string) => { for (const item of waiting.get(name) ?? []) give(item); waiting.delete(name) }
|
|
38
|
+
const wait = async (attempt: number) => { try { await delay(waitMs * Math.min(attempt + 1, 5), undefined, { signal }); return true } catch { return false } }
|
|
39
|
+
|
|
40
|
+
// A lockfile's names, those that can be readied at once, again after a wait
|
|
41
|
+
// where npm asks: false once the scan stops.
|
|
42
|
+
const readyNow = async (names: string[]) => {
|
|
43
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
44
|
+
const left = names.filter(name => waiting.has(name))
|
|
45
|
+
try { await source.atOnce(left, release, signal); return true } catch (error) {
|
|
46
|
+
if (signal.aborted) return false
|
|
47
|
+
if (!busy(error)) { for (const name of left) release(name); return true }
|
|
48
|
+
if (!await wait(attempt)) return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
// Each lockfile's, the rest left to oneByOne.
|
|
54
|
+
const readyAtOnce = async () => {
|
|
55
|
+
for (let names = await atOnce.take(); names !== undefined; names = await atOnce.take()) {
|
|
56
|
+
if (!await readyNow(names)) return
|
|
57
|
+
for (const name of names) if (waiting.has(name)) oneByOne.add(name)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const readyOneByOne = async () => {
|
|
61
|
+
for (let name = await oneByOne.take(); name !== undefined; name = await oneByOne.take()) {
|
|
62
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
63
|
+
try { await source.one(name, signal); break } catch (error) {
|
|
64
|
+
if (signal.aborted) return
|
|
65
|
+
if (!busy(error) || !await wait(attempt)) break
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
release(name)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const lanes = [readyAtOnce().finally(() => { oneByOne.close() }), readyOneByOne()]
|
|
72
|
+
return {
|
|
73
|
+
add(items) {
|
|
74
|
+
const names: string[] = []
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
const kept = waiting.get(item.name)
|
|
77
|
+
if (kept) kept.push(item)
|
|
78
|
+
else { waiting.set(item.name, [item]); names.push(item.name) }
|
|
79
|
+
}
|
|
80
|
+
if (names.length > 0) atOnce.add(names)
|
|
81
|
+
},
|
|
82
|
+
async close() {
|
|
83
|
+
atOnce.close()
|
|
84
|
+
await Promise.all(lanes)
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Session + cookie plumbing for the managed auth server. The raw session
|
|
2
|
+
// token lives ONLY in the cookie; the DB row id is its SHA-256 (see crypto.ts)
|
|
3
|
+
// so a DB read can't reconstruct a live cookie. A double-submit CSRF token is
|
|
4
|
+
// minted per session and returned to the client (echoed back on mutations).
|
|
5
|
+
//
|
|
6
|
+
// Async throughout — the store (db.ts) is async so a future non-SQLite
|
|
7
|
+
// backend slots in without touching these helpers.
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
9
|
+
import type { ManagedConfig } from './config.ts'
|
|
10
|
+
import type { ManagedDb, ManagedSession, ManagedUser, StoredUser } from './db.ts'
|
|
11
|
+
import { hashToken, randomToken } from './crypto.ts'
|
|
12
|
+
|
|
13
|
+
// Base name for the short-lived OAuth-state cookie; `__Host-` is prefixed via
|
|
14
|
+
// `cookieName` when cookies are Secure.
|
|
15
|
+
export const STATE_COOKIE = 'dvstate'
|
|
16
|
+
|
|
17
|
+
// Base names for GitHub issue authorization state (github-issue-oauth.ts),
|
|
18
|
+
// linking another GitHub account (github-link-oauth.ts) and an
|
|
19
|
+
// administrator's read-only view as another user (view-as.ts).
|
|
20
|
+
export const ISSUE_STATE_COOKIE = 'dvissuestate'
|
|
21
|
+
export const LINK_STATE_COOKIE = 'dvlinkstate'
|
|
22
|
+
export const VIEW_COOKIE = 'dvview'
|
|
23
|
+
// The session cookie cannot share a name with these, or one would overwrite
|
|
24
|
+
// or be read as the other.
|
|
25
|
+
export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, LINK_STATE_COOKIE, VIEW_COOKIE]
|
|
26
|
+
|
|
27
|
+
// `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
|
|
28
|
+
// Secure + Path=/ + no Domain, which `buildCookie` already emits).
|
|
29
|
+
export function cookieName(config: ManagedConfig, base: string): string {
|
|
30
|
+
return config.cookieSecure ? `__Host-${base}` : base
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildCookie(
|
|
34
|
+
name: string, value: string,
|
|
35
|
+
opts: { maxAgeS: number; secure: boolean; sameSite?: 'Lax' | 'Strict' },
|
|
36
|
+
): string {
|
|
37
|
+
const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', `SameSite=${opts.sameSite ?? 'Lax'}`, `Max-Age=${opts.maxAgeS}`]
|
|
38
|
+
if (opts.secure) parts.push('Secure')
|
|
39
|
+
return parts.join('; ')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Expire a cookie now (Max-Age=0).
|
|
43
|
+
export function clearCookie(name: string, secure: boolean): string {
|
|
44
|
+
return buildCookie(name, '', { maxAgeS: 0, secure })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseCookies(header: string | undefined): Map<string, string> {
|
|
48
|
+
const out = new Map<string, string>()
|
|
49
|
+
if (!header) return out
|
|
50
|
+
for (const part of header.split(';')) {
|
|
51
|
+
const eq = part.indexOf('=')
|
|
52
|
+
if (eq < 0) continue
|
|
53
|
+
const k = part.slice(0, eq).trim()
|
|
54
|
+
if (k) out.set(k, part.slice(eq + 1).trim())
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Mint a session for a freshly-authenticated user. Upserts the identity, then
|
|
60
|
+
// stores the session keyed by the cookie token's hash. Returns the cookie to
|
|
61
|
+
// set and the CSRF token (also persisted on the row), or null for an account
|
|
62
|
+
// a user linked to theirs, which can neither sign in nor register.
|
|
63
|
+
export async function createSession(
|
|
64
|
+
config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
|
|
65
|
+
): Promise<{ setCookie: string; csrfToken: string; userId: string } | null> {
|
|
66
|
+
const token = randomToken()
|
|
67
|
+
const csrfToken = randomToken()
|
|
68
|
+
const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
|
|
69
|
+
if (userId == null) return null
|
|
70
|
+
await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
|
|
71
|
+
const setCookie = buildCookie(config.sessionCookieName, token, {
|
|
72
|
+
maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
|
|
73
|
+
})
|
|
74
|
+
return { setCookie, csrfToken, userId }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A request's sign-in, whose presence its first read to find it records.
|
|
78
|
+
// Every read after it in that request (the rechecks after its reads of
|
|
79
|
+
// storage, GitHub or npm among them) only reads: last_seen_at was written a
|
|
80
|
+
// moment before, by the read that let the request in. What the router's
|
|
81
|
+
// gate read is handed to the read after it that says nothing was read
|
|
82
|
+
// between them (`handed`), asking nothing again; once only, and to none
|
|
83
|
+
// once another read came first.
|
|
84
|
+
type SessionRead = { session: ManagedSession; user: StoredUser }
|
|
85
|
+
const requests = new AsyncLocalStorage<{ seen: boolean; handed: { cookieHeader: string | undefined; read: SessionRead } | null }>()
|
|
86
|
+
export function runRequestPresence<T>(work: () => T): T {
|
|
87
|
+
return requests.run({ seen: false, handed: null }, work)
|
|
88
|
+
}
|
|
89
|
+
export function handOverSession(cookieHeader: string | undefined, read: SessionRead): void {
|
|
90
|
+
const request = requests.getStore()
|
|
91
|
+
if (request) request.handed = { cookieHeader, read }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Resolve the current session (+ user) from the request's cookies, or null.
|
|
95
|
+
// A view cookie resolves only to that admin's view as another user: a view
|
|
96
|
+
// that has ended never falls back to the admin's own session. `own` skips the
|
|
97
|
+
// view and reads the signed-in session itself. `handed` takes the gate's
|
|
98
|
+
// read, where it is this read's first since (handOverSession).
|
|
99
|
+
export async function readSession(
|
|
100
|
+
config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number, { own = false, handed = false } = {},
|
|
101
|
+
): Promise<SessionRead | null> {
|
|
102
|
+
const request = requests.getStore()
|
|
103
|
+
const given = request?.handed
|
|
104
|
+
if (request) request.handed = null
|
|
105
|
+
if (handed && !own && given && given.cookieHeader === cookieHeader) return given.read
|
|
106
|
+
const ids = sessionIds(config, cookieHeader, { own })
|
|
107
|
+
if (!ids) return null
|
|
108
|
+
const seen = request?.seen === true
|
|
109
|
+
const s = ids.viewer === null
|
|
110
|
+
? await (seen ? db.getSessionWithUser : db.sessionWithUser)(ids.id, now)
|
|
111
|
+
: await (seen ? db.getViewSessionWithUser : db.viewSessionWithUser)(ids.id, ids.viewer, now)
|
|
112
|
+
if (s && request) request.seen = true
|
|
113
|
+
return s
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The sessions the request's cookies name, as readSession reads them: the
|
|
117
|
+
// one acting (`id`), a view where an admin views as another user, with the
|
|
118
|
+
// admin's own it is a view of (`viewer`); none signed out.
|
|
119
|
+
export function sessionIds(config: ManagedConfig, cookieHeader: string | undefined, { own = false } = {}): { id: string; viewer: string | null } | null {
|
|
120
|
+
const cookies = parseCookies(cookieHeader)
|
|
121
|
+
const token = cookies.get(config.sessionCookieName)
|
|
122
|
+
if (token == null || token === '') return null
|
|
123
|
+
const view = own ? undefined : cookies.get(cookieName(config, VIEW_COOKIE))
|
|
124
|
+
return view != null && view !== '' ? { id: hashToken(view), viewer: hashToken(token) } : { id: hashToken(token), viewer: null }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Drop the session named by the request's cookie (logout). The caller also
|
|
128
|
+
// clears the cookie client-side.
|
|
129
|
+
export async function endSession(config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined): Promise<void> {
|
|
130
|
+
const token = parseCookies(cookieHeader).get(config.sessionCookieName)
|
|
131
|
+
if (token != null && token !== '') await db.deleteSession(hashToken(token))
|
|
132
|
+
}
|