@preventive/triage 1.0.0-alpha.46 → 1.0.0-alpha.48
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/client/dependency-paths.d.ts +2 -1
- package/client/dependency-paths.js +16 -4
- package/common/managed/agent-tasks.ts +12 -12
- package/common/managed/github-agents.ts +29 -10
- package/out/activity.js +4 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C2AVODGZ.js +36 -0
- package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
- package/out/chunk-MOACFRJ4.js +3 -0
- package/out/chunk-O6M574JI.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PENGGKX7.js +233 -0
- package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
- package/out/chunk-SZD5CW5R.js +3 -0
- package/out/{chunk-G24A42V6.js → chunk-TQJDYHVE.js} +1 -1
- package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
- package/out/{chunk-7XRR4OFU.js → chunk-WYLQWYLA.js} +1 -1
- package/out/chunk-X6PJAK3T.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +26 -26
- package/out/client-sync.js +2 -2
- package/out/compare.js +14 -14
- package/out/dialogs.js +40 -40
- package/out/graph.js +11 -11
- package/out/index.html +12 -12
- package/out/scan.js +2 -2
- package/out/terminal.js +215 -215
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +9 -5
- package/risk/index.d.ts +4 -2
- package/risk/npm/assess.js +15 -4
- package/risk/npm/registry.js +6 -1
- package/risk/npm/score.js +1 -1
- package/server-managed/activity.ts +24 -11
- package/server-managed/agent-merges.ts +10 -8
- package/server-managed/app-agents.ts +8 -21
- package/server-managed/bundle-build-worker.js +7 -3
- package/server-managed/bundle-build.ts +5 -3
- package/server-managed/bundle-cache.ts +9 -3
- package/server-managed/bundle.ts +3 -4
- package/server-managed/cache-storage.ts +3 -2
- package/server-managed/commit-coauthors.ts +11 -10
- package/server-managed/commit-lockfiles.ts +22 -21
- package/server-managed/db-methods.ts +338 -187
- package/server-managed/db-neon.ts +31 -8
- package/server-managed/db.ts +2 -0
- package/server-managed/dependabot-access.ts +1 -1
- package/server-managed/dependency-review-comments.ts +7 -4
- package/server-managed/dependency-reviews.ts +71 -33
- package/server-managed/github-accounts.ts +28 -1
- package/server-managed/github-app.ts +27 -23
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +8 -1
- package/server-managed/github-metadata.ts +2 -2
- package/server-managed/http-response.ts +28 -10
- package/server-managed/http.ts +736 -247
- package/server-managed/index.ts +2 -2
- package/server-managed/link-reports.ts +5 -1
- package/server-managed/managed-issues.ts +3 -0
- package/server-managed/npm-delta.ts +16 -0
- package/server-managed/npm-kept.ts +51 -10
- package/server-managed/npm-loads.ts +48 -28
- package/server-managed/npm-manifest.ts +1 -1
- package/server-managed/npm-packages.ts +31 -15
- package/server-managed/npm-risk.ts +64 -17
- package/server-managed/object-storage.ts +4 -3
- package/server-managed/pretty-print.ts +45 -12
- package/server-managed/public-workspace.ts +3 -3
- package/server-managed/pull-dependencies.ts +84 -36
- package/server-managed/pull-dependency-reviews.ts +13 -10
- package/server-managed/rename-diff-worker.js +1 -1
- package/server-managed/report-store.ts +16 -11
- package/server-managed/repository-agents.ts +9 -6
- package/server-managed/repository-browser.ts +17 -5
- package/server-managed/repository-code.ts +180 -17
- package/server-managed/repository-dependency-reviews.ts +7 -0
- package/server-managed/repository-history.ts +85 -152
- package/server-managed/repository-issues.ts +27 -13
- package/server-managed/repository-pulse.ts +9 -3
- package/server-managed/repository-squashes.ts +145 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +3 -2
- package/server-managed/session.ts +34 -12
- package/server-managed/solidity-entry-points.ts +5 -3
- package/server-managed/sql-postgres.ts +3 -0
- package/server-managed/sql.ts +17 -8
- package/server-managed/storage-db.ts +11 -3
- package/server-managed/storage-encryption.ts +23 -12
- package/server-managed/storage-maintenance.ts +5 -2
- package/server-managed/storage-payload.ts +18 -0
- package/server-managed/storage-stores.ts +3 -2
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-feed.ts +19 -12
- package/server-managed/team-npm-scopes.ts +29 -0
- package/server-managed/team-reports.ts +74 -17
- package/server-managed/uploads.ts +29 -9
- package/out/chunk-4UFAMJAX.js +0 -1
- package/out/chunk-BOIXMUU5.js +0 -3
- package/out/chunk-CC4OM6TZ.js +0 -3
- package/out/chunk-JKGQ3O7O.js +0 -6
- package/out/chunk-LE5ZWLG4.js +0 -235
- package/out/chunk-MSION3G4.js +0 -36
- package/out/chunk-S6GIXWQW.js +0 -52
- package/out/chunk-V3KWNNMY.js +0 -1
- package/out/chunk-WQ74ZBZF.js +0 -7
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEPENDENCY_REVIEW_SCHEMA } from './dependency-reviews.ts'
|
|
1
|
+
import { DEPENDENCY_REVIEW_COLUMNS, DEPENDENCY_REVIEW_SCHEMA } from './dependency-reviews.ts'
|
|
2
2
|
import { LINK_REPORT_SCHEMA } from './link-reports.ts'
|
|
3
3
|
import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
|
|
4
4
|
import { TEAM_NPM_SCOPE_SCHEMA } from './team-npm-scopes.ts'
|
|
@@ -17,6 +17,7 @@ import { UPSTREAM_CACHE_SCHEMA } from './upstream-cache.ts'
|
|
|
17
17
|
import { COMMENT_SCHEMA } from './comments.ts'
|
|
18
18
|
import { ACTIVITY_SCHEMA, uploadAction } from './activity.ts'
|
|
19
19
|
import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
|
|
20
|
+
import { countStatement, timed } from './request-timing.ts'
|
|
20
21
|
import { postgresSchema, postgresSql } from './sql-postgres.ts'
|
|
21
22
|
import { managedTableRenames } from './db-table-names.ts'
|
|
22
23
|
import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
|
|
@@ -35,11 +36,18 @@ export type PgConnect = () => Promise<PgConnection>
|
|
|
35
36
|
// presence updates, and maintenance leases do not take it. Also fences migrations.
|
|
36
37
|
const LOCK = 'SELECT pg_advisory_xact_lock(1937006964, 1835101793)'
|
|
37
38
|
|
|
39
|
+
// Whether every version up to the latest is recorded, in one round trip at
|
|
40
|
+
// a cold start: a database with no versions table yet (undefined_table) has
|
|
41
|
+
// none. Outside a transaction, so the failed read aborts nothing.
|
|
42
|
+
const SCHEMA_VERSIONS = 32
|
|
38
43
|
async function currentSchema(db: PgConnection): Promise<boolean> {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
try {
|
|
45
|
+
const { rows } = await db.query(`SELECT count(*) AS n FROM managed_schema_version WHERE version BETWEEN 1 AND ${SCHEMA_VERSIONS}`)
|
|
46
|
+
return Number(rows[0]?.['n']) === SCHEMA_VERSIONS
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if ((error as { code?: unknown })?.code === '42P01') return false
|
|
49
|
+
throw error
|
|
50
|
+
}
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
|
|
@@ -176,6 +184,14 @@ async function migrateRepoUploadIndexes(db: PgConnection): Promise<void> {
|
|
|
176
184
|
await db.query('INSERT INTO managed_schema_version VALUES (31)')
|
|
177
185
|
}
|
|
178
186
|
|
|
187
|
+
// The shasums of the tarballs a dependency review reads, none known of
|
|
188
|
+
// those written before.
|
|
189
|
+
async function migrateDependencyReviewShasums(db: PgConnection): Promise<void> {
|
|
190
|
+
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 32')).rows.length > 0) return
|
|
191
|
+
await db.query(`ALTER TABLE managed_dependency_review ${DEPENDENCY_REVIEW_COLUMNS.map(([column, type]) => `ADD COLUMN IF NOT EXISTS ${column} ${type}`).join(', ')}`)
|
|
192
|
+
await db.query('INSERT INTO managed_schema_version VALUES (32)')
|
|
193
|
+
}
|
|
194
|
+
|
|
179
195
|
async function initialize(db: PgConnection): Promise<void> {
|
|
180
196
|
if (await currentSchema(db)) return
|
|
181
197
|
await db.query('BEGIN')
|
|
@@ -250,7 +266,7 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
250
266
|
await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
|
|
251
267
|
}
|
|
252
268
|
for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews, migrateGithubLinks, migrateRepositoryDependencyReviews,
|
|
253
|
-
migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes]) await migrate(db)
|
|
269
|
+
migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes, migrateDependencyReviewShasums]) await migrate(db)
|
|
254
270
|
await db.query('COMMIT')
|
|
255
271
|
} catch (err) {
|
|
256
272
|
await db.query('ROLLBACK')
|
|
@@ -318,7 +334,12 @@ function requestConnections(connect: PgConnect) {
|
|
|
318
334
|
}
|
|
319
335
|
|
|
320
336
|
// Exposed for parity tests against real PostgreSQL semantics via PGlite.
|
|
321
|
-
export async function openPostgresManagedDb(
|
|
337
|
+
export async function openPostgresManagedDb(leased: PgConnect, options: ManagedDbOptions = {}, reuseConnections = false): Promise<ManagedDb> {
|
|
338
|
+
// Each statement told in its request's Server-Timing (request-timing.ts).
|
|
339
|
+
const connect: PgConnect = async () => {
|
|
340
|
+
const connection = await leased()
|
|
341
|
+
return { query: (sql, params) => { countStatement(); return connection.query(sql, params) }, release: discard => connection.release(discard) }
|
|
342
|
+
}
|
|
322
343
|
const initial = await connect()
|
|
323
344
|
try { await initialize(initial) } catch (err) { await initial.release(true).catch(() => {}); throw err }
|
|
324
345
|
await initial.release()
|
|
@@ -424,7 +445,9 @@ export async function openNeonManagedDb(url: string, options: ManagedDbOptions =
|
|
|
424
445
|
let db: ManagedDb
|
|
425
446
|
try {
|
|
426
447
|
db = await openPostgresManagedDb(async () => {
|
|
427
|
-
|
|
448
|
+
// A lease's wait told in its request's Server-Timing: a new connection's
|
|
449
|
+
// TLS, WebSocket and Postgres handshake, where the pool had none idle.
|
|
450
|
+
const client = await timed('conn', () => pool.connect())
|
|
428
451
|
if (!watched.has(client)) { watched.add(client); client.on('error', () => { failed.add(client) }) }
|
|
429
452
|
return { query: (sql, params) => client.query(sql, params),
|
|
430
453
|
release: discard => Promise.try(() => client.release(discard === true || failed.has(client))) }
|
package/server-managed/db.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { revisionSchema } from './revisions.ts'
|
|
|
10
10
|
import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
|
|
11
11
|
import { GITHUB_LINK_USER_COLUMNS } from './github-links.ts'
|
|
12
12
|
import { REPOSITORY_DEPENDENCY_REVIEW_COLUMNS } from './repository-dependency-reviews.ts'
|
|
13
|
+
import { DEPENDENCY_REVIEW_COLUMNS } from './dependency-reviews.ts'
|
|
13
14
|
import { STORAGE_SCHEMA } from './storage-db.ts'
|
|
14
15
|
import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
15
16
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
@@ -124,6 +125,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
124
125
|
ensureColumn(db, 'managed_finding_issue', 'auto_fix_checked_at', 'INTEGER')
|
|
125
126
|
ensureColumn(db, 'managed_selected_repo', 'active', 'INTEGER NOT NULL DEFAULT 1')
|
|
126
127
|
for (const [column, type] of REPOSITORY_DEPENDENCY_REVIEW_COLUMNS) ensureColumn(db, 'managed_selected_repo', column, type)
|
|
128
|
+
for (const [column, type] of DEPENDENCY_REVIEW_COLUMNS) ensureColumn(db, 'managed_dependency_review', column, type)
|
|
127
129
|
ensureColumn(db, 'managed_finding_triage_event', 'report_id', 'TEXT')
|
|
128
130
|
ensureColumn(db, 'managed_finding_triage_event', 'report', 'TEXT')
|
|
129
131
|
ensureColumn(db, 'managed_finding_triage_event', 'repo', 'TEXT')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// A repository's Dependabot alerts for a repository risk scan, read with the
|
|
2
2
|
// App's installation token where the admin's own access lets them see them on
|
|
3
|
-
// GitHub, as
|
|
3
|
+
// GitHub, as doc/bundles.md's repository risk scan section tells.
|
|
4
4
|
import type { ManagedConfig } from './config.ts'
|
|
5
5
|
import type { SelectedRepo } from './db.ts'
|
|
6
6
|
import { type DependabotAlert, dependabotAlerts } from './dependabot-alerts.ts'
|
|
@@ -18,11 +18,14 @@ const MAX_FORMATTED = 1_000_000
|
|
|
18
18
|
// read after, every one formatted.
|
|
19
19
|
const REVIEWS_AT_ONCE = 200
|
|
20
20
|
|
|
21
|
-
// `review` as the hook is given it
|
|
22
|
-
//
|
|
21
|
+
// `review` as the hook is given it: each version with the integrity and
|
|
22
|
+
// shasum the review keeps of the tarball it read, and the version it is of
|
|
23
|
+
// with the patch `locked`, a pull request's lockfile, locks it with, where it
|
|
24
|
+
// is one's.
|
|
23
25
|
function entryOf(review: DependencyReview, locked?: AddedPackage | undefined): DependencyReviewEntry {
|
|
24
|
-
return { ecosystem: 'npm', old: review.basePackage === null ? null : { name: review.basePackage, version: review.baseVersion ?? '', integrity: review.baseIntegrity ?? ''
|
|
25
|
-
|
|
26
|
+
return { ecosystem: 'npm', old: review.basePackage === null ? null : { name: review.basePackage, version: review.baseVersion ?? '', integrity: review.baseIntegrity ?? '',
|
|
27
|
+
...review.baseShasum ? { shasum: review.baseShasum } : {} },
|
|
28
|
+
new: { name: review.package, version: review.version, integrity: review.integrity, ...review.shasum ? { shasum: review.shasum } : {},
|
|
26
29
|
...locked?.patchHash ? { patchHash: locked.patchHash } : {} },
|
|
27
30
|
status: review.status!, coverage: review.coverage === 'full' ? 'full' : 'partial', comment: review.comment,
|
|
28
31
|
files: review.entries.filter(entry => entry.status !== null || entry.comment.trim() !== '').map(entry => ({ path: entry.file, status: entry.status, comment: entry.comment })),
|
|
@@ -7,11 +7,15 @@ export interface ReviewContext {
|
|
|
7
7
|
ecosystem: 'npm'; package: string; version: string; integrity: string
|
|
8
8
|
basePackage: string | null; baseVersion: string | null; baseIntegrity: string | null
|
|
9
9
|
}
|
|
10
|
+
// The shasums (sha1s, in hex) of the tarballs a review reads, its version's
|
|
11
|
+
// and its base's, where npm tells them: each held, with its integrity, to the
|
|
12
|
+
// tarball its reviewer was shown, as a review is written (http.ts).
|
|
13
|
+
export interface ReviewedTarballs { shasum: string | null; baseShasum: string | null }
|
|
10
14
|
export interface ReviewInput { file: string; comment: string; status: ReviewVerdict | null }
|
|
11
15
|
export interface ReviewEntry extends ReviewInput { id: string; time: number }
|
|
12
16
|
export interface ReviewTeam { id: string; name: string }
|
|
13
|
-
export interface DependencyReview extends ReviewContext, ReviewSummary {
|
|
14
|
-
id: string; user: string | null; userLogin: string; time: number; revision: number
|
|
17
|
+
export interface DependencyReview extends ReviewContext, ReviewedTarballs, ReviewSummary {
|
|
18
|
+
id: string; user: string | null; userLogin: string; userName: string | null; time: number; revision: number
|
|
15
19
|
publishedAt: number | null; revokedAt: number | null; entries: ReviewEntry[]; teams: ReviewTeam[]; files: string[] | null
|
|
16
20
|
nonTextFiles: string[] | null
|
|
17
21
|
status: ReviewVerdict | null; comment: string
|
|
@@ -30,14 +34,15 @@ export interface DependencyReviewStore {
|
|
|
30
34
|
// The reviews published to team `teamId`, revoked ones too, the latest
|
|
31
35
|
// published first, a page on from `before` (when it was published, and
|
|
32
36
|
// its id), for one who reads them there (reviewsIn); null for another.
|
|
37
|
+
// In one statement.
|
|
33
38
|
listTeamDependencyReviews(teamId: string, user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null } | null>
|
|
34
|
-
// Whether `user` reads the reviews published to team `teamId` (reviewsIn).
|
|
35
|
-
readsTeamDependencyReviews(teamId: string, user: string): Promise<boolean>
|
|
36
39
|
listDependencyReviewTeams(user: string): Promise<ReviewTeam[]>
|
|
37
40
|
listDependencyReviewEnabledTeams(): Promise<string[]>
|
|
38
41
|
setTeamDependencyReview(sessionId: string, teamId: string, enabled: boolean): Promise<boolean>
|
|
39
42
|
discardDependencyReview(sessionId: string, reviewId: string, revision: number): Promise<void>
|
|
40
|
-
|
|
43
|
+
// Written with the shasums of the tarballs read (`tarballs`), which a
|
|
44
|
+
// draft keeps as it is begun, saved and published; none where not told.
|
|
45
|
+
changeDependencyReview(context: ReviewContext, sessionId: string, change: ReviewChange, now: number, tarballs?: ReviewedTarballs): Promise<DependencyReview>
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
// An unpublished review belongs only to its author. Publication freezes all
|
|
@@ -51,7 +56,8 @@ CREATE TABLE IF NOT EXISTS managed_dependency_review (
|
|
|
51
56
|
package TEXT NOT NULL, version TEXT NOT NULL, integrity TEXT NOT NULL,
|
|
52
57
|
base_package TEXT NOT NULL, base_version TEXT NOT NULL, base_integrity TEXT NOT NULL,
|
|
53
58
|
time INTEGER NOT NULL, revision INTEGER NOT NULL, published_at INTEGER, revoked_at INTEGER, files TEXT,
|
|
54
|
-
status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), comment TEXT NOT NULL DEFAULT ''
|
|
59
|
+
status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), comment TEXT NOT NULL DEFAULT '',
|
|
60
|
+
shasum TEXT NOT NULL DEFAULT '', base_shasum TEXT NOT NULL DEFAULT ''
|
|
55
61
|
) STRICT;
|
|
56
62
|
CREATE INDEX IF NOT EXISTS managed_dependency_review_context_idx
|
|
57
63
|
ON managed_dependency_review(ecosystem, package, version, integrity, base_package, base_version, base_integrity, time);
|
|
@@ -72,13 +78,19 @@ CREATE TABLE IF NOT EXISTS managed_dependency_review_team (
|
|
|
72
78
|
) STRICT;
|
|
73
79
|
CREATE INDEX IF NOT EXISTS managed_dependency_review_team_idx ON managed_dependency_review_team(team_id, review_id);
|
|
74
80
|
`
|
|
81
|
+
// The columns added since the table was, for a database that has it already.
|
|
82
|
+
export const DEPENDENCY_REVIEW_COLUMNS = [['shasum', "TEXT NOT NULL DEFAULT ''"], ['base_shasum', "TEXT NOT NULL DEFAULT ''"]] as const
|
|
75
83
|
const values = (c: ReviewContext) => [c.ecosystem, c.package, c.version, c.integrity, c.basePackage ?? '', c.baseVersion ?? '', c.baseIntegrity ?? '']
|
|
76
84
|
const targetWhere = 'ecosystem = ? AND package = ? AND version = ? AND integrity = ?'
|
|
77
85
|
const contextWhere = `${targetWhere} AND base_package = ? AND base_version = ? AND base_integrity = ?`
|
|
78
|
-
|
|
86
|
+
// Its author by the login they reviewed as, and by the name the app's user
|
|
87
|
+
// has now (null once they are none, or have none), as the lists show them.
|
|
88
|
+
const columns = `id, user_id AS user, user_login AS userLogin, (SELECT n.name FROM managed_user n WHERE n.id = user_id) AS userName, ecosystem, package, version, integrity,
|
|
79
89
|
NULLIF(base_package, '') AS basePackage, NULLIF(base_version, '') AS baseVersion, NULLIF(base_integrity, '') AS baseIntegrity,
|
|
90
|
+
NULLIF(shasum, '') AS shasum, NULLIF(base_shasum, '') AS baseShasum,
|
|
80
91
|
time, revision, published_at AS publishedAt, revoked_at AS revokedAt, files, status, comment`
|
|
81
92
|
const conflict = () => { throw new ManagedMutationError(409, 'review-conflict') }
|
|
93
|
+
const shasums = (tarballs: ReviewedTarballs | undefined) => [tarballs?.shasum ?? '', tarballs?.baseShasum ?? '']
|
|
82
94
|
|
|
83
95
|
// Who reviews dependencies in team `t`, user `u` as the query joins them: an
|
|
84
96
|
// admin, in any team; a member who manages, in theirs; a member who triages,
|
|
@@ -124,8 +136,9 @@ async function requireReviewer(db: ManagedSql, user: string) {
|
|
|
124
136
|
if ((await teams(db, user)).length === 0) throw new ManagedMutationError(403, 'review-access-required')
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
type ReviewRow = Omit<DependencyReview, 'files' | 'entries' | 'teams' | keyof ReviewSummary> & { files: string | null }
|
|
127
140
|
async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Promise<DependencyReview[]> {
|
|
128
|
-
const reviews = rows as
|
|
141
|
+
const reviews = rows as ReviewRow[]
|
|
129
142
|
if (reviews.length === 0) return []
|
|
130
143
|
const ids = reviews.map(review => review.id), placeholders = ids.map(() => '?').join(',')
|
|
131
144
|
const entries = await db.prepare(`SELECT review_id AS reviewId, id, file, comment, status, time
|
|
@@ -145,16 +158,24 @@ async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Pro
|
|
|
145
158
|
return of
|
|
146
159
|
}
|
|
147
160
|
const [entriesOf, teamsOf] = [byReview(entries), byReview(linked)]
|
|
148
|
-
return reviews.map(review =>
|
|
149
|
-
const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
|
|
150
|
-
const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
|
|
151
|
-
const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
|
|
152
|
-
const ownEntries = fileEntries((entriesOf.get(review.id) ?? []) as ReviewEntry[])
|
|
153
|
-
return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: (teamsOf.get(review.id) ?? []) as ReviewTeam[] }
|
|
154
|
-
})
|
|
161
|
+
return reviews.map(review => expanded(review, (entriesOf.get(review.id) ?? []) as ReviewEntry[], (teamsOf.get(review.id) ?? []) as ReviewTeam[]))
|
|
155
162
|
}
|
|
163
|
+
// A review as its row, its entries and the teams its reader reads it in
|
|
164
|
+
// tell it: its files' snapshot, its entries a file's each (fileEntries), and
|
|
165
|
+
// what they sum up to.
|
|
166
|
+
function expanded(review: ReviewRow, entries: ReviewEntry[], readIn: ReviewTeam[]): DependencyReview {
|
|
167
|
+
const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
|
|
168
|
+
const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
|
|
169
|
+
const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
|
|
170
|
+
const ownEntries = fileEntries(entries)
|
|
171
|
+
return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: readIn }
|
|
172
|
+
}
|
|
173
|
+
// Rows gathered into one JSON array value (json_group_array): its text on
|
|
174
|
+
// SQLite, parsed already on Postgres, where none gather as NULL.
|
|
175
|
+
export const gathered = <T>(value: unknown): T[] => value == null ? [] : (typeof value === 'string' ? JSON.parse(value) : value) as T[]
|
|
156
176
|
|
|
157
|
-
async function publish(db: ManagedSql, current: DependencyReview | undefined, user: string, change: Extract<ReviewChange, { action: 'publish' }>, now: number
|
|
177
|
+
async function publish(db: ManagedSql, current: DependencyReview | undefined, user: string, change: Extract<ReviewChange, { action: 'publish' }>, now: number,
|
|
178
|
+
tarballs: ReviewedTarballs | undefined) {
|
|
158
179
|
if (!current || current.revokedAt !== null) return conflict()
|
|
159
180
|
const allowed = new Set((await teams(db, user)).map(team => team.id))
|
|
160
181
|
const selected = [...new Set(change.teamIds)]
|
|
@@ -164,8 +185,9 @@ async function publish(db: ManagedSql, current: DependencyReview | undefined, us
|
|
|
164
185
|
if (!summarizeReview(entries, change.files, change.nonTextFiles).checkedFiles) throw new ManagedMutationError(400, 'no-reviewed-files')
|
|
165
186
|
if (!change.final || !allowedReviewVerdicts(entries.filter(entry => !change.nonTextFiles.includes(entry.file))).includes(change.final.status)) throw new ManagedMutationError(400, 'bad-final-verdict')
|
|
166
187
|
if (needsPackageJsonReview(entries, change.files, change.nonTextFiles)) throw new ManagedMutationError(400, 'package-json-not-reviewed')
|
|
167
|
-
await db.prepare(`UPDATE managed_dependency_review SET published_at = ?, files = ?, status = ?, comment = ?,
|
|
168
|
-
.run(now, JSON.stringify({ paths: [...new Set(change.files)].toSorted(), nonTextFiles: [...new Set(change.nonTextFiles)].toSorted() }),
|
|
188
|
+
await db.prepare(`UPDATE managed_dependency_review SET published_at = ?, files = ?, status = ?, comment = ?, shasum = ?, base_shasum = ?, revision = revision + 1
|
|
189
|
+
WHERE id = ?`).run(now, JSON.stringify({ paths: [...new Set(change.files)].toSorted(), nonTextFiles: [...new Set(change.nonTextFiles)].toSorted() }),
|
|
190
|
+
change.final.status, change.final.comment, ...shasums(tarballs), current.id)
|
|
169
191
|
} else {
|
|
170
192
|
if (change.final) return conflict()
|
|
171
193
|
await db.prepare('UPDATE managed_dependency_review SET revision = revision + 1 WHERE id = ?').run(current.id)
|
|
@@ -193,17 +215,34 @@ async function listContext(db: ManagedSql, context: ReviewContext, user: string,
|
|
|
193
215
|
return expandReviews(db, rows, user)
|
|
194
216
|
}
|
|
195
217
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
218
|
+
// A team's page of reviews in one statement: a row of its reader `v` where
|
|
219
|
+
// they read them there (reviewsIn), none for another, with each review on
|
|
220
|
+
// a row of its own, its entries and the teams `v` reads it in (as
|
|
221
|
+
// expandReviews reads them) each one JSON array.
|
|
199
222
|
async function listTeam(db: ManagedSql, teamId: string, user: string, before: { time: number; id: string } | null) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
223
|
+
const rows = await db.prepare(`WITH reader AS (
|
|
224
|
+
SELECT u.id AS id FROM managed_team t JOIN managed_user u ON u.id = ? WHERE t.id = ? AND ${reviewsIn(false)}
|
|
225
|
+
), page AS (
|
|
226
|
+
SELECT ${columns} FROM managed_dependency_review r JOIN managed_dependency_review_team rt ON rt.review_id = r.id
|
|
227
|
+
WHERE rt.team_id = ? AND r.published_at IS NOT NULL AND EXISTS (SELECT 1 FROM reader)
|
|
228
|
+
${before ? 'AND (r.published_at < ? OR (r.published_at = ? AND r.id < ?))' : ''}
|
|
229
|
+
ORDER BY r.published_at DESC, r.id DESC LIMIT 51
|
|
230
|
+
)
|
|
231
|
+
SELECT v.id AS reader, p.*,
|
|
232
|
+
(SELECT json_group_array(json_object('id', e.id, 'file', e.file, 'comment', e.comment, 'status', e.status, 'time', e.time) ORDER BY e.time, e.id)
|
|
233
|
+
FROM managed_dependency_file_review e WHERE e.review_id = p.id) AS entries,
|
|
234
|
+
(SELECT json_group_array(json_object('id', t.id, 'name', t.name) ORDER BY t.name, t.id)
|
|
235
|
+
FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = v.id
|
|
236
|
+
WHERE rt.review_id = p.id AND (p.user = u.id OR (${reviewsIn(false)}))) AS teams
|
|
237
|
+
FROM reader v LEFT JOIN page p ON 1 = 1
|
|
238
|
+
ORDER BY p.publishedAt DESC, p.id DESC`).all(user, teamId, teamId, ...before ? [before.time, before.time, before.id] : []) as
|
|
239
|
+
(ReviewRow & { reader: string; entries: unknown; teams: unknown })[]
|
|
240
|
+
if (rows.length === 0) return null
|
|
241
|
+
const reviews = rows.filter(row => row.id != null)
|
|
242
|
+
const page = reviews.slice(0, 50)
|
|
205
243
|
const last = page.at(-1)
|
|
206
|
-
return { reviews:
|
|
244
|
+
return { reviews: page.map(({ reader: _reader, entries, teams: readIn, ...review }) => expanded(review, gathered(entries), gathered(readIn))),
|
|
245
|
+
next: reviews.length > 50 && last ? { time: Number(last.publishedAt), id: last.id } : null }
|
|
207
246
|
}
|
|
208
247
|
|
|
209
248
|
async function saveFile(db: ManagedSql, reviewId: string, entry: ReviewInput, requestedId: string | null, now: number) {
|
|
@@ -236,7 +275,6 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
|
|
|
236
275
|
ORDER BY time, id`).all(user, ...ids), user)
|
|
237
276
|
},
|
|
238
277
|
listTeamDependencyReviews: (teamId, user, before) => listTeam(db, teamId, user, before),
|
|
239
|
-
readsTeamDependencyReviews: (teamId, user) => readsTeam(db, teamId, user),
|
|
240
278
|
async getLatestOwnDependencyReview(user, target) {
|
|
241
279
|
const row = await db.prepare(`SELECT ${columns} FROM managed_dependency_review
|
|
242
280
|
WHERE user_id = ? AND ecosystem = ? AND package = ? AND version = ? AND integrity = ? ORDER BY time DESC, id DESC LIMIT 1`)
|
|
@@ -258,7 +296,7 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
|
|
|
258
296
|
},
|
|
259
297
|
listDependencyReviewTeams: user => teams(db, user),
|
|
260
298
|
listDependencyReviews: (context, user, allBases = false) => listContext(db, context, user, allBases),
|
|
261
|
-
async changeDependencyReview(context, sessionId, change, now) {
|
|
299
|
+
async changeDependencyReview(context, sessionId, change, now, tarballs) {
|
|
262
300
|
const user = await actor(db, sessionId)
|
|
263
301
|
if (change.action !== 'revoke' && change.action !== 'publish') await requireReviewer(db, user.id)
|
|
264
302
|
let id = change.reviewId
|
|
@@ -274,8 +312,8 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
|
|
|
274
312
|
if (await unpublished()) return conflict()
|
|
275
313
|
const next = randomUUID()
|
|
276
314
|
await db.prepare(`INSERT INTO managed_dependency_review
|
|
277
|
-
(id, user_id, user_login, ecosystem, package, version, integrity, base_package, base_version, base_integrity, time, revision)
|
|
278
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(next, user.id, user.login, ...values(context), now)
|
|
315
|
+
(id, user_id, user_login, ecosystem, package, version, integrity, base_package, base_version, base_integrity, shasum, base_shasum, time, revision)
|
|
316
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(next, user.id, user.login, ...values(context), ...shasums(tarballs), now)
|
|
279
317
|
return next
|
|
280
318
|
}
|
|
281
319
|
if (change.action === 'start') {
|
|
@@ -294,11 +332,11 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
|
|
|
294
332
|
if (!current || current.publishedAt === null || current.revokedAt !== null) return conflict()
|
|
295
333
|
await db.prepare('UPDATE managed_dependency_review SET revoked_at = ?, revision = revision + 1 WHERE id = ?').run(now, id)
|
|
296
334
|
} else if (change.action === 'publish') {
|
|
297
|
-
await publish(db, current, user.id, change, now)
|
|
335
|
+
await publish(db, current, user.id, change, now, tarballs)
|
|
298
336
|
} else {
|
|
299
337
|
if (current?.publishedAt != null) return conflict()
|
|
300
338
|
if (change.entry.status === null && !change.entryId) throw new ManagedMutationError(400, 'bad-review')
|
|
301
|
-
if (id) await db.prepare('UPDATE managed_dependency_review SET time = ?, revision = revision + 1 WHERE id = ?').run(now, id)
|
|
339
|
+
if (id) await db.prepare('UPDATE managed_dependency_review SET time = ?, shasum = ?, base_shasum = ?, revision = revision + 1 WHERE id = ?').run(now, ...shasums(tarballs), id)
|
|
302
340
|
else { if (change.entryId) return conflict(); id = await create() }
|
|
303
341
|
await saveFile(db, id, change.entry, change.entryId, now)
|
|
304
342
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// GitHub accounts as GitHub's REST and GraphQL APIs name them, where a
|
|
2
2
|
// repository's activity names its authors (repository-history.ts and the
|
|
3
3
|
// Commits, Issues, Agents and Pulse tabs' readers).
|
|
4
|
+
import { githubGraphql } from './github-graphql.ts'
|
|
5
|
+
|
|
4
6
|
const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
|
|
5
7
|
|
|
6
8
|
// An account as GitHub names it: its login, else null, and its numeric id
|
|
@@ -23,7 +25,8 @@ export const actorOf = (actor: GraphAccount) => accountOf({ login: actor?.login,
|
|
|
23
25
|
// GitHub knows its address for; else, as GitHub's REST API does, the account
|
|
24
26
|
// whose noreply address its address is, `<id>+<login>@users.noreply.github.com`:
|
|
25
27
|
// a GitHub App's bot's, which is no user (GraphQL's `user` is a User's, never
|
|
26
|
-
// a bot's).
|
|
28
|
+
// a bot's). One by GitHub's older no-reply address, `<login>@users.noreply.github.com`,
|
|
29
|
+
// naming no id, its user looked up by its login (repository-history.ts linkNoreplyUsers).
|
|
27
30
|
const NOREPLY = /^([1-9]\d{0,11})\+([^@]+)@users\.noreply\.github\.com$/iu
|
|
28
31
|
export type GraphActor = { email?: unknown; user?: GraphAccount | null }
|
|
29
32
|
export function commitAccount(author: GraphActor | null | undefined): NamedAccount {
|
|
@@ -31,3 +34,27 @@ export function commitAccount(author: GraphActor | null | undefined): NamedAccou
|
|
|
31
34
|
const noreply = user.id === null && typeof author?.email === 'string' ? NOREPLY.exec(author.email) : null
|
|
32
35
|
return noreply ? accountOf({ login: noreply[2], id: Number(noreply[1]) }) : user
|
|
33
36
|
}
|
|
37
|
+
|
|
38
|
+
// GitHub's no-reply address of old, `<login>@users.noreply.github.com`, which
|
|
39
|
+
// names no id: GitHub links the commits it authors to the account of its
|
|
40
|
+
// login, as its REST API names them, but its GraphQL API names no user for it.
|
|
41
|
+
const NOREPLY_LOGIN = /^([A-Za-z\d][A-Za-z\d-]{0,38})@users\.noreply\.github\.com$/iu
|
|
42
|
+
const MAX_NOREPLY_LOGINS = 100
|
|
43
|
+
|
|
44
|
+
// Commits' authors and co-authors (`actors`), as GitHub's GraphQL API names
|
|
45
|
+
// them, those it names no user for by such an address named the user of its
|
|
46
|
+
// login, as GitHub links them: those logins, as many as MAX_NOREPLY_LOGINS,
|
|
47
|
+
// looked up in one query, where any is.
|
|
48
|
+
export async function linkNoreplyUsers(actors: (GraphActor | null | undefined)[], token: string, fetchImpl: typeof fetch): Promise<void> {
|
|
49
|
+
const unnamed = actors.flatMap(actor => {
|
|
50
|
+
const login = actor && !actor.user && typeof actor.email === 'string' ? NOREPLY_LOGIN.exec(actor.email)?.[1]?.toLowerCase() : undefined
|
|
51
|
+
return login ? [{ actor: actor!, login }] : []
|
|
52
|
+
})
|
|
53
|
+
const logins = [...new Set(unnamed.map(({ login }) => login))].slice(0, MAX_NOREPLY_LOGINS)
|
|
54
|
+
if (logins.length === 0) return
|
|
55
|
+
const query = `query(${logins.map((_, at) => `$l${at}: String!`).join(', ')}) { ${logins.map((_, at) => `u${at}: user(login: $l${at}) { login databaseId }`).join(' ')} }`
|
|
56
|
+
const data = await githubGraphql(query, Object.fromEntries(logins.map((login, at) => [`l${at}`, login])), token, fetchImpl,
|
|
57
|
+
error => error?.type === 'NOT_FOUND') as Record<string, GraphAccount> | null
|
|
58
|
+
const users = new Map(logins.map((login, at) => [login, data?.[`u${at}`] ?? null]))
|
|
59
|
+
for (const { actor, login } of unnamed) actor.user = users.get(login) ?? null
|
|
60
|
+
}
|
|
@@ -8,6 +8,7 @@ import { createClient } from '@preventive/upstream/github.js'
|
|
|
8
8
|
import type { ManagedConfig } from './config.ts'
|
|
9
9
|
import { GithubApiError, githubUnreachable, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
|
|
10
10
|
import { onTokenRejected, shareGithubRead, tokenRejected } from './github-requests.ts'
|
|
11
|
+
import { timed } from './request-timing.ts'
|
|
11
12
|
|
|
12
13
|
export { GithubApiError } from './github-errors.ts'
|
|
13
14
|
export { onTokenRejected } from './github-requests.ts'
|
|
@@ -74,31 +75,34 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
|
|
|
74
75
|
// GithubApiError (401 passes through for the caller to handle). Identical GETs
|
|
75
76
|
// share an outstanding read through its body. POSTs only opt in for read-only
|
|
76
77
|
// GraphQL queries (repository-history.ts), never for mutations or token mints.
|
|
77
|
-
export
|
|
78
|
+
export function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
|
|
78
79
|
readOnly = method === 'GET'): Promise<unknown> {
|
|
79
80
|
if (readOnly) return shareGithubRead(fetchImpl, JSON.stringify([url, token, method, body]), () => githubJson(url, token, fetchImpl, method, body, false))
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
})
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
// Validate one repo object into a ConnectedRepo (or null to skip), tagging it
|
|
@@ -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
|
+
}
|
|
@@ -71,8 +71,15 @@ function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string,
|
|
|
71
71
|
if (latest && (!repo || repo.repoId !== latest.repository.databaseId)) continue
|
|
72
72
|
const next = latest ? `https://github.com/${repo!.github}/pull/${latest.number}` : null
|
|
73
73
|
if (latest && next) metadata.set(next, { url: next, ...itemMetadata(latest, 'pull')! })
|
|
74
|
+
// Only a changed derived Fix is written: an unchanged one's check time
|
|
75
|
+
// alone would bump the annotations revision that every open team feed
|
|
76
|
+
// polls, for nothing they show. Changes still stamp it, so an older lookup
|
|
77
|
+
// never replaces a newer change (managed-issues.ts). An older lookup that
|
|
78
|
+
// differs can now land after a newer unchanged one, as it could already
|
|
79
|
+
// land before it (whose compare-and-set then fails); either way the next
|
|
80
|
+
// lookup finds the stored Fix differs, and writes the current one.
|
|
74
81
|
for (const issue of byIssue.get(`${job.repo.repoId}:${job.number}`) ?? []) {
|
|
75
|
-
updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
|
|
82
|
+
if (issue.autoFixUrl !== next) updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
|
|
76
83
|
}
|
|
77
84
|
}
|
|
78
85
|
return updates
|
|
@@ -77,8 +77,8 @@ CREATE TABLE IF NOT EXISTS managed_github_repository_visibility (
|
|
|
77
77
|
`
|
|
78
78
|
// Bundles show their commit's details and tags from these tables. Readers rely
|
|
79
79
|
// on bundle access alone; only a GitHub read made with access fills them.
|
|
80
|
-
// Commits are retained without eviction.
|
|
81
|
-
//
|
|
80
|
+
// Commits are retained without eviction. Every refs read of a repository
|
|
81
|
+
// (Create a bundle's, the code viewer's) refreshes its tags, which go with it.
|
|
82
82
|
export const GITHUB_COMMIT_SCHEMA = `
|
|
83
83
|
CREATE TABLE IF NOT EXISTS managed_github_commit (
|
|
84
84
|
cache_key TEXT PRIMARY KEY,
|
|
@@ -38,21 +38,39 @@ export function sendJson(res: ServerResponse, status: number, body: unknown, hea
|
|
|
38
38
|
// before a line, has the response cut short, as is one whose lines fail: a
|
|
39
39
|
// response that ends is one with every line.
|
|
40
40
|
export async function sendLines(res: ServerResponse, lines: AsyncIterable<unknown>, allowed: () => Promise<boolean>) {
|
|
41
|
-
|
|
42
|
-
const encoder = createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } })
|
|
43
|
-
const sent = pipeline(encoder, res).catch(() => {})
|
|
44
|
-
const closed = new Promise(resolve => { res.once('close', resolve) })
|
|
41
|
+
const out = openLines(res)
|
|
45
42
|
try {
|
|
46
43
|
for await (const line of lines) {
|
|
47
|
-
if (res.destroyed || !await allowed()) {
|
|
48
|
-
|
|
49
|
-
await Promise.race([new Promise<void>(resolve => { encoder.flush(() => { resolve() }) }), closed])
|
|
44
|
+
if (res.destroyed || !await allowed()) { out.cut(); return }
|
|
45
|
+
await out.write(line)
|
|
50
46
|
}
|
|
51
47
|
} catch (err) {
|
|
52
48
|
console.warn('managed: a streamed response failed:', err)
|
|
53
|
-
|
|
49
|
+
out.cut()
|
|
54
50
|
return
|
|
55
51
|
}
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
await out.end()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A response of lines of JSON as sendLines sends them, written as its caller
|
|
56
|
+
// has each: `write` sends one, flushed, false where the reader is gone; `end`
|
|
57
|
+
// ends it with every line; `cut` ends it short.
|
|
58
|
+
export function openLines(res: ServerResponse) {
|
|
59
|
+
res.writeHead(200, { 'content-type': 'application/x-ndjson', 'content-encoding': 'br', 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' })
|
|
60
|
+
const encoder = createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } })
|
|
61
|
+
const sent = pipeline(encoder, res).catch(() => {})
|
|
62
|
+
const closed = new Promise(resolve => { res.once('close', resolve) })
|
|
63
|
+
return {
|
|
64
|
+
async write(line: unknown): Promise<boolean> {
|
|
65
|
+
if (res.destroyed) return false
|
|
66
|
+
encoder.write(`${JSON.stringify(line)}\n`)
|
|
67
|
+
await Promise.race([new Promise<void>(resolve => { encoder.flush(() => { resolve() }) }), closed])
|
|
68
|
+
return !res.destroyed
|
|
69
|
+
},
|
|
70
|
+
async end() {
|
|
71
|
+
encoder.end()
|
|
72
|
+
await sent
|
|
73
|
+
},
|
|
74
|
+
cut() { res.destroy() },
|
|
75
|
+
}
|
|
58
76
|
}
|