@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,415 @@
|
|
|
1
|
+
// Repository discovery uses the signed-in user token and App installation
|
|
2
|
+
// tokens. Login and repository installation can use one GitHub App: user
|
|
3
|
+
// authorization requests account permissions, installation grants repository
|
|
4
|
+
// permissions. Discovery itself is read-only and skips archived repositories.
|
|
5
|
+
import { Buffer } from 'node:buffer'
|
|
6
|
+
import { createHash, createSign } from 'node:crypto'
|
|
7
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
8
|
+
import type { ManagedConfig } from './config.ts'
|
|
9
|
+
import { GithubApiError, githubUnreachable, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
|
|
10
|
+
import { onTokenRejected, shareGithubRead, tokenRejected } from './github-requests.ts'
|
|
11
|
+
import { timed } from './request-timing.ts'
|
|
12
|
+
|
|
13
|
+
export { GithubApiError } from './github-errors.ts'
|
|
14
|
+
export { onTokenRejected } from './github-requests.ts'
|
|
15
|
+
|
|
16
|
+
const GITHUB_API = 'https://api.github.com'
|
|
17
|
+
const API_VERSION = '2022-11-28'
|
|
18
|
+
const USER_AGENT = 'deepview-triage'
|
|
19
|
+
const PER_PAGE = 100
|
|
20
|
+
const INSTALLATION_TOKEN_TTL_MS = 5 * 60_000
|
|
21
|
+
const INSTALLATION_TOKEN_EXPIRY_MARGIN_MS = 60_000
|
|
22
|
+
const MAX_INSTALLATION_TOKENS = 256
|
|
23
|
+
interface InstallationTokenEntry { token: string | null; expiresAt: number; pending: Promise<string> | null }
|
|
24
|
+
// Credentials only: visibility, user identities, and permissions are never
|
|
25
|
+
// cached here. Bound memory per transport and separate App/key/install scopes.
|
|
26
|
+
const installationTokens = new WeakMap<typeof fetch, Map<string, InstallationTokenEntry>>()
|
|
27
|
+
|
|
28
|
+
// A token GitHub rejected (401): an installation's no longer reused.
|
|
29
|
+
onTokenRejected((token, fetchImpl) => {
|
|
30
|
+
const cache = installationTokens.get(fetchImpl)
|
|
31
|
+
if (!cache) return
|
|
32
|
+
for (const [key, entry] of cache) if (entry.token === token) cache.delete(key)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// One listed repository. Carries the context to read its contents later:
|
|
36
|
+
// `installationId` mints an App installation token (Contents: Read) for a repo
|
|
37
|
+
// reached through an installation — null for a public repo listed off the user
|
|
38
|
+
// token, readable without the App — and `defaultBranch` is the ref to read.
|
|
39
|
+
export interface ConnectedRepo {
|
|
40
|
+
id: number
|
|
41
|
+
fullName: string
|
|
42
|
+
private: boolean
|
|
43
|
+
visibility: 'public' | 'private' | 'internal' | null
|
|
44
|
+
htmlUrl: string
|
|
45
|
+
defaultBranch: string
|
|
46
|
+
installationId: number | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// App installation credentials are configured (id + private key present) →
|
|
50
|
+
// private repos can be listed via its installation tokens.
|
|
51
|
+
export function githubAppConfigured(config: ManagedConfig): boolean {
|
|
52
|
+
return config.githubAppId != null && config.githubAppPrivateKey != null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The install URL for the repositories App ("Connect a repository"), or null
|
|
56
|
+
// when no slug is set.
|
|
57
|
+
export function installUrl(config: ManagedConfig): string | null {
|
|
58
|
+
if (config.githubAppSlug == null) return null
|
|
59
|
+
return `https://github.com/apps/${encodeURIComponent(config.githubAppSlug)}/installations/new`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Merge repo lists from every source, deduped by full name and sorted. Later
|
|
63
|
+
// sources win a collision (the handler lists private after public).
|
|
64
|
+
export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
|
|
65
|
+
const byName = new Map<string, ConnectedRepo>()
|
|
66
|
+
for (const list of lists) {
|
|
67
|
+
for (const repo of list) byName.set(repo.fullName, repo)
|
|
68
|
+
}
|
|
69
|
+
return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One GitHub API call returning parsed JSON, optionally Bearer-authed by a
|
|
73
|
+
// user token, App JWT, or installation token, `body` sent as JSON (a GraphQL
|
|
74
|
+
// query's, given longer to answer). Network / non-2xx / malformed fold into a
|
|
75
|
+
// GithubApiError (401 passes through for the caller to handle). Identical GETs
|
|
76
|
+
// share an outstanding read through its body. POSTs only opt in for read-only
|
|
77
|
+
// GraphQL queries (repository-history.ts), never for mutations or token mints.
|
|
78
|
+
export function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
|
|
79
|
+
readOnly = method === 'GET'): Promise<unknown> {
|
|
80
|
+
if (readOnly) return shareGithubRead(fetchImpl, JSON.stringify([url, token, method, body]), () => githubJson(url, token, fetchImpl, method, body, false))
|
|
81
|
+
// Each call told in its request's Server-Timing (request-timing.ts).
|
|
82
|
+
return timed('gh', async () => {
|
|
83
|
+
let res: Response
|
|
84
|
+
const started = Date.now()
|
|
85
|
+
try {
|
|
86
|
+
res = await fetchImpl(url, {
|
|
87
|
+
method,
|
|
88
|
+
signal: AbortSignal.timeout(body === undefined ? 10_000 : 20_000),
|
|
89
|
+
redirect: 'error',
|
|
90
|
+
headers: {
|
|
91
|
+
...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
|
|
92
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }), 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
|
|
93
|
+
},
|
|
94
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
95
|
+
})
|
|
96
|
+
} catch (error) { throw githubUnreachable(error, started) }
|
|
97
|
+
if (res.status === 401 && token) tokenRejected(token, fetchImpl)
|
|
98
|
+
if (!res.ok) await throwGithubResponseError(res)
|
|
99
|
+
// The answer's body is read within the same timeout: one not read whole is
|
|
100
|
+
// GitHub not reached, as one not answered is; only one that isn't JSON is
|
|
101
|
+
// malformed.
|
|
102
|
+
try { return await res.json() } catch (error) {
|
|
103
|
+
throw error instanceof SyntaxError ? new GithubApiError(502, 'github-malformed') : githubUnreachable(error, started)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Validate one repo object into a ConnectedRepo (or null to skip), tagging it
|
|
109
|
+
// with the installation it was reached through (null for the user-token path). A
|
|
110
|
+
// missing numeric id or full name → skip (can't key/locate it). Archived repos
|
|
111
|
+
// are skipped — read-only history, not triage targets.
|
|
112
|
+
function parseRepo(raw: unknown, installationId: number | null): ConnectedRepo | null {
|
|
113
|
+
if (raw == null || typeof raw !== 'object') return null
|
|
114
|
+
if ((raw as { archived?: unknown }).archived === true) return null
|
|
115
|
+
const id = (raw as { id?: unknown }).id
|
|
116
|
+
const fullName = (raw as { full_name?: unknown }).full_name
|
|
117
|
+
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
|
|
118
|
+
if (typeof fullName !== 'string' || fullName === '') return null
|
|
119
|
+
const htmlUrl = (raw as { html_url?: unknown }).html_url
|
|
120
|
+
const branch = (raw as { default_branch?: unknown }).default_branch
|
|
121
|
+
const visibility = (raw as { visibility?: unknown }).visibility
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
fullName,
|
|
125
|
+
private: (raw as { private?: unknown }).private === true,
|
|
126
|
+
visibility: visibility === 'public' || visibility === 'private' || visibility === 'internal' ? visibility : null,
|
|
127
|
+
htmlUrl: typeof htmlUrl === 'string' ? htmlUrl : '',
|
|
128
|
+
defaultBranch: typeof branch === 'string' ? branch : '',
|
|
129
|
+
installationId,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Accept an owner/repo or exact GitHub repository URL, never an arbitrary API
|
|
134
|
+
// path. Reject traversal and encoded separators before constructing a fixed URL.
|
|
135
|
+
export function publicRepositoryName(raw: unknown): string | null {
|
|
136
|
+
if (typeof raw !== 'string') return null
|
|
137
|
+
const name = raw.trim().replace(/^https:\/\/github\.com\//iu, '').replace(/\/$/u, '')
|
|
138
|
+
if (!/^[a-z\d](?:[a-z\d-]{0,38})\/[a-z\d_.-]{1,100}$/iu.test(name)) return null
|
|
139
|
+
if (['.', '..'].includes(name.split('/')[1]!)) return null
|
|
140
|
+
return name
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Public additions must be readable by the server without the acting user's
|
|
144
|
+
// credentials. Require explicit public visibility and use GitHub's canonical
|
|
145
|
+
// metadata, never client-supplied ids, installation ids, or branches.
|
|
146
|
+
export async function fetchPublicRepository(fullName: string): Promise<ConnectedRepo> {
|
|
147
|
+
if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(400, 'bad-repository')
|
|
148
|
+
const repo = parseRepo(await upstreamGithub(() => createClient({ token: null, userAgent: USER_AGENT }).getRepo({ repo: fullName })), null)
|
|
149
|
+
if (repo == null || repo.private || repo.visibility !== 'public') throw new GithubApiError(409, 'repo-not-public')
|
|
150
|
+
if (publicRepositoryName(repo.fullName) !== repo.fullName || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(502, 'github-malformed')
|
|
151
|
+
return { ...repo, htmlUrl: `https://github.com/${repo.fullName}` }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── PUBLIC: the user's own repos via their login token ──
|
|
155
|
+
|
|
156
|
+
// List the authenticated user's repositories (GET /user/repos), paginated until
|
|
157
|
+
// a short page, deduped + sorted. With an identity-only token this returns the
|
|
158
|
+
// user's PUBLIC repos. READ-ONLY.
|
|
159
|
+
export async function listUserRepos(accessToken: string): Promise<ConnectedRepo[]> {
|
|
160
|
+
const byName = new Map<string, ConnectedRepo>()
|
|
161
|
+
const body = await upstreamGithub(() => createClient({ token: accessToken, userAgent: USER_AGENT }).listUserRepos())
|
|
162
|
+
for (const r of body) {
|
|
163
|
+
const repo = parseRepo(r, null)
|
|
164
|
+
if (repo != null) byName.set(repo.fullName, repo)
|
|
165
|
+
}
|
|
166
|
+
return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── PRIVATE: App installations via installation tokens ──
|
|
170
|
+
|
|
171
|
+
function base64url(input: Buffer | string): string {
|
|
172
|
+
return Buffer.from(input).toString('base64url')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Mint a short-lived App JWT (RS256) for the repositories App. iat backdated 60s
|
|
176
|
+
// for clock skew; exp 8 min out (within GitHub's 10-min cap); iss = the App id.
|
|
177
|
+
export function appJwt(appId: string, privateKeyPem: string, now: number = Date.now()): string {
|
|
178
|
+
const seconds = Math.floor(now / 1000)
|
|
179
|
+
const iat = seconds - 60
|
|
180
|
+
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
|
181
|
+
const payload = base64url(JSON.stringify({ iat, exp: seconds + 480, iss: appId }))
|
|
182
|
+
const signingInput = `${header}.${payload}`
|
|
183
|
+
return `${signingInput}.${base64url(createSign('RSA-SHA256').update(signingInput).sign(privateKeyPem))}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The repositories App's installation ids (one per org/user that installed it).
|
|
187
|
+
async function listInstallationIds(jwt: string, fetchImpl: typeof fetch): Promise<number[]> {
|
|
188
|
+
const ids: number[] = []
|
|
189
|
+
for (let page = 1; ; page++) {
|
|
190
|
+
const body = await githubJson(`${GITHUB_API}/app/installations?per_page=${PER_PAGE}&page=${page}`, jwt, fetchImpl)
|
|
191
|
+
if (!Array.isArray(body)) throw new GithubApiError(502, 'github-malformed')
|
|
192
|
+
for (const inst of body) {
|
|
193
|
+
const id = (inst as { id?: unknown }).id
|
|
194
|
+
if (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) ids.push(id)
|
|
195
|
+
}
|
|
196
|
+
if (body.length < PER_PAGE) break
|
|
197
|
+
}
|
|
198
|
+
return ids
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Reuse installation credentials only in this process. Refresh well before
|
|
202
|
+
// expiry and coalesce concurrent mints; failed or undated tokens are not cached.
|
|
203
|
+
async function installationAccessToken(appId: string, privateKey: string, installId: number, fetchImpl: typeof fetch): Promise<string> {
|
|
204
|
+
let cache = installationTokens.get(fetchImpl)
|
|
205
|
+
if (!cache) { cache = new Map(); installationTokens.set(fetchImpl, cache) }
|
|
206
|
+
const now = Date.now()
|
|
207
|
+
for (const [key, entry] of cache) if (!entry.pending && entry.expiresAt <= now) cache.delete(key)
|
|
208
|
+
const key = JSON.stringify([appId, createHash('sha256').update(privateKey).digest('hex'), installId])
|
|
209
|
+
const cached = cache.get(key)
|
|
210
|
+
if (cached) {
|
|
211
|
+
cache.delete(key)
|
|
212
|
+
cache.set(key, cached)
|
|
213
|
+
if (cached.pending) return cached.pending
|
|
214
|
+
return cached.token!
|
|
215
|
+
}
|
|
216
|
+
const entry: InstallationTokenEntry = { token: null, expiresAt: 0, pending: null }
|
|
217
|
+
const pending = Promise.resolve().then(async () => {
|
|
218
|
+
try {
|
|
219
|
+
const jwt = appJwt(appId, privateKey)
|
|
220
|
+
const body = await githubJson(`${GITHUB_API}/app/installations/${installId}/access_tokens`, jwt, fetchImpl, 'POST') as { token?: unknown; expires_at?: unknown }
|
|
221
|
+
if (typeof body?.token !== 'string' || body.token === '') throw new GithubApiError(502, 'github-install-token-denied')
|
|
222
|
+
entry.token = body.token
|
|
223
|
+
const expiresAt = typeof body.expires_at === 'string' ? Date.parse(body.expires_at) : NaN
|
|
224
|
+
entry.expiresAt = Number.isFinite(expiresAt) ? Math.min(now + INSTALLATION_TOKEN_TTL_MS, expiresAt - INSTALLATION_TOKEN_EXPIRY_MARGIN_MS) : 0
|
|
225
|
+
if (entry.expiresAt <= Date.now() && cache.get(key) === entry) cache.delete(key)
|
|
226
|
+
return body.token
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (cache.get(key) === entry) cache.delete(key)
|
|
229
|
+
throw err
|
|
230
|
+
} finally { entry.pending = null }
|
|
231
|
+
})
|
|
232
|
+
entry.pending = pending
|
|
233
|
+
cache.set(key, entry)
|
|
234
|
+
if (cache.size > MAX_INSTALLATION_TOKENS) cache.delete(cache.keys().next().value!)
|
|
235
|
+
return await pending
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Resolve this exact repository with the App's identity, then confirm the
|
|
239
|
+
// installation token reaches the same numeric repository. A missing grant is
|
|
240
|
+
// actionable through installation; transport/auth/rate errors must stay errors.
|
|
241
|
+
export async function repositoryInstallation(config: ManagedConfig, repoId: number, fullName: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<number | null> {
|
|
242
|
+
const { githubAppId, githubAppPrivateKey } = config
|
|
243
|
+
if (githubAppId == null || githubAppPrivateKey == null) throw new GithubApiError(503, 'github-app-not-configured')
|
|
244
|
+
if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(409, 'repo-identity-changed')
|
|
245
|
+
const path = fullName.split('/').map(encodeURIComponent).join('/')
|
|
246
|
+
const jwt = appJwt(githubAppId, githubAppPrivateKey)
|
|
247
|
+
let raw
|
|
248
|
+
try { raw = await githubJson(`${GITHUB_API}/repos/${path}/installation`, jwt, fetchImpl) } catch (err) {
|
|
249
|
+
if (err instanceof GithubApiError && err.status === 404) return null
|
|
250
|
+
throw err
|
|
251
|
+
}
|
|
252
|
+
const installation = raw as { id?: unknown; suspended_at?: unknown; permissions?: { contents?: unknown } } | null
|
|
253
|
+
const id = installation?.id
|
|
254
|
+
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) throw new GithubApiError(502, 'github-malformed')
|
|
255
|
+
if (installation?.suspended_at != null || !['read', 'write'].includes(String(installation?.permissions?.contents))) return null
|
|
256
|
+
const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
|
|
257
|
+
const repo = parseRepo(await githubJson(`${GITHUB_API}/repos/${path}`, token, fetchImpl), id)
|
|
258
|
+
if (repo == null || repo.id !== repoId || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(409, 'repo-identity-changed')
|
|
259
|
+
return id
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The `repositories` array of one /installation/repositories page → ConnectedRepo[],
|
|
263
|
+
// each tagged with the installation id that can read it.
|
|
264
|
+
function repoPage(body: unknown, installationId: number): ConnectedRepo[] {
|
|
265
|
+
const repositories = (body as { repositories?: unknown }).repositories
|
|
266
|
+
if (!Array.isArray(repositories)) return []
|
|
267
|
+
const out: ConnectedRepo[] = []
|
|
268
|
+
for (const r of repositories) {
|
|
269
|
+
const repo = parseRepo(r, installationId)
|
|
270
|
+
if (repo != null) out.push(repo)
|
|
271
|
+
}
|
|
272
|
+
return out
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Every repository one installation can read, paginated by the page count from
|
|
276
|
+
// `total_count` (avoids Link-header parsing).
|
|
277
|
+
async function listInstallationRepos(token: string, installationId: number, fetchImpl: typeof fetch): Promise<ConnectedRepo[]> {
|
|
278
|
+
const pageUrl = (page: number): string => `${GITHUB_API}/installation/repositories?per_page=${PER_PAGE}&page=${page}`
|
|
279
|
+
const first = await githubJson(pageUrl(1), token, fetchImpl) as { total_count?: unknown }
|
|
280
|
+
const repos = repoPage(first, installationId)
|
|
281
|
+
const total = typeof first.total_count === 'number' ? first.total_count : repos.length
|
|
282
|
+
const pages = Math.ceil(total / PER_PAGE)
|
|
283
|
+
for (let page = 2; page <= pages; page++) {
|
|
284
|
+
repos.push(...repoPage(await githubJson(pageUrl(page), token, fetchImpl), installationId))
|
|
285
|
+
}
|
|
286
|
+
return repos
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// List every repo the repositories App is installed on, across installations
|
|
290
|
+
// (public + PRIVATE within those installs), deduped + sorted. Empty when the App
|
|
291
|
+
// isn't configured. READ-ONLY.
|
|
292
|
+
export async function listInstalledRepos(config: ManagedConfig, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
|
|
293
|
+
const { githubAppId, githubAppPrivateKey } = config
|
|
294
|
+
if (githubAppId == null || githubAppPrivateKey == null) return []
|
|
295
|
+
const jwt = appJwt(githubAppId, githubAppPrivateKey)
|
|
296
|
+
const lists = await mapGithubRequests(await listInstallationIds(jwt, fetchImpl), async (id) => {
|
|
297
|
+
const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
|
|
298
|
+
return listInstallationRepos(token, id, fetchImpl)
|
|
299
|
+
})
|
|
300
|
+
return mergeRepos(...lists)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Bound fan-out while keeping independent installations/permission checks off
|
|
304
|
+
// one long serial critical path. Preserve input order for deterministic merges.
|
|
305
|
+
async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>): Promise<U[]> {
|
|
306
|
+
const results: U[] = []
|
|
307
|
+
let next = 0
|
|
308
|
+
await Promise.all(Array.from({ length: Math.min(4, items.length) }, async () => {
|
|
309
|
+
while (next < items.length) {
|
|
310
|
+
const index = next++
|
|
311
|
+
results[index] = await work(items[index]!)
|
|
312
|
+
}
|
|
313
|
+
}))
|
|
314
|
+
return results
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// A GitHub account, as GitHub's /user tells it.
|
|
318
|
+
export interface GithubIdentity { id: number; login: string }
|
|
319
|
+
|
|
320
|
+
// The login App and repository App are separate: /user/repos with the login
|
|
321
|
+
// token is not evidence of private-repo access. Ask the repository App for the
|
|
322
|
+
// user's effective GitHub permission (including teams/org/enterprise grants).
|
|
323
|
+
// This endpoint accepts installation tokens with Metadata: read; it does NOT
|
|
324
|
+
// require Administration permission (unlike collaborator mutation endpoints).
|
|
325
|
+
// https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
|
|
326
|
+
// Resolve the current login from /user so renamed handles cannot check someone
|
|
327
|
+
// else's access. All repo paths come from GitHub's installation catalogue.
|
|
328
|
+
export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<GithubIdentity> {
|
|
329
|
+
const identity = await githubJson(`${GITHUB_API}/user`, userToken, fetchImpl) as { id?: unknown; login?: unknown }
|
|
330
|
+
if (typeof identity?.login !== 'string' || !identity.login || typeof identity.id !== 'number' || !Number.isSafeInteger(identity.id) || identity.id <= 0) throw new GithubApiError(502, 'github-malformed')
|
|
331
|
+
return { id: identity.id, login: identity.login }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// The permission's level (`admin`, `write`, `read`), as GitHub tells it of
|
|
335
|
+
// `identity` alone; `none` where it tells of none, or of another account.
|
|
336
|
+
export async function githubRepoPermission(fullName: string, token: string, identity: GithubIdentity, fetchImpl: typeof fetch = globalThis.fetch): Promise<string> {
|
|
337
|
+
const path = fullName.split('/').map(encodeURIComponent).join('/')
|
|
338
|
+
try {
|
|
339
|
+
const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login)}/permission`, token, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
|
|
340
|
+
return body?.user?.id === identity.id && typeof body.permission === 'string' ? body.permission : 'none'
|
|
341
|
+
} catch (err) {
|
|
342
|
+
if (err instanceof GithubApiError && err.status === 404) return 'none'
|
|
343
|
+
throw err // Never broaden access when GitHub cannot verify permission.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The levels that read a repository.
|
|
348
|
+
export const READ_PERMISSIONS: ReadonlySet<string> = new Set(['read', 'write', 'admin'])
|
|
349
|
+
|
|
350
|
+
export async function githubRepoReadPermission(fullName: string, token: string, identity: GithubIdentity, fetchImpl: typeof fetch = globalThis.fetch): Promise<boolean> {
|
|
351
|
+
return READ_PERMISSIONS.has(await githubRepoPermission(fullName, token, identity, fetchImpl))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function filterInstalledRepos(config: ManagedConfig, repositories: ConnectedRepo[], userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
|
|
355
|
+
const identity = await githubUserIdentity(userToken, fetchImpl)
|
|
356
|
+
const tokens = new Map<number, Promise<string | null>>()
|
|
357
|
+
const allowed = await mapGithubRequests(repositories, async (repo) => {
|
|
358
|
+
// Internal repos may have private=false; missing visibility is not proof
|
|
359
|
+
// of public access either. Only explicitly public repositories skip checks.
|
|
360
|
+
if (repo.visibility === 'public') return true
|
|
361
|
+
if (repo.installationId == null) return false
|
|
362
|
+
let token = tokens.get(repo.installationId)
|
|
363
|
+
if (!token) {
|
|
364
|
+
token = repoAccessToken(config, repo.installationId, fetchImpl)
|
|
365
|
+
tokens.set(repo.installationId, token)
|
|
366
|
+
}
|
|
367
|
+
const accessToken = await token
|
|
368
|
+
if (!accessToken) return false
|
|
369
|
+
return githubRepoReadPermission(repo.fullName, accessToken, identity, fetchImpl)
|
|
370
|
+
})
|
|
371
|
+
return repositories.filter((_, index) => allowed[index])
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Get a token to READ a selected repo's contents: an installation token (the
|
|
375
|
+
// App's Contents: Read) when the repo was reached through an installation, or
|
|
376
|
+
// null for a public repo readable unauthenticated. This is the whole point of
|
|
377
|
+
// persisting `installationId` — the stored context is enough to read.
|
|
378
|
+
export async function repoAccessToken(config: ManagedConfig, installationId: number | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
|
|
379
|
+
const { githubAppId, githubAppPrivateKey } = config
|
|
380
|
+
if (installationId == null || githubAppId == null || githubAppPrivateKey == null) return null
|
|
381
|
+
return await installationAccessToken(githubAppId, githubAppPrivateKey, installationId, fetchImpl)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── merged listing ──
|
|
385
|
+
|
|
386
|
+
export interface RepoListing {
|
|
387
|
+
repositories: ConnectedRepo[]
|
|
388
|
+
tokenMissing: boolean
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Every repo a user can reach, merged (deduped + sorted): PUBLIC via their login
|
|
392
|
+
// token + PRIVATE via the App's installations. Each source is best-effort so one
|
|
393
|
+
// failing never blanks the other; `tokenMissing` flags a missing/stale user
|
|
394
|
+
// token (→ the page prompts re-login). A repo seen on both sides keeps the
|
|
395
|
+
// installation tag (private listed last → wins the merge).
|
|
396
|
+
export async function collectRepos(config: ManagedConfig, userToken: string | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<RepoListing> {
|
|
397
|
+
let tokenMissing = userToken == null
|
|
398
|
+
const lists: ConnectedRepo[][] = []
|
|
399
|
+
if (userToken != null) {
|
|
400
|
+
try {
|
|
401
|
+
lists.push(await listUserRepos(userToken))
|
|
402
|
+
} catch (err) {
|
|
403
|
+
if (err instanceof GithubApiError && err.status === 401) tokenMissing = true
|
|
404
|
+
else console.warn('managed: public repo list failed:', err)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (githubAppConfigured(config)) {
|
|
408
|
+
try {
|
|
409
|
+
lists.push(await listInstalledRepos(config, fetchImpl))
|
|
410
|
+
} catch (err) {
|
|
411
|
+
console.warn('managed: private repo list failed:', err)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return { repositories: mergeRepos(...lists), tokenMissing }
|
|
415
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { HttpError } from '@preventive/upstream/github.js'
|
|
2
|
+
|
|
3
|
+
// A failure carrying the HTTP status the router should surface. 401 passes
|
|
4
|
+
// through so the user-token path can map it to "log in again".
|
|
5
|
+
export class GithubApiError extends Error {
|
|
6
|
+
status: number
|
|
7
|
+
retryAfter: number | null
|
|
8
|
+
// What failed, where GitHub wasn't reached (githubUnreachable).
|
|
9
|
+
detail: string | null
|
|
10
|
+
constructor(status: number, message: string, retryAfter: number | null = null, detail: string | null = null) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = 'GithubApiError'
|
|
13
|
+
this.status = status
|
|
14
|
+
this.retryAfter = retryAfter
|
|
15
|
+
this.detail = detail
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// GitHub not reached, or its answer not read whole, `started` ms since the
|
|
20
|
+
// epoch: as `github-unreachable`, with what failed as the error and its causes
|
|
21
|
+
// tell it (a timeout, a connection closed, an answer too large) and after how
|
|
22
|
+
// long, for an admin's page to show (repository-risk.ts). Only the errors'
|
|
23
|
+
// names, codes and messages, never GitHub's text nor a request's headers.
|
|
24
|
+
export function githubUnreachable(error: unknown, started: number): GithubApiError {
|
|
25
|
+
const told: string[] = []
|
|
26
|
+
for (let at = error, depth = 0; at instanceof Error && depth < 4; at = at.cause, depth++) {
|
|
27
|
+
const code = 'code' in at && typeof at.code === 'string' && at.code !== at.name ? ` ${at.code}` : ''
|
|
28
|
+
told.push(`${at.name}${code}${at.message ? `: ${at.message.slice(0, 200)}` : ''}`)
|
|
29
|
+
}
|
|
30
|
+
const failed = told.length > 0 ? told.join('; ') : String(error).slice(0, 200)
|
|
31
|
+
return new GithubApiError(502, 'github-unreachable', null, `${failed}, after ${((Date.now() - started) / 1000).toFixed(1)}s`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function throwGithubResponseError(res: Response): Promise<never> {
|
|
35
|
+
if (res.status === 403 || res.status === 429) {
|
|
36
|
+
const body = await res.json().catch(() => null) as { message?: unknown } | null
|
|
37
|
+
if (res.status === 429 || res.headers.get('x-ratelimit-remaining') === '0' || res.headers.has('retry-after')
|
|
38
|
+
|| (typeof body?.message === 'string' && /rate limit|abuse detection/iu.test(body.message))) {
|
|
39
|
+
const retry = Number(res.headers.get('retry-after'))
|
|
40
|
+
const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000
|
|
41
|
+
const wait = Math.max(Number.isFinite(retry) ? retry : 0, Number.isFinite(reset) ? (reset - Date.now()) / 1000 : 0)
|
|
42
|
+
throw new GithubApiError(429, 'github-rate-limited', Math.max(60, Math.ceil(wait)))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (res.status === 401) throw new GithubApiError(401, 'github-unauthorized')
|
|
46
|
+
if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
|
|
47
|
+
throw new GithubApiError(502, `github-status-${res.status}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function upstreamGithub<T>(request: () => Promise<T>): Promise<T> {
|
|
51
|
+
const started = Date.now()
|
|
52
|
+
try { return await request() } catch (error) {
|
|
53
|
+
// Already told as the router surfaces it.
|
|
54
|
+
if (error instanceof GithubApiError) throw error
|
|
55
|
+
if (error instanceof HttpError) {
|
|
56
|
+
// The upstream error includes GitHub's response text. Retain managed
|
|
57
|
+
// status handling, including rate limits, without exposing that text.
|
|
58
|
+
if (error.status === 401) throw new GithubApiError(401, 'github-unauthorized')
|
|
59
|
+
if (error.status === 404) throw new GithubApiError(404, 'github-not-found')
|
|
60
|
+
if (error.status === 429 || (error.status === 403 && /rate limit|abuse detection/iu.test(error.message))) {
|
|
61
|
+
throw new GithubApiError(429, 'github-rate-limited', 60)
|
|
62
|
+
}
|
|
63
|
+
throw new GithubApiError(502, `github-status-${error.status}`)
|
|
64
|
+
}
|
|
65
|
+
if (error instanceof Error && (error.name === 'AssertionError' || error.cause instanceof SyntaxError)) {
|
|
66
|
+
throw new GithubApiError(502, 'github-malformed')
|
|
67
|
+
}
|
|
68
|
+
throw githubUnreachable(error, started)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// GitHub's GraphQL API, a query at a time, as the repository readers ask it
|
|
2
|
+
// (repository-history.ts, repository-agents.ts, repository-pulse.ts and the rest).
|
|
3
|
+
import { GithubApiError, githubJson } from './github-app.ts'
|
|
4
|
+
|
|
5
|
+
export type GraphqlError = { type?: unknown; path?: unknown; message?: unknown }
|
|
6
|
+
|
|
7
|
+
const NAMED_ERRORS = new Set(['RATE_LIMITED', 'NOT_FOUND', 'FORBIDDEN'])
|
|
8
|
+
// GitHub's own words for a query it stopped past its time (ten seconds):
|
|
9
|
+
// "Something went wrong while executing your query. This may be the result
|
|
10
|
+
// of a timeout, …", with no type.
|
|
11
|
+
const TIMED_OUT = /\btimeout\b/iu
|
|
12
|
+
|
|
13
|
+
// One GraphQL query with `token`, answered with its `data`: one GitHub answers
|
|
14
|
+
// with errors, but for those the caller reads past (`tolerated`, the fields
|
|
15
|
+
// they name then null), is rate limited, names what isn't there, is refused,
|
|
16
|
+
// as one a GitHub App lacks the permission for is, or a token the scope for
|
|
17
|
+
// (`INSUFFICIENT_SCOPES`, a sign-in's having none), took GitHub too long, or
|
|
18
|
+
// else is malformed, as one GitHub rejects unread is (a field or type its API
|
|
19
|
+
// doesn't know). GitHub's words for errors of none of those first three types
|
|
20
|
+
// are logged, those read past too.
|
|
21
|
+
export async function githubGraphql(query: string, variables: Record<string, unknown>, token: string, fetchImpl: typeof fetch,
|
|
22
|
+
tolerated: (error: GraphqlError) => boolean = () => false) {
|
|
23
|
+
const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }, true) as { data?: unknown; errors?: GraphqlError[] } | null
|
|
24
|
+
const all = Array.isArray(body?.errors) ? body.errors : []
|
|
25
|
+
const unnamed = all.filter(error => !NAMED_ERRORS.has(String(error?.type)))
|
|
26
|
+
if (unnamed.length > 0) {
|
|
27
|
+
console.warn('managed: GitHub answered a query with errors:', JSON.stringify(unnamed.slice(0, 3)
|
|
28
|
+
.map(error => ({ type: error?.type, message: String(error?.message).slice(0, 300), path: error?.path }))))
|
|
29
|
+
}
|
|
30
|
+
const errors = all.filter(error => !tolerated(error))
|
|
31
|
+
if (errors.length > 0) {
|
|
32
|
+
if (errors.some(error => error?.type === 'RATE_LIMITED')) throw new GithubApiError(429, 'github-rate-limited', 60)
|
|
33
|
+
if (errors.some(error => error?.type === 'NOT_FOUND')) throw new GithubApiError(404, 'github-not-found')
|
|
34
|
+
if (errors.some(error => error?.type === 'FORBIDDEN' || error?.type === 'INSUFFICIENT_SCOPES')) throw new GithubApiError(502, 'github-status-403')
|
|
35
|
+
if (errors.some(error => typeof error?.message === 'string' && TIMED_OUT.test(error.message))) throw new GithubApiError(502, 'github-timeout')
|
|
36
|
+
throw new GithubApiError(502, 'github-malformed')
|
|
37
|
+
}
|
|
38
|
+
return body?.data
|
|
39
|
+
}
|