@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,328 @@
|
|
|
1
|
+
// What an open pull request's lockfiles' versions want of review, in a
|
|
2
|
+
// repository that reviews dependencies (repository-dependency-reviews.ts), as
|
|
3
|
+
// its tile and popup show it: each version it adds, whether it passes, by as
|
|
4
|
+
// many reviewers' reviews as the repository requires (`reviews`) or by the
|
|
5
|
+
// reviewed dependencies the host registers (`reviewed`,
|
|
6
|
+
// reviewed-dependencies.ts), or needs none, being among those the
|
|
7
|
+
// repository's lockfiles locked at the commit it is reviewed since
|
|
8
|
+
// (`baseline`); apart from them, the other versions its lockfiles lock that
|
|
9
|
+
// neither passes nor needs none (`unreviewed`); and those that can't be
|
|
10
|
+
// reviewed (`unsupported`), which fail its review, as a lockfile it changes
|
|
11
|
+
// that isn't read does. Without that commit, every version wants its
|
|
12
|
+
// reviews; registered reviewed dependencies replace it.
|
|
13
|
+
//
|
|
14
|
+
// A version is reviewed as npm's registry has it, by its name, version and
|
|
15
|
+
// integrity: one locked of npm's registry, by its name or its link to its
|
|
16
|
+
// tarball (locked-packages.ts), with an integrity. One from git, another
|
|
17
|
+
// tarball (a mirror's too), or with no integrity can't be; a file of the
|
|
18
|
+
// project's isn't. Its reviews are those of it, of an integrity it is locked
|
|
19
|
+
// with, either whole, or compared with a version of its package: for a
|
|
20
|
+
// version the pull request adds, one it was; for another, any. A patched one
|
|
21
|
+
// (`patched`) passes by none of them, the app reviewing no patch yet: only
|
|
22
|
+
// by the reviewed dependencies, or locked so, its patch too, at that commit.
|
|
23
|
+
// Which versions the repository's lockfiles locked at that commit never
|
|
24
|
+
// changes: it is read once, every lockfile there of those read here
|
|
25
|
+
// (pull-dependencies.ts), and kept by the commit (`baselines` in this
|
|
26
|
+
// instance, `kept` beyond it), never stored as reviews.
|
|
27
|
+
import { lockedAt, lockedIn } from './commit-lockfiles.ts'
|
|
28
|
+
import { GithubApiError } from './github-app.ts'
|
|
29
|
+
import type { LockedPackage } from './locked-packages.ts'
|
|
30
|
+
import { EVERY_LOCKFILE, type LockfileScope, heldTo, isEvery, reviewedIn } from './lockfile-scope.ts'
|
|
31
|
+
import { type Lru, lru } from './lru.ts'
|
|
32
|
+
import { type DirectoryDependencies, type GithubAsked, type LockfileStore, MAX_ADDED, type PullDependencies, lockedAs, lockedWith } from './pull-dependencies.ts'
|
|
33
|
+
import type { PublishedReview, RepoReviewState } from './repository-dependency-reviews.ts'
|
|
34
|
+
import { type ReviewedDependencies, type ReviewedDependency, askReviewed } from './reviewed-dependencies.ts'
|
|
35
|
+
|
|
36
|
+
// The other versions told unreviewed, and those told unsupported, of each
|
|
37
|
+
// directory, those past them counted.
|
|
38
|
+
export const MAX_UNREVIEWED = 200
|
|
39
|
+
|
|
40
|
+
// The versions a commit's lockfiles lock, and the lockfiles there that
|
|
41
|
+
// couldn't be read (`unread`), whose versions want reviews all the same.
|
|
42
|
+
export interface Baseline { versions: Set<string>; unread: string[] }
|
|
43
|
+
export interface ReviewBaselineStore {
|
|
44
|
+
read(key: string): Promise<{ versions: string[]; unread: string[] } | null>
|
|
45
|
+
write(key: string, value: { versions: string[]; unread: string[] }): Promise<void>
|
|
46
|
+
}
|
|
47
|
+
const baselines: Lru<Baseline> = lru(500_000, baseline => baseline.versions.size + baseline.unread.length + 1)
|
|
48
|
+
|
|
49
|
+
// Why a version can't be reviewed: from git, of a tarball not npm's
|
|
50
|
+
// registry's of its version, or locked with no integrity.
|
|
51
|
+
export type Unsupported = 'git' | 'tarball' | 'integrity'
|
|
52
|
+
// How a version is reviewed: by its name, version and integrity
|
|
53
|
+
// (`version`), not at all (`skip`), or why it can't be.
|
|
54
|
+
function reviewedAs(pkg: LockedPackage): 'version' | 'skip' | Unsupported {
|
|
55
|
+
if (pkg.source === 'file') return 'skip'
|
|
56
|
+
if (pkg.source === 'git') return 'git'
|
|
57
|
+
if (pkg.source !== 'registry' || pkg.version === null) return 'tarball'
|
|
58
|
+
return pkg.integrity ? 'version' : 'integrity'
|
|
59
|
+
}
|
|
60
|
+
// The hashes an integrity names, each of which is npm's of it.
|
|
61
|
+
const hashesOf = (pkg: LockedPackage) => (pkg.integrity ?? '').split(/\s+/u).filter(Boolean)
|
|
62
|
+
// A version as a baseline holds it: its name and version with each hash,
|
|
63
|
+
// and its patch.
|
|
64
|
+
const baselineKeys = (pkg: LockedPackage) => hashesOf(pkg).map(hash => `${pkg.name}@${pkg.version ?? ''}\0${hash}\0${pkg.patchHash ?? ''}`)
|
|
65
|
+
|
|
66
|
+
// The versions the repository's lockfiles locked at commit `since` that are
|
|
67
|
+
// reviewed (reviewedAs), as kept, else read of GitHub (commit-lockfiles.ts,
|
|
68
|
+
// its lockfiles as `reads` keeps them) and kept; with the lockfiles there its
|
|
69
|
+
// reader refuses, isn't one of, or past those read (`unread`). GitHub having
|
|
70
|
+
// none of a lockfile it lists there is a failure of the whole, as of any
|
|
71
|
+
// file, never kept.
|
|
72
|
+
export async function reviewBaseline(fullName: string, token: string, repoId: number, since: string, kept: ReviewBaselineStore, reads: LockfileStore | null = null,
|
|
73
|
+
asked?: GithubAsked): Promise<Baseline> {
|
|
74
|
+
const key = `${repoId}\0${since}`
|
|
75
|
+
const known = baselines.get(key)
|
|
76
|
+
if (known) return known
|
|
77
|
+
const stored = await kept.read(key)
|
|
78
|
+
if (stored) { const baseline = { versions: new Set(stored.versions), unread: stored.unread }; baselines.set(key, baseline); return baseline }
|
|
79
|
+
const read = await lockedAt(fullName, token, since, reads, () => true, asked)
|
|
80
|
+
const { packages } = lockedIn(read), { unread } = read
|
|
81
|
+
const versions = new Set<string>()
|
|
82
|
+
for (const pkg of packages) if (reviewedAs(pkg) === 'version') for (const held of baselineKeys(pkg)) versions.add(held)
|
|
83
|
+
const baseline = { versions, unread }
|
|
84
|
+
baselines.set(key, baseline)
|
|
85
|
+
await kept.write(key, { versions: [...versions], unread: baseline.unread })
|
|
86
|
+
return baseline
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Passing reviews by the version they are of, its name and version.
|
|
90
|
+
type ReviewsOf = Map<string, PublishedReview[]>
|
|
91
|
+
function reviewsByVersion(reviews: PublishedReview[]): ReviewsOf {
|
|
92
|
+
const by: ReviewsOf = new Map()
|
|
93
|
+
for (const review of reviews) {
|
|
94
|
+
const key = `${review.package}@${review.version}`
|
|
95
|
+
const held = by.get(key)
|
|
96
|
+
if (held) held.push(review)
|
|
97
|
+
else by.set(key, [review])
|
|
98
|
+
}
|
|
99
|
+
return by
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ACCEPTING = new Set(['accept', 'accept-with-nits'])
|
|
103
|
+
// Where `pkg`'s reviewers stand on it among `reviews`, those of its version,
|
|
104
|
+
// of an integrity it is locked with, and of its patch: each one's latest
|
|
105
|
+
// telling it, any rejecting it holding it unreviewed (`rejected`); those
|
|
106
|
+
// accepting it passing it (`passing`), each once, by a full, accepting
|
|
107
|
+
// review of theirs, whole or compared with one of `bases` of its package
|
|
108
|
+
// (any, for `any`); and how many review it in its pull request's comments
|
|
109
|
+
// alone (`commented`), which count for it alone: whose reviews passing it,
|
|
110
|
+
// else whose latest, are its comments'. Each reviewer too (`reviewers`), by
|
|
111
|
+
// their login, their latest verdict, whether they pass it, and whether that
|
|
112
|
+
// verdict is its comments' (`comment`), which counts for it alone.
|
|
113
|
+
function stancesOf(pkg: LockedPackage, reviews: ReviewsOf, bases: string[] | 'any') {
|
|
114
|
+
const hashes = hashesOf(pkg)
|
|
115
|
+
const by = new Map<string, PublishedReview[]>()
|
|
116
|
+
for (const review of reviews.get(`${pkg.name}@${pkg.version}`) ?? []) {
|
|
117
|
+
if (!hashes.includes(review.integrity) || (review.patchHash ?? '') !== (pkg.patchHash ?? '')) continue
|
|
118
|
+
by.set(review.reviewer, [...by.get(review.reviewer) ?? [], review])
|
|
119
|
+
}
|
|
120
|
+
const qualifies = (review: PublishedReview) => review.full && ACCEPTING.has(review.status)
|
|
121
|
+
&& (review.basePackage === null || (review.basePackage === pkg.name && (bases === 'any' || bases.includes(review.baseVersion ?? ''))))
|
|
122
|
+
let commented = 0, passing = 0, rejected = false
|
|
123
|
+
const reviewers: Reviewer[] = []
|
|
124
|
+
for (const theirs of by.values()) {
|
|
125
|
+
const latest = theirs.reduce((one, other) => other.time > one.time ? other : one)
|
|
126
|
+
const passes = ACCEPTING.has(latest.status) && theirs.some(qualifies)
|
|
127
|
+
// In its comments alone where those telling where they stand are: those
|
|
128
|
+
// passing it, where they pass it, else their latest.
|
|
129
|
+
const comment = (passes ? theirs.filter(qualifies) : [latest]).every(review => review.comment)
|
|
130
|
+
if (comment) commented++
|
|
131
|
+
if (latest.status === 'reject') rejected = true
|
|
132
|
+
else if (passes) passing++
|
|
133
|
+
reviewers.push({ login: latest.login, status: latest.status, ...passes ? { passes: true as const } : {}, ...latest.comment ? { comment: true as const } : {} })
|
|
134
|
+
}
|
|
135
|
+
return { passing, rejected, commented, reviewers }
|
|
136
|
+
}
|
|
137
|
+
type Reviewer = { login: string; status: PublishedReview['status']; passes?: true; comment?: true }
|
|
138
|
+
|
|
139
|
+
// A version as the client is told it: what its reviews are held to left
|
|
140
|
+
// out, but whether it is patched.
|
|
141
|
+
function shown<T extends LockedPackage>(pkg: T): Omit<T, 'integrity' | 'shasum' | 'patchHash' | 'lockfile'> & { patched?: true } {
|
|
142
|
+
const copy = { ...pkg }
|
|
143
|
+
for (const field of ['integrity', 'shasum', 'patchHash', 'lockfile'] as const) delete copy[field]
|
|
144
|
+
return { ...copy, ...pkg.patchHash ? { patched: true as const } : {} }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A version as the registered reviewed dependencies are asked of it, by
|
|
148
|
+
// what it is asked.
|
|
149
|
+
function askedOf(pkg: LockedPackage): [string, ReviewedDependency] {
|
|
150
|
+
const dependency: ReviewedDependency = { ecosystem: 'npm', lockfile: pkg.lockfile ?? 'npm', name: pkg.name, version: pkg.version ?? '', integrity: pkg.integrity ?? '',
|
|
151
|
+
...pkg.shasum ? { shasum: pkg.shasum } : {}, ...pkg.patchHash ? { patchHash: pkg.patchHash } : {} }
|
|
152
|
+
return [JSON.stringify(dependency), dependency]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A version's review, and whether it wants one (`wants`), neither passing
|
|
156
|
+
// nor needing none, as only one that can be reviewed may.
|
|
157
|
+
type Review = { unsupported: Unsupported } | { baseline: true } | { reviewed: true } | { reviews: number; rejected?: true; commented?: number; reviewers?: Reviewer[] }
|
|
158
|
+
| Record<string, never>
|
|
159
|
+
interface Judged { review: Review; wants: boolean }
|
|
160
|
+
type Judge = (pkg: LockedPackage, bases: string[] | 'any') => Judged
|
|
161
|
+
interface Context { state: RepoReviewState; reviews: ReviewsOf; baseline: Set<string> | null }
|
|
162
|
+
function judge(pkg: LockedPackage, bases: string[] | 'any', { state, reviews, baseline }: Context): Judged {
|
|
163
|
+
const as = reviewedAs(pkg)
|
|
164
|
+
if (as === 'skip') return { review: {}, wants: false }
|
|
165
|
+
if (as !== 'version') return { review: { unsupported: as }, wants: false }
|
|
166
|
+
if (baselineKeys(pkg).some(key => baseline?.has(key))) return { review: { baseline: true }, wants: false }
|
|
167
|
+
const { passing, rejected, commented, reviewers } = stancesOf(pkg, reviews, bases)
|
|
168
|
+
const told = { reviews: passing, ...commented > 0 ? { commented } : {}, ...reviewers.length > 0 ? { reviewers } : {} }
|
|
169
|
+
if (rejected) return { review: { ...told, rejected: true }, wants: true }
|
|
170
|
+
return { review: told, wants: passing < state.required }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A directory as the client is told it: what it adds, as many as MAX_ADDED,
|
|
174
|
+
// each version with its review (`review`, as judged), those past them judged
|
|
175
|
+
// as they are, against the versions they were, as is every one its lockfiles
|
|
176
|
+
// lock of them (another integrity of it, say), each told by the least passing
|
|
177
|
+
// of them, and those of all that want one (`toReview`); the others its lockfiles lock that want reviews, and every one
|
|
178
|
+
// that can't be reviewed (as many of each as MAX_UNREVIEWED, how many more,
|
|
179
|
+
// once `capped`); and how many it locks past those read (`moreLocked`), which
|
|
180
|
+
// aren't checked; without `review`, what it adds alone.
|
|
181
|
+
function reviewedDirectory(dir: DirectoryDependencies, review: Judge | null) {
|
|
182
|
+
if ('error' in dir) return dir
|
|
183
|
+
const { locked, moreLocked, added, ...told } = dir
|
|
184
|
+
if (!review) return { ...told, added: added.slice(0, MAX_ADDED).map(shown) }
|
|
185
|
+
const adds = new Map(added.map(pkg => [lockedAs(pkg), { pkg, variants: [pkg] }]))
|
|
186
|
+
for (const pkg of locked) {
|
|
187
|
+
const of = adds.get(lockedAs(pkg))
|
|
188
|
+
if (of && lockedWith(pkg) !== lockedWith(of.pkg)) of.variants.push(pkg)
|
|
189
|
+
}
|
|
190
|
+
const own = [...adds.values()].map(({ pkg, variants }) => {
|
|
191
|
+
const judged = variants.map(variant => ({ variant, ...review(variant, pkg.was ?? []) }))
|
|
192
|
+
const least = judged.find(one => 'unsupported' in one.review) ?? judged.find(one => one.wants) ?? judged[0]!
|
|
193
|
+
return { pkg, judged: least, wanting: judged.filter(one => one.wants).map(({ variant }) => variant) }
|
|
194
|
+
})
|
|
195
|
+
const others = locked.filter(pkg => !adds.has(lockedAs(pkg))).map(pkg => ({ pkg, judged: review(pkg, 'any') }))
|
|
196
|
+
const unsupported = locked.flatMap(pkg => { const as = reviewedAs(pkg); return as === 'version' || as === 'skip' ? [] : [{ ...shown(pkg), unsupported: as }] })
|
|
197
|
+
return { ...told, added: own.slice(0, MAX_ADDED).map(({ pkg, judged }) => ({ ...shown(pkg), ...judged.review })),
|
|
198
|
+
toReview: own.flatMap(({ wanting }) => wanting.map(shown)),
|
|
199
|
+
unreviewed: others.filter(({ judged }) => judged.wants).map(({ pkg, judged }) => ({ ...shown(pkg), ...judged.review })), unsupported, moreLocked }
|
|
200
|
+
}
|
|
201
|
+
function capped<T extends { toReview: unknown[]; unreviewed: unknown[]; unsupported: unknown[] }>({ toReview: _toReview, ...dir }: T) {
|
|
202
|
+
return { ...dir, unreviewed: dir.unreviewed.slice(0, MAX_UNREVIEWED), moreUnreviewed: Math.max(0, dir.unreviewed.length - MAX_UNREVIEWED),
|
|
203
|
+
unsupported: dir.unsupported.slice(0, MAX_UNREVIEWED), moreUnsupported: Math.max(0, dir.unsupported.length - MAX_UNREVIEWED) }
|
|
204
|
+
}
|
|
205
|
+
// How many versions of `lists` there are, each once across them, by `key`,
|
|
206
|
+
// as the client tells them once: one wanting reviews by its name and
|
|
207
|
+
// version, one that can't be reviewed by where it is from too.
|
|
208
|
+
type Shown = Pick<LockedPackage, 'name' | 'version' | 'source' | 'from'>
|
|
209
|
+
const distinct = (lists: Shown[][], key: (pkg: Shown) => string) => new Set(lists.flat().map(key)).size
|
|
210
|
+
const versionKey = (pkg: Shown) => `${pkg.name}@${pkg.version ?? ''}`
|
|
211
|
+
const sourceKey = (pkg: Shown) => `${pkg.source}\0${versionKey(pkg)}\0${pkg.from ?? ''}`
|
|
212
|
+
|
|
213
|
+
// The reviews an open pull request's comments make of `wanting`, the
|
|
214
|
+
// versions still wanting one (pull-comment-reviews.ts): those that count,
|
|
215
|
+
// and whether they couldn't be read.
|
|
216
|
+
export type CommentReviews = (wanting: ReviewedDependency[]) => Promise<{ reviews: PublishedReview[]; failed: boolean }>
|
|
217
|
+
|
|
218
|
+
// What a pull request's lockfiles lock, but in directories none of whose
|
|
219
|
+
// lockfiles `scope` holds to, as they aren't reviewed: left out, as lockfiles
|
|
220
|
+
// left unread are where it holds to none of them (`omitted`, told to none).
|
|
221
|
+
export function heldDependencies<T extends PullDependencies>(told: T, scope: LockfileScope): Omit<T, 'omitted'> {
|
|
222
|
+
const { omitted, ...answered } = told
|
|
223
|
+
if (isEvery(scope)) return answered
|
|
224
|
+
return { ...answered, cut: told.cut && !(omitted && !omitted.some(path => heldTo(scope, path))), lockfiles: told.lockfiles.filter(dir => reviewedIn(scope, dir.paths)) }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A pull request's dependencies as the client is told them: without the
|
|
228
|
+
// versions its directories lock; with `state`, a repository's review in
|
|
229
|
+
// effect, each version's review (reviewedDirectory), those wanting one asked
|
|
230
|
+
// of the registered reviewed dependencies (`reviewed`), and those still
|
|
231
|
+
// wanting one of the reviews its comments make (`commentReviews`, where the
|
|
232
|
+
// host reads them), and the review's terms (`review`), with how many
|
|
233
|
+
// reviewers' its comments count (`commentReviews`), or that they couldn't be
|
|
234
|
+
// read (`commentsFailed`), and how many versions across its directories want
|
|
235
|
+
// reviews, of those it adds (`toReview`) and the rest (`unreviewed`), and
|
|
236
|
+
// can't be reviewed (`unsupported`), each once; how many the reviewed dependencies failed to
|
|
237
|
+
// answer (`unasked`), and whether they are registered (`reviewedDependencies`,
|
|
238
|
+
// the commit reviewed since then none); and where the baseline couldn't be
|
|
239
|
+
// read, why (`baselineError`), every version then wanting its reviews, or
|
|
240
|
+
// the lockfiles there left unread (`baselineUnread`), whose versions do. It
|
|
241
|
+
// fails where a version can't be reviewed, a directory's lockfiles couldn't
|
|
242
|
+
// be read or lock more than are checked, or the pull request changes more
|
|
243
|
+
// lockfiles than are read (`cut`). Those of directories none of whose
|
|
244
|
+
// lockfiles the review holds to (`lockfiles`, lockfile-scope.ts, told in its
|
|
245
|
+
// terms where not every one) are left out (heldDependencies).
|
|
246
|
+
export async function reviewedDependencies(told: PullDependencies, state: RepoReviewState | null,
|
|
247
|
+
{ baseline = null, baselineUnread = [], baselineError = null, reviewed = null, commentReviews = null }: { baseline?: Set<string> | null; baselineUnread?: string[]
|
|
248
|
+
baselineError?: string | null; reviewed?: ReviewedDependencies | null; commentReviews?: CommentReviews | null } = {}) {
|
|
249
|
+
const scope = state?.lockfiles ?? EVERY_LOCKFILE
|
|
250
|
+
const answered = heldDependencies(told, scope)
|
|
251
|
+
if (!state) return { ...answered, lockfiles: answered.lockfiles.map(dir => reviewedDirectory(dir, null)) }
|
|
252
|
+
const { cut, lockfiles: held } = answered
|
|
253
|
+
let context = { state, reviews: reviewsByVersion(state.reviews), baseline: reviewed ? null : baseline }
|
|
254
|
+
// What wants a review, as `lookup` is asked of, each once.
|
|
255
|
+
const wanting = () => {
|
|
256
|
+
const asked = new Map<string, ReviewedDependency>()
|
|
257
|
+
const asking: Judge = (pkg, bases) => {
|
|
258
|
+
const judged = judge(pkg, bases, context)
|
|
259
|
+
if (judged.wants) asked.set(...askedOf(pkg))
|
|
260
|
+
return judged
|
|
261
|
+
}
|
|
262
|
+
for (const dir of held) reviewedDirectory(dir, asking)
|
|
263
|
+
return asked
|
|
264
|
+
}
|
|
265
|
+
const passed = new Set<string>()
|
|
266
|
+
let unasked = 0
|
|
267
|
+
const ask = async (asked: Map<string, ReviewedDependency>) => {
|
|
268
|
+
const answer = asked.size > 0 && reviewed ? await askReviewed(reviewed.lookup, asked) : { passed: new Set<string>(), unasked: 0 }
|
|
269
|
+
for (const key of answer.passed) passed.add(key)
|
|
270
|
+
unasked += answer.unasked
|
|
271
|
+
}
|
|
272
|
+
const asked = wanting()
|
|
273
|
+
await ask(asked)
|
|
274
|
+
// Of those still wanting one, and every other one the lockfiles it holds
|
|
275
|
+
// to add that its teams' reviews pass, telling other pull requests those
|
|
276
|
+
// it reviews (`commented`), but those `lookup` passes, reviewed, the
|
|
277
|
+
// reviews its comments make, an open one's alone (pull-comment-reviews.ts),
|
|
278
|
+
// which count as its teams' do; those their rejects hold asked of `lookup`
|
|
279
|
+
// too.
|
|
280
|
+
const parsing = new Map([...asked].filter(([key]) => !passed.has(key)))
|
|
281
|
+
if (commentReviews) {
|
|
282
|
+
const others = new Map<string, ReviewedDependency>()
|
|
283
|
+
for (const pkg of held.flatMap(dir => 'added' in dir ? dir.added : [])) {
|
|
284
|
+
const [key, dependency] = askedOf(pkg)
|
|
285
|
+
if (!asked.has(key) && 'reviews' in judge(pkg, pkg.was ?? [], context).review) others.set(key, dependency)
|
|
286
|
+
}
|
|
287
|
+
const vouched = others.size > 0 && reviewed ? (await askReviewed(reviewed.lookup, others)).passed : new Set<string>()
|
|
288
|
+
for (const [key, dependency] of others) if (!vouched.has(key)) parsing.set(key, dependency)
|
|
289
|
+
}
|
|
290
|
+
const comments = parsing.size > 0 && commentReviews ? await commentReviews([...parsing.values()]) : { reviews: [], failed: false }
|
|
291
|
+
if (comments.reviews.length > 0) {
|
|
292
|
+
context = { ...context, reviews: reviewsByVersion([...state.reviews, ...comments.reviews.map(review => ({ ...review, comment: true as const }))]) }
|
|
293
|
+
await ask(new Map([...wanting()].filter(([key]) => !asked.has(key))))
|
|
294
|
+
}
|
|
295
|
+
const dirs = held.map(dir => reviewedDirectory(dir, (pkg, bases) => {
|
|
296
|
+
const judged = judge(pkg, bases, context)
|
|
297
|
+
return judged.wants && passed.has(askedOf(pkg)[0]) ? { review: { reviewed: true }, wants: false } : judged
|
|
298
|
+
}))
|
|
299
|
+
const unread = baselineUnread.filter(path => heldTo(scope, path))
|
|
300
|
+
const listed = dirs.flatMap(dir => 'unreviewed' in dir ? [dir] : [])
|
|
301
|
+
return { ...answered, cut, lockfiles: dirs.map(dir => 'unreviewed' in dir ? capped(dir) : dir), review: { required: state.required,
|
|
302
|
+
since: reviewed ? null : state.since, canReview: state.canReview, ...reviewed ? { reviewedDependencies: true } : {},
|
|
303
|
+
toReview: distinct(listed.map(dir => dir.toReview), versionKey), unreviewed: distinct(listed.map(dir => dir.unreviewed), versionKey),
|
|
304
|
+
unsupported: distinct(listed.map(dir => dir.unsupported), sourceKey),
|
|
305
|
+
...unasked > 0 ? { unasked } : {}, ...baselineError && !reviewed ? { baselineError } : {},
|
|
306
|
+
...comments.reviews.length > 0 ? { commentReviews: new Set(comments.reviews.map(review => review.reviewer)).size } : {}, ...comments.failed ? { commentsFailed: true } : {},
|
|
307
|
+
...unread.length > 0 && !reviewed ? { baselineUnread: unread.slice(0, MAX_UNREVIEWED) } : {}, ...isEvery(scope) ? {} : { lockfiles: scope } } }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The package versions a pull request's lockfiles lock that its reviews here
|
|
311
|
+
// may pass, whose reviews are asked: those that can be reviewed, unpatched.
|
|
312
|
+
export const reviewedPackages = (told: PullDependencies) => told.lockfiles
|
|
313
|
+
.flatMap(dir => 'error' in dir ? [] : dir.locked.filter(pkg => reviewedAs(pkg) === 'version' && !pkg.patchHash).map(pkg => ({ name: pkg.name, version: pkg.version! })))
|
|
314
|
+
|
|
315
|
+
// A repository's baseline as read for a pull request: its versions and the
|
|
316
|
+
// lockfiles there left unread; none where it reviews since no commit; and
|
|
317
|
+
// where GitHub doesn't read it, none and why.
|
|
318
|
+
export async function baselineFor(fullName: string, token: string, repoId: number, state: RepoReviewState, kept: ReviewBaselineStore, reads: LockfileStore | null,
|
|
319
|
+
asked?: GithubAsked) {
|
|
320
|
+
if (!state.since) return { baseline: null, unread: [], error: null }
|
|
321
|
+
try {
|
|
322
|
+
const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept, reads, asked)
|
|
323
|
+
return { baseline: versions, unread, error: null }
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (err instanceof GithubApiError && err.status !== 429 && err.status !== 401) return { baseline: null, unread: [], error: err.message }
|
|
326
|
+
throw err
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// What an open pull request's reviews say, as the code viewer's Pull requests
|
|
2
|
+
// tab and a pull request's popup show it (repository-issues.ts): who
|
|
3
|
+
// reviewed it and how, each by their latest review and the commit it was on,
|
|
4
|
+
// its author's own aside (the replies GitHub makes reviews of in a thread);
|
|
5
|
+
// whose review is requested, and how many more than those read; its review
|
|
6
|
+
// threads open and resolved, the latest read (open ones are, mostly, those
|
|
7
|
+
// lately opened), the gravest open one by the P0–P3 badge Codex marks a
|
|
8
|
+
// finding with; and
|
|
9
|
+
// Codex's own, which it keeps in a comment of its own (its summary, edited in
|
|
10
|
+
// place) rather than in reviews, reviewing only where it finds something:
|
|
11
|
+
// the commit its code review and its security review were last on, either
|
|
12
|
+
// one still running, asked for again since, or failed since. A review an
|
|
13
|
+
// agentic workflow (gh-aw) posted, as GitHub Actions' bot, names its
|
|
14
|
+
// workflow by the marker gh-aw leaves (`<!-- gh-aw-workflow-id: NAME -->`), in
|
|
15
|
+
// the popup, which reads reviews' bodies.
|
|
16
|
+
import { githubAgent } from '../common/managed/github-agents.ts'
|
|
17
|
+
import { ACTOR, type GraphAccount, actorOf } from './github-accounts.ts'
|
|
18
|
+
|
|
19
|
+
const REVIEWERS = 20
|
|
20
|
+
const REQUESTS = 10
|
|
21
|
+
// The threads the Pull requests tab counts, and those a popup lists.
|
|
22
|
+
export const TILE_THREADS = 50
|
|
23
|
+
export const ITEM_THREADS = 100
|
|
24
|
+
// The comments read for Codex's: its summary among the first, posted as the
|
|
25
|
+
// pull request opens, or among the last, where it was first asked later;
|
|
26
|
+
// asking it again, and its failures, among the last.
|
|
27
|
+
const FIRST_COMMENTS = 5
|
|
28
|
+
const LAST_COMMENTS = 20
|
|
29
|
+
const MAX_TITLE = 200
|
|
30
|
+
const MAX_PATH = 1000
|
|
31
|
+
const COMMIT = /^[a-f\d]{40}$/u
|
|
32
|
+
|
|
33
|
+
// The fields a pull request's reviews are read by, its threads' places and
|
|
34
|
+
// links with them for its popup (`detail`). A team requested is no reviewer
|
|
35
|
+
// read, but one of those requested GitHub names no one for: reading a team
|
|
36
|
+
// needs its organization's Members permission, and a query reading one
|
|
37
|
+
// without it fails whole (as one naming an EnterpriseTeam, which GitHub's
|
|
38
|
+
// docs name and its API knows none of, does).
|
|
39
|
+
export const reviewFields = (threads: number, detail: boolean) => `headRefOid
|
|
40
|
+
latestReviews(first: ${REVIEWERS}) { totalCount nodes { author { ${ACTOR} } state submittedAt commit { oid }${detail ? ' body' : ''} } }
|
|
41
|
+
reviewRequests(first: ${REQUESTS}) { totalCount nodes { requestedReviewer { __typename ... on User { login databaseId } ... on Bot { login databaseId }
|
|
42
|
+
... on Mannequin { login databaseId } } } }
|
|
43
|
+
reviewThreads(last: ${threads}) { totalCount nodes { isResolved isOutdated${detail ? ' path line originalLine' : ''}
|
|
44
|
+
comments(first: 1) { nodes { author { ${ACTOR} } body${detail ? ' url' : ''} } } } }
|
|
45
|
+
firstComments: comments(first: ${FIRST_COMMENTS}) { nodes { author { ${ACTOR} } body updatedAt } }
|
|
46
|
+
lastComments: comments(last: ${LAST_COMMENTS}) { nodes { author { ${ACTOR} } body createdAt updatedAt } }`
|
|
47
|
+
|
|
48
|
+
// The fields read for reviews that GitHub may refuse alone, as it refuses an
|
|
49
|
+
// App a pull request's checks: their pull request then shows no reviews.
|
|
50
|
+
const REVIEW_PATHS = new Set(['latestReviews', 'reviewRequests', 'reviewThreads', 'firstComments', 'lastComments'])
|
|
51
|
+
export const reviewsRefused = (error: { type?: unknown; path?: unknown } | null | undefined) => error?.type === 'FORBIDDEN' && Array.isArray(error.path)
|
|
52
|
+
&& error.path.some(part => REVIEW_PATHS.has(part as string))
|
|
53
|
+
|
|
54
|
+
type Comment = { author?: GraphAccount; body?: unknown; updatedAt?: unknown; createdAt?: unknown } | null
|
|
55
|
+
type Thread = { isResolved?: unknown; isOutdated?: unknown; path?: unknown; line?: unknown; originalLine?: unknown
|
|
56
|
+
comments?: { nodes?: (Comment & { url?: unknown })[] } | null } | null
|
|
57
|
+
export type ReviewNode = {
|
|
58
|
+
headRefOid?: unknown
|
|
59
|
+
latestReviews?: { totalCount?: unknown; nodes?: ({ author?: GraphAccount; state?: unknown; submittedAt?: unknown; commit?: { oid?: unknown } | null; body?: unknown } | null)[] } | null
|
|
60
|
+
reviewRequests?: { totalCount?: unknown; nodes?: ({ requestedReviewer?: { __typename?: unknown; login?: unknown; databaseId?: unknown } | null } | null)[] } | null
|
|
61
|
+
reviewThreads?: { totalCount?: unknown; nodes?: Thread[] } | null
|
|
62
|
+
firstComments?: { nodes?: Comment[] } | null
|
|
63
|
+
lastComments?: { nodes?: Comment[] } | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const STATES: Record<string, string> = { APPROVED: 'approved', CHANGES_REQUESTED: 'changes', COMMENTED: 'commented', DISMISSED: 'dismissed' }
|
|
67
|
+
const CODEX_STATES: Record<string, string> = { completed: 'done', running: 'running', queued: 'running', pending: 'running', in_progress: 'running', failed: 'failed', error: 'failed' }
|
|
68
|
+
const SEVERITY = /!\[(P[0-3]) Badge\]/u
|
|
69
|
+
const SUMMARY = /<!--\s*codex-review-state:v1\s+(\{[^]*?\})\s*-->/u
|
|
70
|
+
// Asking it to review again reruns its code review alone, never its security review.
|
|
71
|
+
const ASKED = /^@codex\s+review\b/iu
|
|
72
|
+
const FAILED = /^Codex Review: Something went wrong/u
|
|
73
|
+
const WORKFLOW = /<!--\s*gh-aw-workflow-id:\s*([\w.-]{1,100})\s*-->/u
|
|
74
|
+
|
|
75
|
+
const timeOf = (value: unknown) => {
|
|
76
|
+
const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
|
|
77
|
+
return Number.isSafeInteger(at) ? at : null
|
|
78
|
+
}
|
|
79
|
+
const commitOf = (value: unknown) => typeof value === 'string' && COMMIT.test(value.toLowerCase()) ? value.toLowerCase() : null
|
|
80
|
+
const textOf = (value: unknown) => typeof value === 'string' ? value : ''
|
|
81
|
+
const isCodex = (account: GraphAccount) => {
|
|
82
|
+
const { login, id } = actorOf(account)
|
|
83
|
+
return login !== null && githubAgent(login, id) === 'codex'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A finding's gravity, by its badge, `p0` the gravest; null for none.
|
|
87
|
+
const severityOf = (body: string) => SEVERITY.exec(body.slice(0, 500))?.[1]?.toLowerCase() ?? null
|
|
88
|
+
// A thread's title: its first comment's first line with words, as written,
|
|
89
|
+
// but for Codex's badge and emphasis.
|
|
90
|
+
function titleOf(body: string) {
|
|
91
|
+
for (const line of body.split('\n', 6)) {
|
|
92
|
+
const words = line.replaceAll(/!\[[^\]]*\]\([^)]*\)|<\/?sub>|\*\*|__/gu, '').trim()
|
|
93
|
+
if (words) return words.slice(0, MAX_TITLE)
|
|
94
|
+
}
|
|
95
|
+
return ''
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Codex's review of one kind, as its summary names it: the commit it was
|
|
99
|
+
// last on and how it stands.
|
|
100
|
+
function codexReview(value: unknown) {
|
|
101
|
+
const review = value && typeof value === 'object' ? value as { commit_sha?: unknown; status?: unknown } : null
|
|
102
|
+
if (!review) return null
|
|
103
|
+
return { commit: commitOf(review.commit_sha), status: CODEX_STATES[String(review.status)] ?? null }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// What Codex's summary says of its reviews, else null.
|
|
107
|
+
function summaryState(body: string): { code_review?: unknown; security_review?: unknown } | null {
|
|
108
|
+
try {
|
|
109
|
+
const value: unknown = JSON.parse(SUMMARY.exec(body)?.[1] ?? 'null')
|
|
110
|
+
return value && typeof value === 'object' ? value : null
|
|
111
|
+
} catch { return null }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Codex's reviews, from its summary, else none: its account, as the summary
|
|
115
|
+
// names it; whether it was asked again since the summary last changed
|
|
116
|
+
// (`@codex review`), and whether it failed since it was last asked.
|
|
117
|
+
function codexOf(node: ReviewNode) {
|
|
118
|
+
const summary = [...node.firstComments?.nodes ?? [], ...node.lastComments?.nodes ?? []]
|
|
119
|
+
.find(comment => comment && isCodex(comment.author) && SUMMARY.test(textOf(comment.body)))
|
|
120
|
+
if (!summary) return null
|
|
121
|
+
const state = summaryState(textOf(summary.body))
|
|
122
|
+
if (!state) return null
|
|
123
|
+
const since = timeOf(summary.updatedAt) ?? 0
|
|
124
|
+
const later = (node.lastComments?.nodes ?? []).filter(comment => comment && (timeOf(comment.createdAt) ?? 0) > since) as NonNullable<Comment>[]
|
|
125
|
+
const at = (comment: NonNullable<Comment> | undefined) => comment ? timeOf(comment.createdAt) ?? 0 : null
|
|
126
|
+
const asked = at(later.findLast(comment => !isCodex(comment.author) && ASKED.test(textOf(comment.body).trim())))
|
|
127
|
+
const failed = at(later.findLast(comment => isCodex(comment.author) && FAILED.test(textOf(comment.body).trim())))
|
|
128
|
+
const { login, id } = actorOf(summary.author)
|
|
129
|
+
return { login, id, code: codexReview(state.code_review), security: codexReview(state.security_review),
|
|
130
|
+
requested: asked !== null && !(failed !== null && failed > asked), failed: failed !== null && (asked === null || failed > asked) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type Reviewer = { login: string | null; id: number | null; workflow?: string; state: string | null; requested: boolean; commit: string | null; at: number | null }
|
|
134
|
+
|
|
135
|
+
// The reviewers, each once: by their latest review, its state and commit;
|
|
136
|
+
// whose is requested (again, for one who reviewed), a user, a bot (Copilot's
|
|
137
|
+
// code review, asked for, is its bot's) or an imported account's mannequin.
|
|
138
|
+
// Codex's reviews are its summary's where it has one (`codex`). How many
|
|
139
|
+
// requested GitHub named no one for, a team among them, as `unnamed`,
|
|
140
|
+
// counted with those past the ones read.
|
|
141
|
+
function reviewersOf(node: ReviewNode, author: string | null, codex: boolean) {
|
|
142
|
+
const own = author?.toLowerCase()
|
|
143
|
+
const reviewers: Reviewer[] = (node.latestReviews?.nodes ?? []).flatMap(review => {
|
|
144
|
+
const { login, id } = actorOf(review?.author)
|
|
145
|
+
const state = STATES[String(review?.state)]
|
|
146
|
+
if (!state || (login !== null && login.toLowerCase() === own) || (codex && isCodex(review?.author))) return []
|
|
147
|
+
const workflow = WORKFLOW.exec(textOf(review?.body))?.[1]
|
|
148
|
+
return [{ login, id, ...workflow ? { workflow } : {}, state, requested: false, commit: commitOf(review?.commit?.oid), at: timeOf(review?.submittedAt) }]
|
|
149
|
+
})
|
|
150
|
+
let unnamed = 0
|
|
151
|
+
for (const request of node.reviewRequests?.nodes ?? []) {
|
|
152
|
+
const reviewer = request?.requestedReviewer
|
|
153
|
+
const { login, id } = actorOf(reviewer)
|
|
154
|
+
if (login === null) {
|
|
155
|
+
unnamed++
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
// By their account where both name it, as GitHub may name one account
|
|
159
|
+
// otherwise on its reviews than on its requests (Copilot's code review's
|
|
160
|
+
// bot); else by their login.
|
|
161
|
+
const reviewed = reviewers.find(item => id !== null && item.id !== null ? item.id === id : item.login?.toLowerCase() === login.toLowerCase())
|
|
162
|
+
if (reviewed) reviewed.requested = true
|
|
163
|
+
else reviewers.push({ login, id, state: null, requested: true, commit: null, at: null })
|
|
164
|
+
}
|
|
165
|
+
return { reviewers, unnamed }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Its review threads, the latest read: how many are open and resolved,
|
|
169
|
+
// whether more than those read are, the gravest open one; and, for its popup (`detail`), each
|
|
170
|
+
// open one: its gravity, title, file and line, whether its code changed
|
|
171
|
+
// since, who opened it and where it is on GitHub.
|
|
172
|
+
function threadsOf(node: ReviewNode, detail: boolean) {
|
|
173
|
+
const threads = (node.reviewThreads?.nodes ?? []).filter(Boolean) as NonNullable<Thread>[]
|
|
174
|
+
const open = threads.filter(thread => thread.isResolved !== true)
|
|
175
|
+
const severities = open.map(thread => severityOf(textOf(thread.comments?.nodes?.[0]?.body))).filter(Boolean) as string[]
|
|
176
|
+
const total = node.reviewThreads?.totalCount
|
|
177
|
+
const counted = { open: open.length, resolved: threads.length - open.length,
|
|
178
|
+
more: Number.isSafeInteger(total) && (total as number) > threads.length, severity: severities.toSorted()[0] ?? null }
|
|
179
|
+
if (!detail) return counted
|
|
180
|
+
return { ...counted, list: open.map(thread => {
|
|
181
|
+
const first = thread.comments?.nodes?.[0]
|
|
182
|
+
const body = textOf(first?.body)
|
|
183
|
+
const opener = actorOf(first?.author)
|
|
184
|
+
const line = Number.isSafeInteger(thread.line) ? thread.line : Number.isSafeInteger(thread.originalLine) ? thread.originalLine : null
|
|
185
|
+
const url = typeof first?.url === 'string' && first.url.startsWith('https://github.com/') ? first.url : null
|
|
186
|
+
return { severity: severityOf(body), title: titleOf(body), path: typeof thread.path === 'string' ? thread.path.slice(0, MAX_PATH) : null, line,
|
|
187
|
+
outdated: thread.isOutdated === true, author: opener.login, authorId: opener.id, url }
|
|
188
|
+
}) }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A pull request's reviews (`author` its author's login), for its tile or,
|
|
192
|
+
// with its threads listed, its popup (`detail`); null where none was read, or
|
|
193
|
+
// GitHub refused its reviews, requests or threads (reviewsRefused), rather
|
|
194
|
+
// than none.
|
|
195
|
+
export function pullReviews(node: ReviewNode, author: string | null, detail = false) {
|
|
196
|
+
if (node.headRefOid === undefined || node.latestReviews === null || node.reviewRequests === null || node.reviewThreads === null) return null
|
|
197
|
+
const codex = codexOf(node)
|
|
198
|
+
// Those past the reviews and the requests read, as many as GitHub counts,
|
|
199
|
+
// apart: a review unread may be on the latest commit, a request never is.
|
|
200
|
+
const past = (connection: { totalCount?: unknown; nodes?: unknown[] } | null | undefined) => Number.isSafeInteger(connection?.totalCount)
|
|
201
|
+
? Math.max(0, (connection!.totalCount as number) - (connection!.nodes?.length ?? 0)) : 0
|
|
202
|
+
const { reviewers, unnamed } = reviewersOf(node, author, codex !== null)
|
|
203
|
+
return { head: commitOf(node.headRefOid), reviewers, moreReviews: past(node.latestReviews), moreRequests: past(node.reviewRequests) + unnamed,
|
|
204
|
+
threads: threadsOf(node, detail), codex }
|
|
205
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Items in the order they are added, each taken once by whichever reader asks
|
|
2
|
+
// first: none once closed and all taken, or once `signal` aborts, but those
|
|
3
|
+
// added by then where `drain`, none waited for.
|
|
4
|
+
export interface Queue<T> { add(item: T): void; close(): void; take(): Promise<T | undefined> }
|
|
5
|
+
export function queue<T>(signal: AbortSignal, { drain = false } = {}): Queue<T> {
|
|
6
|
+
const items: T[] = []
|
|
7
|
+
const waiting: (() => void)[] = []
|
|
8
|
+
let closed = false
|
|
9
|
+
const wake = () => { for (const resolve of waiting.splice(0)) resolve() }
|
|
10
|
+
signal.addEventListener('abort', wake, { once: true })
|
|
11
|
+
return {
|
|
12
|
+
add(item) { items.push(item); wake() },
|
|
13
|
+
close() { closed = true; wake() },
|
|
14
|
+
async take() {
|
|
15
|
+
for (;;) {
|
|
16
|
+
if (signal.aborted && !drain) return undefined
|
|
17
|
+
const item = items.shift()
|
|
18
|
+
if (item !== undefined || closed || signal.aborted) return item
|
|
19
|
+
await new Promise<void>(resolve => { waiting.push(resolve) })
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Work done `count` at a time, in the order asked, the rest waiting their
|
|
26
|
+
// turn.
|
|
27
|
+
export function inTurns(count: number): <T>(work: () => Promise<T>) => Promise<T> {
|
|
28
|
+
let active = 0
|
|
29
|
+
const waiting: (() => void)[] = []
|
|
30
|
+
return async work => {
|
|
31
|
+
if (active < count) active++
|
|
32
|
+
else await new Promise<void>(resolve => { waiting.push(resolve) })
|
|
33
|
+
try { return await work() } finally {
|
|
34
|
+
const next = waiting.shift()
|
|
35
|
+
if (next) next()
|
|
36
|
+
else active--
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|