@preventive/triage 1.0.0-alpha.21 → 1.0.0-alpha.23
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/common/bundle-source-links.d.ts +5 -0
- package/common/bundle-source-links.js +82 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/managed/routes.js +3 -0
- package/common/server-info.ts +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +74 -58
- package/out/client-sync.js +3 -3
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +6 -6
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +186 -159
- package/package.json +12 -1
- package/server-e2e/app.ts +2 -1
- package/server-e2e/config.ts +2 -0
- package/server-managed/config.ts +9 -16
- package/server-managed/db-methods.ts +52 -21
- package/server-managed/db-neon.ts +6 -1
- package/server-managed/db-schema.ts +1 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/github-app.ts +107 -49
- package/server-managed/github-errors.ts +28 -0
- package/server-managed/github-issue-oauth.ts +48 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-oauth.ts +7 -23
- package/server-managed/http.ts +181 -9
- package/server-managed/managed-issues.ts +47 -0
- package/server-managed/report-sources.ts +4 -2
- package/server-managed/repository-browser.ts +127 -0
- package/server-managed/workspace-shares.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.23",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -109,12 +109,16 @@
|
|
|
109
109
|
"server-managed/db-table-names.ts",
|
|
110
110
|
"server-managed/db.ts",
|
|
111
111
|
"server-managed/github-app.ts",
|
|
112
|
+
"server-managed/github-errors.ts",
|
|
112
113
|
"server-managed/github-metadata.ts",
|
|
114
|
+
"server-managed/github-issue-oauth.ts",
|
|
115
|
+
"server-managed/github-issues.ts",
|
|
113
116
|
"server-managed/github-oauth.ts",
|
|
114
117
|
"server-managed/github-pulls.ts",
|
|
115
118
|
"server-managed/http-response.ts",
|
|
116
119
|
"server-managed/http.ts",
|
|
117
120
|
"server-managed/import-triage.ts",
|
|
121
|
+
"server-managed/managed-issues.ts",
|
|
118
122
|
"server-managed/index.ts",
|
|
119
123
|
"server-managed/public-workspace.ts",
|
|
120
124
|
"server-managed/repo-path.ts",
|
|
@@ -122,6 +126,7 @@
|
|
|
122
126
|
"server-managed/report-query.ts",
|
|
123
127
|
"server-managed/report-response.ts",
|
|
124
128
|
"server-managed/report-sources.ts",
|
|
129
|
+
"server-managed/repository-browser.ts",
|
|
125
130
|
"server-managed/repository-discovery.ts",
|
|
126
131
|
"server-managed/repository-policy.ts",
|
|
127
132
|
"server-managed/session.ts",
|
|
@@ -172,12 +177,16 @@
|
|
|
172
177
|
"common/bundle-reasons.js",
|
|
173
178
|
"common/bundle-sources.d.ts",
|
|
174
179
|
"common/bundle-sources.js",
|
|
180
|
+
"common/bundle-source-links.d.ts",
|
|
181
|
+
"common/bundle-source-links.js",
|
|
175
182
|
"common/bundle-stats.js",
|
|
176
183
|
"common/bundle-tabs.js",
|
|
177
184
|
"common/code-language.js",
|
|
178
185
|
"common/default-scan-models.ts",
|
|
179
186
|
"common/finding-id.js",
|
|
180
187
|
"common/github-pr.ts",
|
|
188
|
+
"common/github-issue-labels.js",
|
|
189
|
+
"common/github-issue-labels.d.ts",
|
|
181
190
|
"common/managed/comments.ts",
|
|
182
191
|
"common/managed/permissions.ts",
|
|
183
192
|
"common/managed/report-content.ts",
|
|
@@ -200,6 +209,8 @@
|
|
|
200
209
|
"out/client-managed-import.js",
|
|
201
210
|
"out/client-sync.js",
|
|
202
211
|
"out/graph.js",
|
|
212
|
+
"out/github-issue-authorized.html",
|
|
213
|
+
"out/github-issue-authorized.js",
|
|
203
214
|
"out/icon-maskable.svg",
|
|
204
215
|
"out/icon.svg",
|
|
205
216
|
"out/index.html",
|
package/server-e2e/app.ts
CHANGED
|
@@ -348,7 +348,8 @@ async function assembleE2eApp(config: Config, rollback: AsyncDisposableStack) {
|
|
|
348
348
|
// The sync protocol this build advertises — emitted as a `server-info` frame
|
|
349
349
|
// right after the challenge on every connection. This is the e2e boot, so it
|
|
350
350
|
// always advertises e2e.
|
|
351
|
-
const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null, ...(config.deepviewScanServer ? { deepviewScanServer: config.deepviewScanServer } : {})
|
|
351
|
+
const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null, ...(config.deepviewScanServer ? { deepviewScanServer: config.deepviewScanServer } : {}),
|
|
352
|
+
...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}) }
|
|
352
353
|
|
|
353
354
|
// Shared per-connection dispatch surface. Both the WS plane
|
|
354
355
|
// (installWsServer below) and the SSE+POST fallback (installSseServer
|
package/server-e2e/config.ts
CHANGED
|
@@ -28,6 +28,7 @@ export type Config = {
|
|
|
28
28
|
password: string | null
|
|
29
29
|
trustProxyEnv: string | undefined
|
|
30
30
|
deepviewScanServer: string | null
|
|
31
|
+
githubNewIssueLabels?: string
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
// Parse + range-validate an integer env var, throwing a clear
|
|
@@ -207,5 +208,6 @@ export function loadConfig(): Config {
|
|
|
207
208
|
debug, neonUrl, blobToken, tokenSecret, password,
|
|
208
209
|
trustProxyEnv: env['TRUST_PROXY'],
|
|
209
210
|
deepviewScanServer: configuredScanServer(env['DEEPVIEW_SCAN_SERVER']),
|
|
211
|
+
githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
|
|
210
212
|
}
|
|
211
213
|
}
|
package/server-managed/config.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
// Managed-server boot config
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// optional, SEPARATE GitHub App (id + private key + slug) enables PRIVATE repo
|
|
5
|
-
// listing via installation tokens — kept distinct from the login App so its
|
|
6
|
-
// Contents permission never lands on the login consent. The sync protocol is
|
|
7
|
-
// not parsed here yet.
|
|
1
|
+
// Managed-server boot config. GitHub user authorization establishes identity;
|
|
2
|
+
// optional App installation credentials enable repository access. A single
|
|
3
|
+
// GitHub App can provide both flows, with repository grants at installation.
|
|
8
4
|
import { env } from 'node:process'
|
|
9
5
|
import { databaseUrls } from '../server-common/database-config.ts'
|
|
10
6
|
|
|
@@ -35,21 +31,17 @@ export interface ManagedConfig {
|
|
|
35
31
|
cookieSecure: boolean
|
|
36
32
|
sessionCookieName: string
|
|
37
33
|
sessionTtlMs: number
|
|
38
|
-
// Optional
|
|
39
|
-
//
|
|
40
|
-
// consent never touches login) and is read via installation tokens: the id +
|
|
41
|
-
// PEM private key mint them; the slug builds the "Connect a repository"
|
|
42
|
-
// install URL. id/key absent → private repos are off (public-only listing);
|
|
43
|
-
// slug absent → no install link.
|
|
34
|
+
// Optional installation credentials for the same GitHub App as login.
|
|
35
|
+
// Repository permissions are approved when connecting repositories.
|
|
44
36
|
githubAppId: string | null
|
|
45
37
|
githubAppPrivateKey: string | null
|
|
46
38
|
githubAppSlug: string | null
|
|
39
|
+
githubNewIssueLabels?: string
|
|
47
40
|
// Max accepted size (bytes) for an uploaded report on the "Manage reports"
|
|
48
41
|
// page. Reports are findings dumps (JSON / markdown / CSV), small to a few MB.
|
|
49
42
|
maxReportBytes: number
|
|
50
43
|
// Max accepted size (bytes) for an uploaded bundle (sourcemap / stasis
|
|
51
|
-
// archive). Bundles run larger than reports, so a higher cap (default
|
|
52
|
-
// matching the e2e objstore per-upload cap).
|
|
44
|
+
// archive). Bundles run larger than reports, so a higher cap (default 200 MiB).
|
|
53
45
|
maxBundleBytes: number
|
|
54
46
|
// How many triage-trail events to keep per finding (managed_finding_triage_event):
|
|
55
47
|
// 0, the default, keeps everything — the trail is the record. An operator
|
|
@@ -130,8 +122,9 @@ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
|
|
|
130
122
|
githubAppId: env['GITHUB_APP_ID'] ?? null,
|
|
131
123
|
githubAppPrivateKey: normalizePem(env['GITHUB_APP_PRIVATE_KEY']),
|
|
132
124
|
githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
|
|
125
|
+
githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
|
|
133
126
|
maxReportBytes: intEnv('MAX_REPORT_BYTES', 10_485_760, 1, 104_857_600),
|
|
134
|
-
maxBundleBytes: intEnv('MAX_BUNDLE_BYTES',
|
|
127
|
+
maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 209_715_200, 1, 1_073_741_824),
|
|
135
128
|
triageHistoryLimit: intEnv('TRIAGE_HISTORY_LIMIT', 0, 0, 1_000_000_000),
|
|
136
129
|
}
|
|
137
130
|
}
|
|
@@ -7,6 +7,7 @@ import { preferredSlug } from './slugs.ts'
|
|
|
7
7
|
import { type CommentStore, commentMethods } from './comments.ts'
|
|
8
8
|
import { type ActivityStore, activityMethods } from './activity.ts'
|
|
9
9
|
import { type GithubMetadataStore, githubMetadataMethods } from './github-metadata.ts'
|
|
10
|
+
import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
|
|
10
11
|
import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
|
|
11
12
|
import type { ManagedSql } from './sql.ts'
|
|
12
13
|
import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
|
|
@@ -280,6 +281,7 @@ export interface UserTeamReport {
|
|
|
280
281
|
id: string
|
|
281
282
|
slug: string
|
|
282
283
|
filename: string
|
|
284
|
+
analyzer: string | null
|
|
283
285
|
repoFullName: string | null
|
|
284
286
|
repoDirectory: string
|
|
285
287
|
cacheKey: string
|
|
@@ -305,7 +307,7 @@ export interface UserTeam {
|
|
|
305
307
|
}
|
|
306
308
|
|
|
307
309
|
// Backend-agnostic store surface (SQLite + PostgreSQL implementations).
|
|
308
|
-
export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, WorkspaceShareStore, ImportTriageStore {
|
|
310
|
+
export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore {
|
|
309
311
|
// Upsert the identity; returns the user's opaque id (stable across logins).
|
|
310
312
|
// Initial-admin approval comes only from trusted login configuration. It
|
|
311
313
|
// promotes a matching No access identity only while it is the sole user.
|
|
@@ -325,6 +327,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
325
327
|
// mutable context while keeping the original added_by/added_at; deselectRepo
|
|
326
328
|
// resolves true iff a row was removed.
|
|
327
329
|
selectRepo(repo: SelectedRepoInput, now: number): Promise<void>
|
|
330
|
+
connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean>
|
|
328
331
|
deselectRepo(repoId: number): Promise<boolean>
|
|
329
332
|
listSelectedRepos(): Promise<SelectedRepo[]>
|
|
330
333
|
listAllRepos(): Promise<ManagedRepo[]>
|
|
@@ -344,6 +347,10 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
344
347
|
// getReport reads one row (for download); deleteReport resolves true iff a row
|
|
345
348
|
// was removed.
|
|
346
349
|
insertReport(report: ReportRecordInput, now: number): Promise<void>
|
|
350
|
+
// Content reuse is atomic with insertion, including across server instances.
|
|
351
|
+
// Existing copies are kept; the oldest matching row is the stable identity.
|
|
352
|
+
insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord>
|
|
353
|
+
getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
|
|
347
354
|
listReports(userId?: string): Promise<AdminReport[]>
|
|
348
355
|
getReport(id: string): Promise<ReportRecord | null>
|
|
349
356
|
getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
|
|
@@ -504,6 +511,12 @@ function prepareStatements(db: ManagedSql) {
|
|
|
504
511
|
installation_id = excluded.installation_id, default_branch = excluded.default_branch,
|
|
505
512
|
html_url = excluded.html_url, updated_at = excluded.updated_at, active = 1`,
|
|
506
513
|
),
|
|
514
|
+
// Never reactivate or recreate a repo, overwrite another connection, or
|
|
515
|
+
// commit after the administrator's session/role was revoked during GitHub I/O.
|
|
516
|
+
connectRepoInstallationStmt: db.prepare(`UPDATE managed_selected_repo SET installation_id = ?, updated_at = ?
|
|
517
|
+
WHERE repo_id = ? AND full_name = ? AND added_at = ? AND installation_id IS NULL
|
|
518
|
+
AND EXISTS (SELECT 1 FROM managed_session s JOIN managed_user u ON u.id = s.user_id
|
|
519
|
+
WHERE s.id = ? AND s.expires_at > ? AND u.role = 'admin')`),
|
|
507
520
|
deleteRepoStmt: db.prepare(`DELETE FROM managed_selected_repo WHERE repo_id = ?`),
|
|
508
521
|
deactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 0 WHERE repo_id = ? AND active = 1`),
|
|
509
522
|
reactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 1 WHERE repo_id = ? AND active = 0`),
|
|
@@ -563,6 +576,10 @@ function prepareStatements(db: ManagedSql) {
|
|
|
563
576
|
analyzer AS analyzer, visible AS visible, bundle_id AS bundleId
|
|
564
577
|
FROM managed_report WHERE id = ?`,
|
|
565
578
|
),
|
|
579
|
+
selectReportByHashStmt: db.prepare(
|
|
580
|
+
`SELECT id FROM managed_report WHERE sha256 = ? AND COALESCE(analyzer, '') = COALESCE(?, '')
|
|
581
|
+
ORDER BY uploaded_at, id LIMIT 1`,
|
|
582
|
+
),
|
|
566
583
|
selectReportAccessStmt: db.prepare(
|
|
567
584
|
`WITH requested AS (SELECT value AS id FROM json_each(?)), report_grants AS (
|
|
568
585
|
SELECT r.id AS id, MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
|
|
@@ -732,7 +749,7 @@ function prepareStatements(db: ManagedSql) {
|
|
|
732
749
|
// Reports attached to the repos of the user's teams, tagged by team (a report
|
|
733
750
|
// shows under every team whose repo it's attached to). Newest first.
|
|
734
751
|
selectUserTeamReportsStmt: db.prepare(
|
|
735
|
-
`SELECT DISTINCT tr.team_id AS teamId, r.id AS id, r.slug AS slug, r.filename AS filename, r.uploaded_at,
|
|
752
|
+
`SELECT DISTINCT tr.team_id AS teamId, r.id AS id, r.slug AS slug, r.filename AS filename, r.analyzer, r.uploaded_at,
|
|
736
753
|
r.sha256 AS sha256, r.bundle_id AS bundleId, r.repo_id AS repoId, r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
|
|
737
754
|
tu.view_dependencies AS dependencies, tu.view_security AS security,
|
|
738
755
|
(SELECT role FROM managed_user WHERE id = tu.user_id) AS role
|
|
@@ -837,6 +854,9 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
837
854
|
async deselectRepo(repoId: number): Promise<boolean> {
|
|
838
855
|
return Number((await deleteRepoStmt.run(repoId)).changes) > 0
|
|
839
856
|
},
|
|
857
|
+
async connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean> {
|
|
858
|
+
return Number((await stmts.connectRepoInstallationStmt.run(installationId, now, repo.repoId, repo.fullName, repo.addedAt, sessionId, now)).changes) > 0
|
|
859
|
+
},
|
|
840
860
|
async listSelectedRepos(): Promise<SelectedRepo[]> {
|
|
841
861
|
const rows = (await selectReposStmt.all()) as RepoRow[]
|
|
842
862
|
return rows.map(readRepo)
|
|
@@ -888,14 +908,34 @@ type ReportRow = {
|
|
|
888
908
|
// openSqliteManagedDb small. Closes over its prepared statements.
|
|
889
909
|
function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
890
910
|
const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
|
|
911
|
+
async function getReport(id: string): Promise<ReportRecord | null> {
|
|
912
|
+
const row = (await selectReportStmt.get(id)) as ReportRow | undefined
|
|
913
|
+
if (row == null) return null
|
|
914
|
+
return { ...row, repoEmbedded: row.repoEmbedded === 1, visible: row.visible === 1 }
|
|
915
|
+
}
|
|
916
|
+
async function getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null> {
|
|
917
|
+
const row = await stmts.selectReportByHashStmt.get(sha256, analyzer) as { id: string } | undefined
|
|
918
|
+
return row ? getReport(row.id) : null
|
|
919
|
+
}
|
|
920
|
+
async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
|
|
921
|
+
await insertReportStmt.run(
|
|
922
|
+
report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
|
|
923
|
+
report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
|
|
924
|
+
report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
|
|
925
|
+
report.bundleIntegrity ?? null, now,
|
|
926
|
+
)
|
|
927
|
+
}
|
|
891
928
|
return {
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
)
|
|
929
|
+
insertReport,
|
|
930
|
+
getReport,
|
|
931
|
+
getReportByHash,
|
|
932
|
+
async insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord> {
|
|
933
|
+
// The driver holds its writer lock for this whole operation. Checking
|
|
934
|
+
// again here closes the race between the HTTP lookup and blob upload.
|
|
935
|
+
const existing = await getReportByHash(report.sha256, report.analyzer ?? null)
|
|
936
|
+
if (existing) return existing
|
|
937
|
+
await insertReport(report, now)
|
|
938
|
+
return (await getReport(report.id))!
|
|
899
939
|
},
|
|
900
940
|
async listReports(userId?: string): Promise<AdminReport[]> {
|
|
901
941
|
const rows = (await selectReportsStmt.all(userId ?? null, userId ?? null, userId ?? null)) as ReportListRow[]
|
|
@@ -908,16 +948,6 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
908
948
|
uploadedAt: r.uploadedAt,
|
|
909
949
|
}))
|
|
910
950
|
},
|
|
911
|
-
async getReport(id: string): Promise<ReportRecord | null> {
|
|
912
|
-
const row = (await selectReportStmt.get(id)) as ReportRow | undefined
|
|
913
|
-
if (row == null) return null
|
|
914
|
-
return {
|
|
915
|
-
id: row.id, slug: row.slug, filename: row.filename, contentType: row.contentType, byteSize: row.byteSize,
|
|
916
|
-
sha256: row.sha256, uploadedBy: row.uploadedBy, uploadedAt: row.uploadedAt,
|
|
917
|
-
repoId: row.repoId, repoDirectory: row.repoDirectory, repoEmbedded: row.repoEmbedded === 1, analyzer: row.analyzer, visible: row.visible === 1,
|
|
918
|
-
bundleId: row.bundleId,
|
|
919
|
-
}
|
|
920
|
-
},
|
|
921
951
|
async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
|
|
922
952
|
const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
|
|
923
953
|
if (!session) return null
|
|
@@ -1165,14 +1195,14 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
|
|
|
1165
1195
|
const scopes = await stmts.selectUserTeamScopesStmt.all(userId) as { teamId: string; repoId: number; path: string; fullName: string }[]
|
|
1166
1196
|
const reportsByTeam = new Map<string, UserTeamReport[]>()
|
|
1167
1197
|
for (const r of (await selectUserTeamReportsStmt.all(userId)) as {
|
|
1168
|
-
teamId: string; id: string; slug: string; filename: string; sha256: string; bundleId: string | null; repoId: number
|
|
1198
|
+
teamId: string; id: string; slug: string; filename: string; analyzer: string | null; sha256: string; bundleId: string | null; repoId: number
|
|
1169
1199
|
repoDirectory: string; repoFullName: string | null; dependencies: number; security: number; role: string
|
|
1170
1200
|
}[]) {
|
|
1171
1201
|
const list = reportsByTeam.get(r.teamId) ?? []
|
|
1172
1202
|
const cacheKey = createHash('sha256').update(JSON.stringify([
|
|
1173
1203
|
r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role,
|
|
1174
1204
|
])).digest('base64url')
|
|
1175
|
-
list.push({ id: r.id, slug: r.slug, filename: r.filename, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, cacheKey })
|
|
1205
|
+
list.push({ id: r.id, slug: r.slug, filename: r.filename, analyzer: r.analyzer, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, cacheKey })
|
|
1176
1206
|
reportsByTeam.set(r.teamId, list)
|
|
1177
1207
|
}
|
|
1178
1208
|
const bundlesByTeam = new Map<string, UserTeamBundle[]>()
|
|
@@ -1320,6 +1350,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1320
1350
|
return row?.githubId ?? null
|
|
1321
1351
|
},
|
|
1322
1352
|
...githubMetadataMethods(db),
|
|
1353
|
+
...managedIssueMethods(db),
|
|
1323
1354
|
...selectedRepoMethods(stmts),
|
|
1324
1355
|
...activity,
|
|
1325
1356
|
...comments,
|
|
@@ -3,6 +3,7 @@ import { WebSocket } from 'ws'
|
|
|
3
3
|
import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
|
|
4
4
|
import { MANAGED_SCHEMA } from './db-schema.ts'
|
|
5
5
|
import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
6
|
+
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
6
7
|
import { COMMENT_SCHEMA } from './comments.ts'
|
|
7
8
|
import { ACTIVITY_SCHEMA } from './activity.ts'
|
|
8
9
|
import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
|
|
@@ -56,7 +57,7 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
56
57
|
await db.query(postgresSchema(WORKSPACE_SHARE_SCHEMA))
|
|
57
58
|
await db.query('INSERT INTO managed_schema_version VALUES (4)')
|
|
58
59
|
}
|
|
59
|
-
await db.query(postgresSchema(GITHUB_METADATA_SCHEMA))
|
|
60
|
+
await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA))
|
|
60
61
|
await db.query(`ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS state_reason ${GITHUB_STATE_REASON_COLUMN}`)
|
|
61
62
|
await db.query('ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS attempted_at BIGINT')
|
|
62
63
|
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 5')).rows.length === 0) {
|
|
@@ -78,6 +79,10 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
78
79
|
await db.query("ALTER TABLE managed_bundle ADD COLUMN IF NOT EXISTS repo_directory TEXT NOT NULL DEFAULT ''")
|
|
79
80
|
await db.query('INSERT INTO managed_schema_version VALUES (7)')
|
|
80
81
|
}
|
|
82
|
+
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 8')).rows.length === 0) {
|
|
83
|
+
await db.query('CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id)')
|
|
84
|
+
await db.query('INSERT INTO managed_schema_version VALUES (8)')
|
|
85
|
+
}
|
|
81
86
|
await db.query('COMMIT')
|
|
82
87
|
} catch (err) {
|
|
83
88
|
await db.query('ROLLBACK')
|
|
@@ -108,6 +108,7 @@ CREATE TABLE IF NOT EXISTS managed_report (
|
|
|
108
108
|
) STRICT;
|
|
109
109
|
|
|
110
110
|
CREATE INDEX IF NOT EXISTS managed_report_uploaded_at_idx ON managed_report(uploaded_at);
|
|
111
|
+
CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id);
|
|
111
112
|
CREATE INDEX IF NOT EXISTS managed_report_bundle_integrity_idx ON managed_report(bundle_integrity);
|
|
112
113
|
CREATE INDEX IF NOT EXISTS managed_report_bundle_hash_idx ON managed_report(bundle_id, sha256);
|
|
113
114
|
|
package/server-managed/db.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { initCommentMethods } from './comments.ts'
|
|
|
8
8
|
import { initActivityMethods } from './activity.ts'
|
|
9
9
|
import { MANAGED_SCHEMA } from './db-schema.ts'
|
|
10
10
|
import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
11
|
+
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
11
12
|
import { managedTableRenames } from './db-table-names.ts'
|
|
12
13
|
import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
|
|
13
14
|
import { createSqliteDriver, scopeManagedMethods } from './sql.ts'
|
|
@@ -64,7 +65,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
64
65
|
db.exec('PRAGMA synchronous = FULL;')
|
|
65
66
|
db.exec('PRAGMA foreign_keys = ON;')
|
|
66
67
|
migrateManagedTableNames(db)
|
|
67
|
-
db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA)
|
|
68
|
+
db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA)
|
|
68
69
|
ensureColumn(db, 'managed_github_metadata', 'state_reason', GITHUB_STATE_REASON_COLUMN)
|
|
69
70
|
ensureColumn(db, 'managed_github_metadata', 'attempted_at', 'INTEGER')
|
|
70
71
|
// Existing public links must opt in too; never preserve an implicit grant.
|
|
@@ -1,23 +1,32 @@
|
|
|
1
|
-
// Repository
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// identity-only login token. No installation; the login App needs no
|
|
6
|
-
// repository permissions, so login never shows "Act on your behalf".
|
|
7
|
-
// PRIVATE — a SEPARATE GitHub App (Contents: Read) the user installs on the
|
|
8
|
-
// repos they want; read server-side via installation tokens. That
|
|
9
|
-
// App's Contents permission lives on IT, never on the login App, so
|
|
10
|
-
// login stays clean. "Connect a repository" installs it (installUrl).
|
|
11
|
-
//
|
|
12
|
-
// Archived repos are skipped. Nothing here writes.
|
|
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.
|
|
13
5
|
import { Buffer } from 'node:buffer'
|
|
14
|
-
import { createSign } from 'node:crypto'
|
|
6
|
+
import { createHash, createSign } from 'node:crypto'
|
|
15
7
|
import type { ManagedConfig } from './config.ts'
|
|
8
|
+
import { GithubApiError, throwGithubResponseError } from './github-errors.ts'
|
|
9
|
+
|
|
10
|
+
export { GithubApiError } from './github-errors.ts'
|
|
16
11
|
|
|
17
12
|
const GITHUB_API = 'https://api.github.com'
|
|
18
13
|
const API_VERSION = '2022-11-28'
|
|
19
14
|
const USER_AGENT = 'deepview-triage'
|
|
20
15
|
const PER_PAGE = 100
|
|
16
|
+
const INSTALLATION_TOKEN_TTL_MS = 5 * 60_000
|
|
17
|
+
const INSTALLATION_TOKEN_EXPIRY_MARGIN_MS = 60_000
|
|
18
|
+
const MAX_INSTALLATION_TOKENS = 256
|
|
19
|
+
interface InstallationTokenEntry { token: string | null; expiresAt: number; pending: Promise<string> | null }
|
|
20
|
+
// Credentials only: visibility, user identities, and permissions are never
|
|
21
|
+
// cached here. Bound memory per transport and separate App/key/install scopes.
|
|
22
|
+
const installationTokens = new WeakMap<typeof fetch, Map<string, InstallationTokenEntry>>()
|
|
23
|
+
|
|
24
|
+
function discardInstallationToken(token: string | null, fetchImpl: typeof fetch): void {
|
|
25
|
+
if (!token) return
|
|
26
|
+
const cache = installationTokens.get(fetchImpl)
|
|
27
|
+
if (!cache) return
|
|
28
|
+
for (const [key, entry] of cache) if (entry.token === token) cache.delete(key)
|
|
29
|
+
}
|
|
21
30
|
|
|
22
31
|
// One listed repository. Carries the context to read its contents later:
|
|
23
32
|
// `installationId` mints an App installation token (Contents: Read) for a repo
|
|
@@ -33,18 +42,7 @@ export interface ConnectedRepo {
|
|
|
33
42
|
installationId: number | null
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
//
|
|
37
|
-
// through so the user-token path can map it to "log in again".
|
|
38
|
-
export class GithubApiError extends Error {
|
|
39
|
-
status: number
|
|
40
|
-
constructor(status: number, message: string) {
|
|
41
|
-
super(message)
|
|
42
|
-
this.name = 'GithubApiError'
|
|
43
|
-
this.status = status
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// The separate repositories App is configured (id + private key present) →
|
|
45
|
+
// App installation credentials are configured (id + private key present) →
|
|
48
46
|
// private repos can be listed via its installation tokens.
|
|
49
47
|
export function githubAppConfigured(config: ManagedConfig): boolean {
|
|
50
48
|
return config.githubAppId != null && config.githubAppPrivateKey != null
|
|
@@ -70,7 +68,7 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
|
|
|
70
68
|
// One GitHub API call returning parsed JSON, optionally Bearer-authed by a
|
|
71
69
|
// user token, App JWT, or installation token. Network / non-2xx / malformed
|
|
72
70
|
// fold into a GithubApiError (401 passes through for the caller to handle).
|
|
73
|
-
async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET'): Promise<unknown> {
|
|
71
|
+
export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET'): Promise<unknown> {
|
|
74
72
|
let res: Response
|
|
75
73
|
try {
|
|
76
74
|
res = await fetchImpl(url, {
|
|
@@ -83,9 +81,8 @@ async function githubJson(url: string, token: string | null, fetchImpl: typeof f
|
|
|
83
81
|
},
|
|
84
82
|
})
|
|
85
83
|
} catch { throw new GithubApiError(502, 'github-unreachable') }
|
|
86
|
-
if (res.status === 401)
|
|
87
|
-
if (res.
|
|
88
|
-
if (!res.ok) throw new GithubApiError(502, `github-status-${res.status}`)
|
|
84
|
+
if (res.status === 401) discardInstallationToken(token, fetchImpl)
|
|
85
|
+
if (!res.ok) await throwGithubResponseError(res)
|
|
89
86
|
try { return await res.json() } catch { throw new GithubApiError(502, 'github-malformed') }
|
|
90
87
|
}
|
|
91
88
|
|
|
@@ -156,7 +153,7 @@ export async function listUserRepos(accessToken: string, fetchImpl: typeof fetch
|
|
|
156
153
|
return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
|
|
157
154
|
}
|
|
158
155
|
|
|
159
|
-
// ── PRIVATE:
|
|
156
|
+
// ── PRIVATE: App installations via installation tokens ──
|
|
160
157
|
|
|
161
158
|
function base64url(input: Buffer | string): string {
|
|
162
159
|
return Buffer.from(input).toString('base64url')
|
|
@@ -188,13 +185,65 @@ async function listInstallationIds(jwt: string, fetchImpl: typeof fetch): Promis
|
|
|
188
185
|
return ids
|
|
189
186
|
}
|
|
190
187
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
async function
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
188
|
+
// Reuse installation credentials only in this process. Refresh well before
|
|
189
|
+
// expiry and coalesce concurrent mints; failed or undated tokens are not cached.
|
|
190
|
+
async function installationAccessToken(appId: string, privateKey: string, installId: number, fetchImpl: typeof fetch): Promise<string> {
|
|
191
|
+
let cache = installationTokens.get(fetchImpl)
|
|
192
|
+
if (!cache) { cache = new Map(); installationTokens.set(fetchImpl, cache) }
|
|
193
|
+
const now = Date.now()
|
|
194
|
+
for (const [key, entry] of cache) if (!entry.pending && entry.expiresAt <= now) cache.delete(key)
|
|
195
|
+
const key = JSON.stringify([appId, createHash('sha256').update(privateKey).digest('hex'), installId])
|
|
196
|
+
const cached = cache.get(key)
|
|
197
|
+
if (cached) {
|
|
198
|
+
cache.delete(key)
|
|
199
|
+
cache.set(key, cached)
|
|
200
|
+
if (cached.pending) return cached.pending
|
|
201
|
+
return cached.token!
|
|
202
|
+
}
|
|
203
|
+
const entry: InstallationTokenEntry = { token: null, expiresAt: 0, pending: null }
|
|
204
|
+
const pending = Promise.resolve().then(async () => {
|
|
205
|
+
try {
|
|
206
|
+
const jwt = appJwt(appId, privateKey)
|
|
207
|
+
const body = await githubJson(`${GITHUB_API}/app/installations/${installId}/access_tokens`, jwt, fetchImpl, 'POST') as { token?: unknown; expires_at?: unknown }
|
|
208
|
+
if (typeof body?.token !== 'string' || body.token === '') throw new GithubApiError(502, 'github-install-token-denied')
|
|
209
|
+
entry.token = body.token
|
|
210
|
+
const expiresAt = typeof body.expires_at === 'string' ? Date.parse(body.expires_at) : NaN
|
|
211
|
+
entry.expiresAt = Number.isFinite(expiresAt) ? Math.min(now + INSTALLATION_TOKEN_TTL_MS, expiresAt - INSTALLATION_TOKEN_EXPIRY_MARGIN_MS) : 0
|
|
212
|
+
if (entry.expiresAt <= Date.now() && cache.get(key) === entry) cache.delete(key)
|
|
213
|
+
return body.token
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (cache.get(key) === entry) cache.delete(key)
|
|
216
|
+
throw err
|
|
217
|
+
} finally { entry.pending = null }
|
|
218
|
+
})
|
|
219
|
+
entry.pending = pending
|
|
220
|
+
cache.set(key, entry)
|
|
221
|
+
if (cache.size > MAX_INSTALLATION_TOKENS) cache.delete(cache.keys().next().value!)
|
|
222
|
+
return await pending
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Resolve this exact repository with the App's identity, then confirm the
|
|
226
|
+
// installation token reaches the same numeric repository. A missing grant is
|
|
227
|
+
// actionable through installation; transport/auth/rate errors must stay errors.
|
|
228
|
+
export async function repositoryInstallation(config: ManagedConfig, repoId: number, fullName: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<number | null> {
|
|
229
|
+
const { githubAppId, githubAppPrivateKey } = config
|
|
230
|
+
if (githubAppId == null || githubAppPrivateKey == null) throw new GithubApiError(503, 'github-app-not-configured')
|
|
231
|
+
if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(409, 'repo-identity-changed')
|
|
232
|
+
const path = fullName.split('/').map(encodeURIComponent).join('/')
|
|
233
|
+
const jwt = appJwt(githubAppId, githubAppPrivateKey)
|
|
234
|
+
let raw
|
|
235
|
+
try { raw = await githubJson(`${GITHUB_API}/repos/${path}/installation`, jwt, fetchImpl) } catch (err) {
|
|
236
|
+
if (err instanceof GithubApiError && err.status === 404) return null
|
|
237
|
+
throw err
|
|
238
|
+
}
|
|
239
|
+
const installation = raw as { id?: unknown; suspended_at?: unknown; permissions?: { contents?: unknown } } | null
|
|
240
|
+
const id = installation?.id
|
|
241
|
+
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) throw new GithubApiError(502, 'github-malformed')
|
|
242
|
+
if (installation?.suspended_at != null || !['read', 'write'].includes(String(installation?.permissions?.contents))) return null
|
|
243
|
+
const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
|
|
244
|
+
const repo = parseRepo(await githubJson(`${GITHUB_API}/repos/${path}`, token, fetchImpl), id)
|
|
245
|
+
if (repo == null || repo.id !== repoId || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(409, 'repo-identity-changed')
|
|
246
|
+
return id
|
|
198
247
|
}
|
|
199
248
|
|
|
200
249
|
// The `repositories` array of one /installation/repositories page → ConnectedRepo[],
|
|
@@ -232,7 +281,7 @@ export async function listInstalledRepos(config: ManagedConfig, fetchImpl: typeo
|
|
|
232
281
|
if (githubAppId == null || githubAppPrivateKey == null) return []
|
|
233
282
|
const jwt = appJwt(githubAppId, githubAppPrivateKey)
|
|
234
283
|
const lists = await mapGithubRequests(await listInstallationIds(jwt, fetchImpl), async (id) => {
|
|
235
|
-
const token = await
|
|
284
|
+
const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
|
|
236
285
|
return listInstallationRepos(token, id, fetchImpl)
|
|
237
286
|
})
|
|
238
287
|
return mergeRepos(...lists)
|
|
@@ -260,9 +309,25 @@ async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>
|
|
|
260
309
|
// https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
|
|
261
310
|
// Resolve the current login from /user so renamed handles cannot check someone
|
|
262
311
|
// else's access. All repo paths come from GitHub's installation catalogue.
|
|
263
|
-
export async function
|
|
312
|
+
export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<{ id: number; login: string }> {
|
|
264
313
|
const identity = await githubJson(`${GITHUB_API}/user`, userToken, fetchImpl) as { id?: unknown; login?: unknown }
|
|
265
314
|
if (typeof identity?.login !== 'string' || !identity.login || typeof identity.id !== 'number' || !Number.isSafeInteger(identity.id) || identity.id <= 0) throw new GithubApiError(502, 'github-malformed')
|
|
315
|
+
return { id: identity.id, login: identity.login }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function githubRepoReadPermission(fullName: string, token: string, identity: { id: number; login: string }, fetchImpl: typeof fetch = globalThis.fetch): Promise<boolean> {
|
|
319
|
+
const path = fullName.split('/').map(encodeURIComponent).join('/')
|
|
320
|
+
try {
|
|
321
|
+
const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login)}/permission`, token, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
|
|
322
|
+
return body?.user?.id === identity.id && ['read', 'write', 'admin'].includes(String(body?.permission))
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (err instanceof GithubApiError && err.status === 404) return false
|
|
325
|
+
throw err // Never broaden access when GitHub cannot verify permission.
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function filterInstalledRepos(config: ManagedConfig, repositories: ConnectedRepo[], userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
|
|
330
|
+
const identity = await githubUserIdentity(userToken, fetchImpl)
|
|
266
331
|
const tokens = new Map<number, Promise<string | null>>()
|
|
267
332
|
const allowed = await mapGithubRequests(repositories, async (repo) => {
|
|
268
333
|
// Internal repos may have private=false; missing visibility is not proof
|
|
@@ -276,26 +341,19 @@ export async function filterInstalledRepos(config: ManagedConfig, repositories:
|
|
|
276
341
|
}
|
|
277
342
|
const accessToken = await token
|
|
278
343
|
if (!accessToken) return false
|
|
279
|
-
|
|
280
|
-
try {
|
|
281
|
-
const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login as string)}/permission`, accessToken, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
|
|
282
|
-
return body?.user?.id === identity.id && ['read', 'write', 'admin'].includes(String(body?.permission))
|
|
283
|
-
} catch (err) {
|
|
284
|
-
if (err instanceof GithubApiError && err.status === 404) return false
|
|
285
|
-
throw err // Never broaden the list when GitHub cannot verify access.
|
|
286
|
-
}
|
|
344
|
+
return githubRepoReadPermission(repo.fullName, accessToken, identity, fetchImpl)
|
|
287
345
|
})
|
|
288
346
|
return repositories.filter((_, index) => allowed[index])
|
|
289
347
|
}
|
|
290
348
|
|
|
291
|
-
//
|
|
349
|
+
// Get a token to READ a selected repo's contents: an installation token (the
|
|
292
350
|
// App's Contents: Read) when the repo was reached through an installation, or
|
|
293
351
|
// null for a public repo readable unauthenticated. This is the whole point of
|
|
294
352
|
// persisting `installationId` — the stored context is enough to read.
|
|
295
353
|
export async function repoAccessToken(config: ManagedConfig, installationId: number | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
|
|
296
354
|
const { githubAppId, githubAppPrivateKey } = config
|
|
297
355
|
if (installationId == null || githubAppId == null || githubAppPrivateKey == null) return null
|
|
298
|
-
return await
|
|
356
|
+
return await installationAccessToken(githubAppId, githubAppPrivateKey, installationId, fetchImpl)
|
|
299
357
|
}
|
|
300
358
|
|
|
301
359
|
// ── merged listing ──
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// A failure carrying the HTTP status the router should surface. 401 passes
|
|
2
|
+
// through so the user-token path can map it to "log in again".
|
|
3
|
+
export class GithubApiError extends Error {
|
|
4
|
+
status: number
|
|
5
|
+
retryAfter: number | null
|
|
6
|
+
constructor(status: number, message: string, retryAfter: number | null = null) {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = 'GithubApiError'
|
|
9
|
+
this.status = status
|
|
10
|
+
this.retryAfter = retryAfter
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function throwGithubResponseError(res: Response): Promise<never> {
|
|
15
|
+
if (res.status === 403 || res.status === 429) {
|
|
16
|
+
const body = await res.json().catch(() => null) as { message?: unknown } | null
|
|
17
|
+
if (res.status === 429 || res.headers.get('x-ratelimit-remaining') === '0' || res.headers.has('retry-after')
|
|
18
|
+
|| (typeof body?.message === 'string' && /rate limit|abuse detection/iu.test(body.message))) {
|
|
19
|
+
const retry = Number(res.headers.get('retry-after'))
|
|
20
|
+
const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000
|
|
21
|
+
const wait = Math.max(Number.isFinite(retry) ? retry : 0, Number.isFinite(reset) ? (reset - Date.now()) / 1000 : 0)
|
|
22
|
+
throw new GithubApiError(429, 'github-rate-limited', Math.max(60, Math.ceil(wait)))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (res.status === 401) throw new GithubApiError(401, 'github-unauthorized')
|
|
26
|
+
if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
|
|
27
|
+
throw new GithubApiError(502, `github-status-${res.status}`)
|
|
28
|
+
}
|