@preventive/triage 1.0.0-alpha.52 → 1.0.0-alpha.54
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-compare-diff.js +28 -9
- package/common/bundle-compare-inputs.d.ts +1 -0
- package/common/bundle-compare-inputs.js +91 -15
- package/common/bundle-comparison.js +14 -7
- package/common/default-scan-models.ts +3 -0
- package/common/dependency-reviews.d.ts +1 -1
- package/common/dependency-reviews.js +1 -1
- package/common/managed/finding-detail.d.ts +4 -2
- package/common/managed/finding-detail.js +27 -15
- package/common/managed/team-app.d.ts +9 -0
- package/common/managed/team-app.js +59 -6
- package/common/workspace-app.js +38 -6
- package/out/activity.js +4 -4
- package/out/chunk-4EMM6UCD.js +1 -0
- package/out/chunk-4ISO5F7Y.js +7 -0
- package/out/chunk-4XIH7YTX.js +1 -0
- package/out/chunk-6W5BMATD.js +1 -0
- package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
- package/out/chunk-DEVAORP2.js +1 -0
- package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
- package/out/chunk-E3LFSUDJ.js +9 -0
- package/out/chunk-FHIVVVUA.js +6 -0
- package/out/chunk-G3LY2FV4.js +1 -0
- package/out/chunk-HAZKDK4M.js +4 -0
- package/out/chunk-HOB3YPS5.js +1 -0
- package/out/chunk-I47TYPXO.js +52 -0
- package/out/chunk-J7A5OSZE.js +244 -0
- package/out/chunk-JFL7WLRV.js +1 -0
- package/out/chunk-JMG5DXII.js +2 -0
- package/out/chunk-JOXLZXMI.js +1 -0
- package/out/chunk-L4CTEMRG.js +2 -0
- package/out/chunk-M37X4LCF.js +14 -0
- package/out/chunk-M3Y6SLC4.js +1 -0
- package/out/chunk-NXATZJQ2.js +1 -0
- package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
- package/out/chunk-PAD2JHZT.js +1 -0
- package/out/chunk-QMVOEBWC.js +13 -0
- package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
- package/out/chunk-RRBIJL2B.js +1 -0
- package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
- package/out/chunk-TOXMUOIW.js +5 -0
- package/out/chunk-UBOCLLF2.js +1 -0
- package/out/chunk-UCP3JRE2.js +1 -0
- package/out/{chunk-NCN3M5EP.js → chunk-VC72ZO3P.js} +2 -2
- package/out/chunk-VRX2APNA.js +9 -0
- package/out/{chunk-IMM274IW.js → chunk-VZMELPVL.js} +4 -3
- package/out/{chunk-CHRBUGVN.js → chunk-WHVXTJHK.js} +1 -1
- package/out/chunk-WMFDQS2L.js +11 -0
- package/out/chunk-WNSVBOSO.js +41 -0
- package/out/chunk-WREU7JAG.js +1 -0
- package/out/chunk-Y5ZHFNBD.js +2 -0
- package/out/chunk-YF4M5OCI.js +1 -0
- package/out/chunk-Z667U2UJ.js +1 -0
- package/out/chunk-ZQHLECTI.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +27 -26
- package/out/compare.js +14 -14
- package/out/dialogs.js +36 -51
- package/out/graph.js +17 -15
- package/out/index.html +26 -10
- package/out/prism.js +1 -1
- package/out/scan.js +2 -2
- package/out/terminal.js +232 -232
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +12 -7
- package/risk/actions/resolve.js +36 -5
- package/risk/index.d.ts +20 -2
- package/risk/npm/malware.js +6 -4
- package/risk/npm/registry.js +8 -6
- package/server-managed/bundle-advisories.ts +56 -74
- package/server-managed/bundle-build-worker.js +61 -4
- package/server-managed/bundle-build.ts +13 -4
- package/server-managed/bundle-dependency-reviews.ts +74 -0
- package/server-managed/bundle-validation.ts +32 -0
- package/server-managed/db-methods.ts +51 -16
- package/server-managed/db-neon.ts +18 -2
- package/server-managed/db.ts +11 -2
- package/server-managed/github-issue-links.ts +151 -47
- package/server-managed/github-pulls.ts +34 -15
- package/server-managed/history-windows.ts +172 -0
- package/server-managed/http.ts +227 -12
- package/server-managed/index.ts +13 -1
- package/server-managed/managed-issues.ts +29 -15
- package/server-managed/npm-insights.ts +4 -3
- package/server-managed/npm-kept.ts +4 -0
- package/server-managed/package-entry-points.ts +37 -9
- package/server-managed/pretty-print.ts +7 -5
- package/server-managed/public-workspace.ts +2 -2
- package/server-managed/repository-actions.ts +7 -4
- package/server-managed/repository-browser.ts +11 -4
- package/server-managed/repository-history.ts +2 -2
- package/server-managed/repository-pulse.ts +11 -2
- package/server-managed/repository-risk-audit.ts +4 -5
- package/server-managed/repository-risk.ts +4 -4
- package/server-managed/sql.ts +2 -1
- package/server-managed/stasis-config.ts +46 -0
- package/server-managed/team-app-summaries.ts +54 -0
- package/server-managed/team-app.ts +52 -31
- package/server-managed/team-reports.ts +34 -13
- package/server-managed/upstream-cache.ts +41 -32
- package/server-managed/workspace-shares.ts +3 -1
- package/out/chunk-2ZDZWLFY.js +0 -1
- package/out/chunk-7VAWINQF.js +0 -6
- package/out/chunk-FMXWXDR4.js +0 -36
- package/out/chunk-IZ7POWI3.js +0 -18
- package/out/chunk-LJ7XRXFU.js +0 -52
- package/out/chunk-OGAYMEER.js +0 -232
- package/out/chunk-QB4CBOX2.js +0 -1
- package/out/chunk-TQJDYHVE.js +0 -2
- package/out/chunk-VH4XMZIW.js +0 -2
- package/out/chunk-WYLQWYLA.js +0 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// A Stasis bundle validated (GET and POST /api/bundles/:id/validation) by
|
|
2
|
+
// Stasis's own validation (`@exodus/stasis/validate`; its rules are in
|
|
3
|
+
// Stasis's doc/validate.md), the repository read the app's way: with its
|
|
4
|
+
// reader's GitHub access, own code by git's blob ids of the commit's listing.
|
|
5
|
+
import { type BundleValidation, type ValidationPlan, followingMoves, validateBundle, validateRepository } from '@exodus/stasis/validate'
|
|
6
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
7
|
+
import { upstreamGithub } from './github-errors.ts'
|
|
8
|
+
|
|
9
|
+
export { type BundleValidation, type PlannedBundle, validationPlan } from '@exodus/stasis/validate'
|
|
10
|
+
|
|
11
|
+
// The rules a validation was made by, kept with it: one made by others is made again.
|
|
12
|
+
export const BUNDLE_VALIDATION_READ = 3
|
|
13
|
+
|
|
14
|
+
type Options = { deadline?: number }
|
|
15
|
+
|
|
16
|
+
// The bundle `plan`'s dependencies alone, held to their registry, own code unverified for `reason`.
|
|
17
|
+
export const validateDependencies = (plan: ValidationPlan, reason: string, options: Options = {}): Promise<BundleValidation> =>
|
|
18
|
+
validateBundle(plan, { reason }, options)
|
|
19
|
+
|
|
20
|
+
// The bundle `plan` validated at `commit` of `fullName`, read with `token`,
|
|
21
|
+
// built in `directory`: own code held to git's blob ids of the commit's tree
|
|
22
|
+
// as upstream lists it (each listing held to its tree's id), the commit to the
|
|
23
|
+
// repository's own history, the dependencies to the lockfiles installing it;
|
|
24
|
+
// what can't be held to told in the report (own code's `reason`,
|
|
25
|
+
// `lockfileError`), not thrown.
|
|
26
|
+
export function validateAtCommit(plan: ValidationPlan, { fullName, token, commit, directory }: { fullName: string; token: string; commit: string; directory: string },
|
|
27
|
+
options: Options = {}): Promise<BundleValidation> {
|
|
28
|
+
const client = createClient({ token, userAgent: 'deepview-triage' })
|
|
29
|
+
const repository = followingMoves(client, fullName)
|
|
30
|
+
return upstreamGithub(() => validateRepository(plan, { repository, commit, directory },
|
|
31
|
+
{ ...options, strict: false, listTree: paths => client.listRepoTree({ repo: repository.name(), sha: commit, paths }) }))
|
|
32
|
+
}
|
|
@@ -17,6 +17,7 @@ import { type ManagedIssue, type ManagedIssueStore, managedIssueMethods } from '
|
|
|
17
17
|
import type { IssueFixUpdate } from './github-issue-links.ts'
|
|
18
18
|
import { type BundleBuildLeaseStore, bundleBuildLeaseMethods } from './bundle-build-leases.ts'
|
|
19
19
|
import { type UpstreamCacheStore, upstreamCacheMethods } from './upstream-cache.ts'
|
|
20
|
+
import { type TeamAppSummaryStore, teamAppSummaryMethods } from './team-app-summaries.ts'
|
|
20
21
|
import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
|
|
21
22
|
import type { ManagedSql } from './sql.ts'
|
|
22
23
|
import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
|
|
@@ -147,6 +148,9 @@ export interface TeamReportAccessSnapshot extends ReportAccessSnapshot {
|
|
|
147
148
|
reportId?: string | null
|
|
148
149
|
hiddenReportId?: string | null
|
|
149
150
|
repositories: { repoId: number; github: string; path: string | null }[]
|
|
151
|
+
// By report ID, the content key its catalog entry has (UserTeamReport
|
|
152
|
+
// cacheKey) as read with the rest.
|
|
153
|
+
catalogKeys?: Readonly<Record<string, string>>
|
|
150
154
|
}
|
|
151
155
|
export interface ReportAccessSnapshot {
|
|
152
156
|
user: StoredUser
|
|
@@ -206,6 +210,11 @@ export interface RepoDataItem {
|
|
|
206
210
|
|
|
207
211
|
export type RepoReportItem = Pick<ReportRecord, 'id' | 'filename' | 'sha256' | 'bundleId'>
|
|
208
212
|
|
|
213
|
+
// A report that names a bundle: linked to it (bundleId is that bundle's), or
|
|
214
|
+
// declaring its integrity as the bundle it was made from without the link.
|
|
215
|
+
// `readable`: whether the manager asking may read it (as getReportView tells).
|
|
216
|
+
export type BundleReportItem = Pick<ReportRecord, 'id' | 'filename' | 'bundleId'> & { readable: boolean }
|
|
217
|
+
|
|
209
218
|
// A stored per-finding triage row, with the last writer's login resolved like
|
|
210
219
|
// listReports (live login, falling back to the durable snapshot). `flagged`
|
|
211
220
|
// maps the tri-state column: null unset, true/false set. Every field null =
|
|
@@ -368,7 +377,7 @@ export interface UserTeam {
|
|
|
368
377
|
}
|
|
369
378
|
|
|
370
379
|
// Backend-agnostic store surface (SQLite + PostgreSQL implementations).
|
|
371
|
-
export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyReviewStore, ActivityStore, CommentStore, GithubMetadataStore, GithubCommitStore, ManagedIssueStore, BundleBuildLeaseStore, UpstreamCacheStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb, LinkReportStore, RepositoryAliasStore, TeamNpmScopeStore, GithubLinkStore {
|
|
380
|
+
export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyReviewStore, ActivityStore, CommentStore, GithubMetadataStore, GithubCommitStore, ManagedIssueStore, BundleBuildLeaseStore, UpstreamCacheStore, TeamAppSummaryStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb, LinkReportStore, RepositoryAliasStore, TeamNpmScopeStore, GithubLinkStore {
|
|
372
381
|
claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
|
|
373
382
|
finishMaintenanceLease(owner: string, until: number): Promise<void>
|
|
374
383
|
getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
|
|
@@ -451,6 +460,10 @@ export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyRe
|
|
|
451
460
|
getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string, reportId?: string | null): Promise<TeamReportAccessSnapshot | null>
|
|
452
461
|
getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
|
|
453
462
|
listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]>
|
|
463
|
+
// The reports linked to a bundle, or whose bundleHashes declared its integrity
|
|
464
|
+
// (bundle_integrity), newest first: what deleting it leaves without code.
|
|
465
|
+
// Each says whether the manager `userId` (none: an admin) may read it.
|
|
466
|
+
listReportsForBundle(bundleId: string, integrity: string, userId?: string): Promise<BundleReportItem[]>
|
|
454
467
|
deleteReport(id: string): Promise<boolean>
|
|
455
468
|
// Attach / detach a report's repo + directory link (repoId null = detach);
|
|
456
469
|
// resolves true iff the report exists. The caller validates repoId and the
|
|
@@ -641,15 +654,23 @@ type UserTeamRows = {
|
|
|
641
654
|
bundles: (Omit<UserTeamBundle, 'visible'> & { teamId: string; visible: number })[]
|
|
642
655
|
}
|
|
643
656
|
|
|
657
|
+
// A catalog report's content key: its content, where it is, and what its
|
|
658
|
+
// reader may see of it (their grants and role) under the link reports'
|
|
659
|
+
// revision `links`. A team snapshot names it of each report it reads.
|
|
660
|
+
type CatalogReportRow = Pick<UserTeamRows['reports'][number], 'sha256' | 'bundleId' | 'filename' | 'repoId' | 'repoDirectory' | 'repoFullName' | 'dependencies' | 'security'>
|
|
661
|
+
function reportCatalogKey(r: CatalogReportRow, role: string, links: string): string {
|
|
662
|
+
return createHash('sha256').update(JSON.stringify([
|
|
663
|
+
r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, role, links,
|
|
664
|
+
])).digest('base64url')
|
|
665
|
+
}
|
|
666
|
+
|
|
644
667
|
// A user's team catalog from its rows, `links` the link reports' revision.
|
|
645
668
|
function userTeams(teams: UserTeamRows['teams'], scopes: UserTeamRows['scopes'], links: string, reports: UserTeamRows['reports'], bundles: UserTeamRows['bundles']): UserTeam[] {
|
|
646
669
|
const reportsByTeam = new Map<string, UserTeamReport[]>()
|
|
647
670
|
for (const r of reports) {
|
|
648
671
|
const list = reportsByTeam.get(r.teamId) ?? []
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
])).digest('base64url')
|
|
652
|
-
list.push({ id: r.id, slug: r.slug, filename: r.filename, visible: r.visible === 1, analyzer: r.analyzer, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, cacheKey })
|
|
672
|
+
list.push({ id: r.id, slug: r.slug, filename: r.filename, visible: r.visible === 1, analyzer: r.analyzer, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory,
|
|
673
|
+
cacheKey: reportCatalogKey(r, r.role, links) })
|
|
653
674
|
reportsByTeam.set(r.teamId, list)
|
|
654
675
|
}
|
|
655
676
|
const bundlesByTeam = new Map<string, UserTeamBundle[]>()
|
|
@@ -899,10 +920,10 @@ function prepareStatements(db: ManagedSql) {
|
|
|
899
920
|
EXISTS(SELECT 1 FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
|
|
900
921
|
WHERE tu.user_id = v.uid AND tu.team_id = ? AND t.hidden = 0) AS member,
|
|
901
922
|
(SELECT json_group_array(json_object('id', x.id, 'filename', x.filename, 'byteSize', x.byteSize, 'sha256', x.sha256,
|
|
902
|
-
'visible', x.visible, 'repoDirectory', x.repoDirectory, 'repoFullName', x.repoFullName,
|
|
923
|
+
'visible', x.visible, 'bundleId', x.bundleId, 'repoId', x.repoId, 'repoDirectory', x.repoDirectory, 'repoFullName', x.repoFullName,
|
|
903
924
|
'dependencies', x.dependencies, 'security', x.security) ORDER BY x.id)
|
|
904
925
|
FROM (SELECT DISTINCT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256, r.visible AS visible,
|
|
905
|
-
r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
|
|
926
|
+
r.bundle_id AS bundleId, r.repo_id AS repoId, r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
|
|
906
927
|
tu.view_dependencies AS dependencies, tu.view_security AS security
|
|
907
928
|
FROM managed_team_user tu
|
|
908
929
|
JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
|
|
@@ -917,6 +938,15 @@ function prepareStatements(db: ManagedSql) {
|
|
|
917
938
|
FROM viewer v`,
|
|
918
939
|
),
|
|
919
940
|
reportFilenamesWithBundleHashStmt: db.prepare(`SELECT DISTINCT filename FROM managed_report WHERE bundle_id = ? AND sha256 = ?`),
|
|
941
|
+
reportsForBundleStmt: db.prepare(
|
|
942
|
+
`SELECT r.id AS id, r.filename AS filename, r.bundle_id AS bundleId,
|
|
943
|
+
(? IS NULL OR r.uploaded_by = ? OR EXISTS(SELECT 1 FROM managed_team_repo tr
|
|
944
|
+
JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
945
|
+
JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
|
|
946
|
+
WHERE tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL} AND tu.user_id = ?)) AS readable
|
|
947
|
+
FROM managed_report r WHERE r.bundle_id = ? OR r.bundle_integrity = ?
|
|
948
|
+
ORDER BY r.uploaded_at DESC, r.filename ASC, r.id ASC`,
|
|
949
|
+
),
|
|
920
950
|
deleteReportStmt: db.prepare(`DELETE FROM managed_report WHERE id = ?`),
|
|
921
951
|
setReportRepoStmt: db.prepare(`UPDATE managed_report SET repo_id = ?, repo_directory = ? WHERE id = ?`),
|
|
922
952
|
setReportVisibleStmt: db.prepare(`UPDATE managed_report SET visible = ? WHERE id = ?`),
|
|
@@ -982,7 +1012,7 @@ function prepareStatements(db: ManagedSql) {
|
|
|
982
1012
|
WHERE c.finding_id IN (SELECT id FROM requested)) AS comments,
|
|
983
1013
|
(SELECT json_group_array(json_object('findingId', i.finding_id, 'repoId', i.repo_id, 'repository', i.repository, 'requestId', i.request_id,
|
|
984
1014
|
'issueUrl', i.issue_url, 'autoFixUrl', i.auto_fix_url, 'autoFixCheckedAt', i.auto_fix_checked_at,
|
|
985
|
-
'createdBy', i.created_by, 'createdAt', i.created_at) ORDER BY i.finding_id)
|
|
1015
|
+
'duplicateOfUrl', i.duplicate_of_url, 'createdBy', i.created_by, 'createdAt', i.created_at) ORDER BY i.finding_id)
|
|
986
1016
|
FROM managed_finding_issue i WHERE i.finding_id IN (SELECT id FROM requested)) AS issues`,
|
|
987
1017
|
),
|
|
988
1018
|
selectTriageFixesStmt: db.prepare(`SELECT DISTINCT fix FROM managed_finding_triage
|
|
@@ -996,7 +1026,8 @@ function prepareStatements(db: ManagedSql) {
|
|
|
996
1026
|
FROM managed_finding_triage t WHERE t.finding_id IN (SELECT id FROM requested)) AS triage,
|
|
997
1027
|
(SELECT json_group_array(json_object('id', c.id, 'version', c.version) ORDER BY c.id)
|
|
998
1028
|
FROM managed_finding_comment c WHERE c.finding_id IN (SELECT id FROM requested)) AS comments,
|
|
999
|
-
(SELECT json_group_array(json_object('findingId', i.finding_id, 'issueUrl', i.issue_url, 'autoFixUrl', i.auto_fix_url
|
|
1029
|
+
(SELECT json_group_array(json_object('findingId', i.finding_id, 'issueUrl', i.issue_url, 'autoFixUrl', i.auto_fix_url,
|
|
1030
|
+
'duplicateOfUrl', i.duplicate_of_url) ORDER BY i.finding_id)
|
|
1000
1031
|
FROM managed_finding_issue i WHERE i.finding_id IN (SELECT id FROM requested) AND i.issue_url IS NOT NULL) AS issues`,
|
|
1001
1032
|
),
|
|
1002
1033
|
insertBundleStmt: db.prepare(
|
|
@@ -1368,21 +1399,19 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
|
|
|
1368
1399
|
const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
|
|
1369
1400
|
if (user.role === 'none' || !session.member) return { user, teamId: null, reports: [], repositories: [] }
|
|
1370
1401
|
const whole = user.role === 'admin' || user.role === 'manage'
|
|
1371
|
-
const rows = jsonRows<{
|
|
1372
|
-
id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
|
|
1373
|
-
repoFullName: string | null; dependencies: number; security: number; visible: number
|
|
1374
|
-
}>(session.reports)
|
|
1402
|
+
const rows = jsonRows<CatalogReportRow & { id: string; byteSize: number; visible: number }>(session.reports)
|
|
1375
1403
|
// Only privileged callers can receive a hidden row from this query. Give
|
|
1376
1404
|
// that individual preview its own capacity; published report selections
|
|
1377
1405
|
// still use the complete workspace for cross-report classification.
|
|
1378
1406
|
const hidden = rows.find(row => row.id === reportId && row.visible === 0)
|
|
1379
1407
|
const selected = hidden ? [hidden] : rows
|
|
1380
1408
|
const repositories = jsonRows<TeamReportAccessSnapshot['repositories'][number]>(session.repositories)
|
|
1381
|
-
|
|
1409
|
+
const links = linkRevisionOf(jsonRows(session.links))
|
|
1410
|
+
return { user, teamId, reportId, hiddenReportId: hidden?.id ?? null, repositories, linkRevision: links, reports: selected.map(row => ({
|
|
1382
1411
|
id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
|
|
1383
1412
|
repo: { github: row.repoFullName, directory: row.repoDirectory },
|
|
1384
1413
|
permissions: { dependencies: whole || row.dependencies === 1, security: whole || row.security === 1 },
|
|
1385
|
-
})) }
|
|
1414
|
+
})), catalogKeys: Object.fromEntries(selected.map(row => [row.id, reportCatalogKey(row, user.role, links)])) }
|
|
1386
1415
|
},
|
|
1387
1416
|
async getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null> {
|
|
1388
1417
|
const session = await stmts.selectReportSnapshotStmt.get(sessionId, now, JSON.stringify([...new Set(ids)])) as
|
|
@@ -1405,6 +1434,11 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
|
|
|
1405
1434
|
const rows = (await stmts.reportFilenamesWithBundleHashStmt.all(bundleId, sha256)) as { filename: string }[]
|
|
1406
1435
|
return rows.map(row => row.filename)
|
|
1407
1436
|
},
|
|
1437
|
+
async listReportsForBundle(bundleId: string, integrity: string, userId?: string): Promise<BundleReportItem[]> {
|
|
1438
|
+
const rows = await stmts.reportsForBundleStmt.all(userId ?? null, userId ?? null, userId ?? null, bundleId, integrity) as
|
|
1439
|
+
(Omit<BundleReportItem, 'readable'> & { readable: unknown })[]
|
|
1440
|
+
return rows.map(row => ({ id: row.id, filename: row.filename, bundleId: row.bundleId, readable: Boolean(row.readable) }))
|
|
1441
|
+
},
|
|
1408
1442
|
async deleteReport(id: string): Promise<boolean> {
|
|
1409
1443
|
return Number((await deleteReportStmt.run(id)).changes) > 0
|
|
1410
1444
|
},
|
|
@@ -1800,6 +1834,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1800
1834
|
...repositoryDependencyReviewMethods(db),
|
|
1801
1835
|
...bundleBuildLeaseMethods(db),
|
|
1802
1836
|
...upstreamCacheMethods(db),
|
|
1837
|
+
...teamAppSummaryMethods(db),
|
|
1803
1838
|
async claimMaintenanceLease(owner, now, until, migration = false) {
|
|
1804
1839
|
return !!await claimLeaseStmt.get(migration ? 2 : 1, owner, until, now)
|
|
1805
1840
|
},
|
|
@@ -1906,7 +1941,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1906
1941
|
// Recheck access inside the same write transaction as the derived field.
|
|
1907
1942
|
const current = await methods.getTeamReportAccessSnapshot(sessionId, Date.now(), snapshot.teamId!, snapshot.reportId)
|
|
1908
1943
|
if (!current?.teamId || JSON.stringify(current) !== JSON.stringify(snapshot)) return false
|
|
1909
|
-
for (const update of updates) await methods.
|
|
1944
|
+
for (const update of updates) await methods.setManagedIssueTracking(update.findingId, update.issueUrl, update.previous, update.next, update.checkedAt)
|
|
1910
1945
|
return true
|
|
1911
1946
|
},
|
|
1912
1947
|
...workspaceShareMethods(db),
|
|
@@ -14,6 +14,7 @@ import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUM
|
|
|
14
14
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
15
15
|
import { BUNDLE_BUILD_LEASE_SCHEMA } from './bundle-build-leases.ts'
|
|
16
16
|
import { UPSTREAM_CACHE_SCHEMA } from './upstream-cache.ts'
|
|
17
|
+
import { TEAM_APP_SUMMARY_SCHEMA } from './team-app-summaries.ts'
|
|
17
18
|
import { COMMENT_SCHEMA } from './comments.ts'
|
|
18
19
|
import { ACTIVITY_SCHEMA, uploadAction } from './activity.ts'
|
|
19
20
|
import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
|
|
@@ -39,7 +40,7 @@ const LOCK = 'SELECT pg_advisory_xact_lock(1937006964, 1835101793)'
|
|
|
39
40
|
// Whether every version up to the latest is recorded, in one round trip at
|
|
40
41
|
// a cold start: a database with no versions table yet (undefined_table) has
|
|
41
42
|
// none. Outside a transaction, so the failed read aborts nothing.
|
|
42
|
-
const SCHEMA_VERSIONS =
|
|
43
|
+
const SCHEMA_VERSIONS = 35
|
|
43
44
|
async function currentSchema(db: PgConnection): Promise<boolean> {
|
|
44
45
|
try {
|
|
45
46
|
const { rows } = await db.query(`SELECT count(*) AS n FROM managed_schema_version WHERE version BETWEEN 1 AND ${SCHEMA_VERSIONS}`)
|
|
@@ -202,6 +203,20 @@ async function migrateDependencyReviewPatches(db: PgConnection): Promise<void> {
|
|
|
202
203
|
await db.query('INSERT INTO managed_schema_version VALUES (33)')
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
async function migrateTeamAppSummaries(db: PgConnection): Promise<void> {
|
|
207
|
+
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 34')).rows.length > 0) return
|
|
208
|
+
await db.query(postgresSchema(TEAM_APP_SUMMARY_SCHEMA))
|
|
209
|
+
await db.query('INSERT INTO managed_schema_version VALUES (34)')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The issue a saved one was closed as a duplicate of, none known of those
|
|
213
|
+
// checked before.
|
|
214
|
+
async function migrateIssueDuplicates(db: PgConnection): Promise<void> {
|
|
215
|
+
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 35')).rows.length > 0) return
|
|
216
|
+
await db.query('ALTER TABLE managed_finding_issue ADD COLUMN IF NOT EXISTS duplicate_of_url TEXT')
|
|
217
|
+
await db.query('INSERT INTO managed_schema_version VALUES (35)')
|
|
218
|
+
}
|
|
219
|
+
|
|
205
220
|
async function initialize(db: PgConnection): Promise<void> {
|
|
206
221
|
if (await currentSchema(db)) return
|
|
207
222
|
await db.query('BEGIN')
|
|
@@ -276,7 +291,8 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
276
291
|
await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
|
|
277
292
|
}
|
|
278
293
|
for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews, migrateGithubLinks, migrateRepositoryDependencyReviews,
|
|
279
|
-
migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes, migrateDependencyReviewShasums, migrateDependencyReviewPatches
|
|
294
|
+
migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes, migrateDependencyReviewShasums, migrateDependencyReviewPatches, migrateTeamAppSummaries,
|
|
295
|
+
migrateIssueDuplicates]) await migrate(db)
|
|
280
296
|
await db.query('COMMIT')
|
|
281
297
|
} catch (err) {
|
|
282
298
|
await db.query('ROLLBACK')
|
package/server-managed/db.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUM
|
|
|
16
16
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
17
17
|
import { BUNDLE_BUILD_LEASE_SCHEMA } from './bundle-build-leases.ts'
|
|
18
18
|
import { UPSTREAM_CACHE_SCHEMA } from './upstream-cache.ts'
|
|
19
|
+
import { TEAM_APP_SUMMARY_SCHEMA } from './team-app-summaries.ts'
|
|
19
20
|
import { managedTableRenames } from './db-table-names.ts'
|
|
20
21
|
import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
|
|
21
22
|
import { createSqliteDriver, scopeManagedMethods } from './sql.ts'
|
|
@@ -69,10 +70,16 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
69
70
|
const db = new DatabaseSync(path)
|
|
70
71
|
try {
|
|
71
72
|
db.exec('PRAGMA journal_mode = WAL;')
|
|
72
|
-
|
|
73
|
+
// The bootstrap below commits each schema statement on its own: under
|
|
74
|
+
// FULL, ~120 fsyncs on a new file. Each step checks the schema before
|
|
75
|
+
// changing it and only reads outside the database, so a step lost to a
|
|
76
|
+
// power failure reruns on the next open, and WAL stays consistent under
|
|
77
|
+
// NORMAL. FULL is restored after it; the next commit's fsync of the WAL
|
|
78
|
+
// persists the bootstrap's frames too.
|
|
79
|
+
db.exec('PRAGMA synchronous = NORMAL;')
|
|
73
80
|
db.exec('PRAGMA foreign_keys = ON;')
|
|
74
81
|
migrateManagedTableNames(db)
|
|
75
|
-
db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + GITHUB_COMMIT_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA + BUNDLE_BUILD_LEASE_SCHEMA + UPSTREAM_CACHE_SCHEMA)
|
|
82
|
+
db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + GITHUB_COMMIT_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA + BUNDLE_BUILD_LEASE_SCHEMA + UPSTREAM_CACHE_SCHEMA + TEAM_APP_SUMMARY_SCHEMA)
|
|
76
83
|
ensureColumn(db, 'managed_selected_repo', 'cached_default_branch', 'TEXT')
|
|
77
84
|
ensureColumn(db, 'managed_session', 'upload_key', 'TEXT')
|
|
78
85
|
ensureColumn(db, 'managed_session', 'viewer_session', 'TEXT REFERENCES managed_session(id) ON DELETE CASCADE')
|
|
@@ -123,6 +130,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
123
130
|
ensureColumn(db, 'managed_team', 'dependency_review', 'INTEGER NOT NULL DEFAULT 0')
|
|
124
131
|
ensureColumn(db, 'managed_finding_issue', 'auto_fix_url', 'TEXT')
|
|
125
132
|
ensureColumn(db, 'managed_finding_issue', 'auto_fix_checked_at', 'INTEGER')
|
|
133
|
+
ensureColumn(db, 'managed_finding_issue', 'duplicate_of_url', 'TEXT')
|
|
126
134
|
ensureColumn(db, 'managed_selected_repo', 'active', 'INTEGER NOT NULL DEFAULT 1')
|
|
127
135
|
for (const [column, type] of REPOSITORY_DEPENDENCY_REVIEW_COLUMNS) ensureColumn(db, 'managed_selected_repo', column, type)
|
|
128
136
|
for (const [column, type] of DEPENDENCY_REVIEW_COLUMNS) ensureColumn(db, 'managed_dependency_review', column, type)
|
|
@@ -133,6 +141,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
133
141
|
initCommentMethods(db)
|
|
134
142
|
initActivityMethods(db)
|
|
135
143
|
db.exec(revisionSchema())
|
|
144
|
+
db.exec('PRAGMA synchronous = FULL;')
|
|
136
145
|
} catch (err) {
|
|
137
146
|
try { db.close() } catch {}
|
|
138
147
|
throw err
|
|
@@ -1,26 +1,52 @@
|
|
|
1
|
-
import { type GithubFixResult, githubIssueClosedReason, isGithubRepoName, parseGithubFixUrl } from '../common/github-pr.ts'
|
|
1
|
+
import { type GithubFixResult, githubIssueClosedReason, isGithubRepoName, parseGithubFixUrl, parseGithubIssueUrl } from '../common/github-pr.ts'
|
|
2
2
|
import type { ManagedConfig } from './config.ts'
|
|
3
3
|
import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
|
|
4
|
-
import type { ManagedIssue } from './managed-issues.ts'
|
|
4
|
+
import type { IssueTracking, ManagedIssue } from './managed-issues.ts'
|
|
5
5
|
import { ensureUserAccessToken } from './github-oauth.ts'
|
|
6
6
|
import { lookupFixes } from './github-pulls.ts'
|
|
7
7
|
|
|
8
|
-
export type IssueFixUpdate = { findingId: string; issueUrl: string; previous:
|
|
8
|
+
export type IssueFixUpdate = { findingId: string; issueUrl: string; previous: IssueTracking; next: IssueTracking; checkedAt: number }
|
|
9
9
|
type Pull = { number: number; title: string; body: string | null; state: string; isDraft: boolean; createdAt: string;
|
|
10
10
|
repository: { databaseId: number; nameWithOwner: string } }
|
|
11
11
|
type Connection = { nodes: Pull[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } }
|
|
12
|
-
type
|
|
12
|
+
type RepositoryReference = { databaseId?: unknown; nameWithOwner?: unknown } | null | undefined
|
|
13
|
+
type IssueReference = { number?: unknown; repository?: RepositoryReference }
|
|
14
|
+
type Issue = { number: number; title: string; body: string | null; state: string; stateReason: string | null; closedByPullRequestsReferences?: Connection
|
|
15
|
+
duplicateOf?: IssueReference | null }
|
|
13
16
|
type Repository = TeamReportAccessSnapshot['repositories'][number]
|
|
17
|
+
// The issue a linked one was closed as a duplicate of, in a workspace
|
|
18
|
+
// repository: null for none (or one outside them, never followed or
|
|
19
|
+
// disclosed, as a pull request outside them isn't).
|
|
20
|
+
type Duplicate = { repo: Repository; number: number } | null
|
|
21
|
+
// `duplicateOf` is a linked issue's, from its last page read: undefined while
|
|
22
|
+
// none is, or where GitHub refused the reference.
|
|
14
23
|
type Job = { repo: Repository; kind: 'pull' | 'issue'; number: number; aliases: string[];
|
|
15
|
-
linked: boolean; cursor: string | null; pulls: Pull[]; complete: boolean }
|
|
24
|
+
linked: boolean; cursor: string | null; pulls: Pull[]; complete: boolean; duplicateOf?: Duplicate | undefined }
|
|
16
25
|
type Metadata = Exclude<GithubFixResult, { error: string }>
|
|
17
26
|
const pullFields = 'number title body state isDraft createdAt repository { databaseId nameWithOwner }'
|
|
27
|
+
// A linked issue's: the issue it was closed as a duplicate of, and a page of
|
|
28
|
+
// the pull requests linked to it.
|
|
29
|
+
const linkedFields = (cursor: string | null) => 'duplicateOf { number repository { databaseId nameWithOwner } } '
|
|
30
|
+
+ `closedByPullRequestsReferences(first: 100, includeClosedPrs: true, after: ${JSON.stringify(cursor)}) { nodes { ${pullFields} } pageInfo { hasNextPage endCursor } }`
|
|
31
|
+
// How many issues a saved one is followed through, each closed as a
|
|
32
|
+
// duplicate of the next: each is another request after the last.
|
|
33
|
+
const MAX_DUPLICATE_HOPS = 5
|
|
34
|
+
const jobKey = (repoId: number, kind: Job['kind'], number: number) => `${repoId}:${kind}:${number}`
|
|
35
|
+
const issueUrlOf = (repo: Repository, number: number) => `https://github.com/${repo.github}/issues/${number}`
|
|
36
|
+
// Whether GitHub named a repository as one is: by its stable ID and a name.
|
|
37
|
+
const isRepositoryReference = (ref: RepositoryReference): ref is { databaseId: number; nameWithOwner: string } =>
|
|
38
|
+
Number.isSafeInteger(ref?.databaseId) && typeof ref?.nameWithOwner === 'string' && isGithubRepoName(ref.nameWithOwner)
|
|
39
|
+
// The workspace repository GitHub named, matching its name and stable ID
|
|
40
|
+
// both; none for one outside the workspace repository grants.
|
|
41
|
+
const workspaceRepository = (ref: { databaseId: number; nameWithOwner: string }, allowed: Map<string, Repository>) => {
|
|
42
|
+
const repo = allowed.get(ref.nameWithOwner.toLowerCase())
|
|
43
|
+
return repo?.repoId === ref.databaseId ? repo : null
|
|
44
|
+
}
|
|
18
45
|
|
|
19
46
|
function metadataQuery(jobs: Job[]) {
|
|
20
47
|
return `{ ${jobs.map((job, index) => {
|
|
21
48
|
const [owner, name] = job.repo.github.split('/')
|
|
22
|
-
const fields = job.kind === 'pull' ? pullFields : `number title body state stateReason ${job.linked
|
|
23
|
-
? `closedByPullRequestsReferences(first: 100, includeClosedPrs: true, after: ${JSON.stringify(job.cursor)}) { nodes { ${pullFields} } pageInfo { hasNextPage endCursor } }` : ''}`
|
|
49
|
+
const fields = job.kind === 'pull' ? pullFields : `number title body state stateReason ${job.linked ? linkedFields(job.cursor) : ''}`
|
|
24
50
|
return `r${index}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { databaseId nameWithOwner item: ${job.kind === 'pull' ? 'pullRequest' : 'issue'}(number: ${job.number}) { ${fields} } }`
|
|
25
51
|
}).join('\n')} }`
|
|
26
52
|
}
|
|
@@ -33,16 +59,32 @@ function itemMetadata(item: Issue | Pull, kind: Job['kind']): Omit<Metadata, 'ur
|
|
|
33
59
|
return { title: item.title, description: item.body ?? null, status, stateReason }
|
|
34
60
|
}
|
|
35
61
|
|
|
36
|
-
|
|
62
|
+
// The issue `issue` was closed as a duplicate of, where GitHub names one
|
|
63
|
+
// (Duplicate); undefined for a reference it gave malformed.
|
|
64
|
+
function duplicateOf(issue: Issue, allowed: Map<string, Repository>): Duplicate | undefined {
|
|
65
|
+
if (issue.state !== 'CLOSED' || issue.stateReason !== 'DUPLICATE' || issue.duplicateOf == null) return null
|
|
66
|
+
const { number, repository } = issue.duplicateOf
|
|
67
|
+
if (!Number.isSafeInteger(number) || (number as number) <= 0 || !isRepositoryReference(repository)) return undefined
|
|
68
|
+
const repo = workspaceRepository(repository, allowed)
|
|
69
|
+
return repo ? { repo, number: number as number } : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `refused`: GitHub answered with an error on the issue's duplicate alone,
|
|
73
|
+
// which, like a malformed one, leaves it shown but what it redirects to
|
|
74
|
+
// unknown.
|
|
75
|
+
function readPage(job: Job, item: Issue | Pull, metadata: Map<string, Metadata>, allowed: Map<string, Repository>, refused: boolean): boolean {
|
|
37
76
|
const details = itemMetadata(item, job.kind)
|
|
38
77
|
if (!details || item.number !== job.number) return false
|
|
39
78
|
for (const url of job.aliases) metadata.set(url, { url, ...details })
|
|
40
79
|
if (!job.linked) return false
|
|
80
|
+
const duplicate = refused ? undefined : duplicateOf(item as Issue, allowed)
|
|
81
|
+
if (duplicate === undefined) return false
|
|
41
82
|
const connection = (item as Issue).closedByPullRequestsReferences
|
|
42
83
|
if (!connection || !Array.isArray(connection.nodes) || typeof connection.pageInfo?.hasNextPage !== 'boolean'
|
|
43
84
|
|| connection.nodes.some(pr => !pr || !Number.isSafeInteger(pr.number) || pr.number <= 0 || typeof pr.createdAt !== 'string'
|
|
44
85
|
|| !Number.isFinite(Date.parse(pr.createdAt)) || !itemMetadata(pr, 'pull') || typeof pr.isDraft !== 'boolean'
|
|
45
|
-
|| !
|
|
86
|
+
|| !isRepositoryReference(pr.repository))) return false
|
|
87
|
+
job.duplicateOf = duplicate
|
|
46
88
|
job.pulls.push(...connection.nodes)
|
|
47
89
|
if (!connection.pageInfo.hasNextPage) { job.complete = true; return false }
|
|
48
90
|
if (typeof connection.pageInfo.endCursor !== 'string' || !connection.pageInfo.endCursor || connection.pageInfo.endCursor === job.cursor) return false
|
|
@@ -50,43 +92,89 @@ function readPage(job: Job, item: Issue | Pull, metadata: Map<string, Metadata>)
|
|
|
50
92
|
return true
|
|
51
93
|
}
|
|
52
94
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
// A saved issue's job, then those of the issues it was closed as a duplicate
|
|
96
|
+
// of in turn, as far as this lookup has read them, a redirect followed rather
|
|
97
|
+
// than the saved issue replaced. `next` is null where the last names none
|
|
98
|
+
// further to follow (or one already in the chain, or past
|
|
99
|
+
// MAX_DUPLICATE_HOPS); the one it names where this lookup has no linked job
|
|
100
|
+
// for it; undefined where one is yet unread, or what it names unknown.
|
|
101
|
+
function duplicateChain(root: Job, jobs: Map<string, Job>): { chain: Job[]; next: Duplicate | undefined } {
|
|
102
|
+
const chain = [root]
|
|
103
|
+
for (let job = root; ;) {
|
|
104
|
+
const target = job.duplicateOf
|
|
105
|
+
if (!target || chain.length > MAX_DUPLICATE_HOPS) return { chain, next: target && null }
|
|
106
|
+
const next = jobs.get(jobKey(target.repo.repoId, 'issue', target.number))
|
|
107
|
+
if (next && chain.includes(next)) return { chain, next: null }
|
|
108
|
+
if (!next?.linked) return { chain, next: target }
|
|
109
|
+
chain.push(job = next)
|
|
63
110
|
}
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The issues saved ones were closed as duplicates of, which this lookup has
|
|
114
|
+
// yet to read with their linked pull requests: each made a linked job, as a
|
|
115
|
+
// saved issue's own is (one read without them never read further).
|
|
116
|
+
function duplicateJobs(jobs: Map<string, Job>, roots: Iterable<string>): Job[] {
|
|
117
|
+
const found: Job[] = []
|
|
118
|
+
for (const key of roots) {
|
|
119
|
+
const root = jobs.get(key)
|
|
120
|
+
const next = root && duplicateChain(root, jobs).next
|
|
121
|
+
if (!next) continue
|
|
122
|
+
const nextKey = jobKey(next.repo.repoId, 'issue', next.number), url = issueUrlOf(next.repo, next.number)
|
|
123
|
+
let job = jobs.get(nextKey)
|
|
124
|
+
if (!job) jobs.set(nextKey, job = { repo: next.repo, kind: 'issue', number: next.number, aliases: [], linked: true, cursor: null, pulls: [], complete: false })
|
|
125
|
+
job.linked = true
|
|
126
|
+
if (!job.aliases.includes(url)) job.aliases.push(url)
|
|
127
|
+
found.push(job)
|
|
128
|
+
}
|
|
129
|
+
return found
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// `byIssue`: the saved issues each job's issue is, by its key.
|
|
133
|
+
function issueUpdates(jobs: Map<string, Job>, byIssue: Map<string, ManagedIssue[]>, allowed: Map<string, Repository>, checkedAt: number, metadata: Map<string, Metadata>) {
|
|
134
|
+
const updates: IssueFixUpdate[] = []
|
|
135
|
+
for (const [key, saved] of byIssue) {
|
|
136
|
+
const root = jobs.get(key)
|
|
137
|
+
if (!root?.linked) continue
|
|
138
|
+
// The saved issue and every one it was closed as a duplicate of in turn,
|
|
139
|
+
// each read whole: the last is tracked, and the pull requests of all.
|
|
140
|
+
const { chain, next } = duplicateChain(root, jobs)
|
|
141
|
+
if (next !== null || !chain.every(job => job.complete)) continue
|
|
66
142
|
// "Last" means the newest PR by creation time, never its latest comment.
|
|
67
|
-
const latest = job.pulls.filter(pr => pr.state !== 'CLOSED')
|
|
68
|
-
|
|
143
|
+
const latest = chain.flatMap(job => job.pulls).filter(pr => pr.state !== 'CLOSED')
|
|
144
|
+
.toSorted((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || b.number - a.number)[0]
|
|
145
|
+
const repo = latest && workspaceRepository(latest.repository, allowed)
|
|
69
146
|
// The viewer's token can have broader access than this workspace. Never
|
|
70
|
-
// persist or disclose a PR from outside the workspace repository grants
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
147
|
+
// persist or disclose a PR from outside the workspace repository grants:
|
|
148
|
+
// where the newest is one, the saved Fix stays. The duplicate, checked
|
|
149
|
+
// against the grants itself, is saved all the same.
|
|
150
|
+
const foreign = Boolean(latest && !repo)
|
|
151
|
+
const autoFix = latest && repo ? `https://github.com/${repo.github}/pull/${latest.number}` : null
|
|
152
|
+
if (latest && autoFix) metadata.set(autoFix, { url: autoFix, ...itemMetadata(latest, 'pull')! })
|
|
153
|
+
const last = chain.at(-1)!
|
|
154
|
+
const duplicate = chain.length > 1 ? issueUrlOf(last.repo, last.number) : null
|
|
155
|
+
// Only a changed derived Fix or duplicate is written: an unchanged one's
|
|
156
|
+
// check time alone would bump the annotations revision that every open
|
|
157
|
+
// team feed polls, for nothing they show. Changes still stamp it, so an
|
|
158
|
+
// older lookup never replaces a newer change (managed-issues.ts). An older
|
|
159
|
+
// lookup that differs can now land after a newer unchanged one, as it
|
|
160
|
+
// could already land before it (whose compare-and-set then fails); either
|
|
161
|
+
// way the next lookup finds the stored values differ, and writes the
|
|
162
|
+
// current ones.
|
|
163
|
+
for (const issue of saved) {
|
|
164
|
+
const tracking = { autoFix: foreign ? issue.autoFixUrl : autoFix, duplicateOf: duplicate }
|
|
165
|
+
if (issue.autoFixUrl !== tracking.autoFix || issue.duplicateOfUrl !== tracking.duplicateOf) {
|
|
166
|
+
updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, checkedAt,
|
|
167
|
+
previous: { autoFix: issue.autoFixUrl, duplicateOf: issue.duplicateOfUrl }, next: tracking })
|
|
168
|
+
}
|
|
83
169
|
}
|
|
84
170
|
}
|
|
85
171
|
return updates
|
|
86
172
|
}
|
|
87
173
|
|
|
88
174
|
// Fetch issue details, linked PR states and manual Fix metadata in one batch.
|
|
89
|
-
// Pagination
|
|
175
|
+
// Pagination, workspaces with over 200 distinct links, and issues closed as a
|
|
176
|
+
// duplicate of one not yet read need further batches. A duplicate saved last
|
|
177
|
+
// time is read in the first, as a redirect kept is: its URL among `urls`.
|
|
90
178
|
// Live GraphQL results prove this viewer's access; never publish them into the
|
|
91
179
|
// REST shared cache under an older public-visibility grant.
|
|
92
180
|
export async function lookupIssueLinks(config: ManagedConfig, db: ManagedDb, snapshot: TeamReportAccessSnapshot,
|
|
@@ -94,14 +182,22 @@ export async function lookupIssueLinks(config: ManagedConfig, db: ManagedDb, sna
|
|
|
94
182
|
const checkedAt = Date.now(), signal = AbortSignal.timeout(10_000)
|
|
95
183
|
const allowed = new Map(snapshot.repositories.filter(repo => isGithubRepoName(repo.github)).map(repo => [repo.github.toLowerCase(), repo]))
|
|
96
184
|
const jobs = new Map<string, Job>()
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
185
|
+
// The saved issues by their issue's key, and the issues read with their
|
|
186
|
+
// linked pull requests: theirs, and the duplicates they were saved with.
|
|
187
|
+
const byIssue = new Map<string, ManagedIssue[]>(), linked = new Set<string>()
|
|
188
|
+
for (const issue of issues) {
|
|
189
|
+
const ref = parseGithubIssueUrl(issue.issueUrl)
|
|
190
|
+
if (!ref) continue
|
|
191
|
+
const key = jobKey(issue.repoId, 'issue', ref.number)
|
|
192
|
+
byIssue.set(key, [...byIssue.get(key) ?? [], issue])
|
|
193
|
+
linked.add(key)
|
|
194
|
+
const duplicate = parseGithubIssueUrl(issue.duplicateOfUrl), repo = duplicate && allowed.get(duplicate.repo.toLowerCase())
|
|
195
|
+
if (duplicate && repo) linked.add(jobKey(repo.repoId, 'issue', duplicate.number))
|
|
196
|
+
}
|
|
101
197
|
for (const url of urls) {
|
|
102
198
|
const ref = parseGithubFixUrl(url), repo = ref && allowed.get(ref.repo.toLowerCase())
|
|
103
199
|
if (!ref || !repo) continue
|
|
104
|
-
const key =
|
|
200
|
+
const key = jobKey(repo.repoId, ref.kind, ref.number), previous = jobs.get(key)
|
|
105
201
|
if (previous) { previous.aliases.push(url); continue }
|
|
106
202
|
jobs.set(key, { repo, kind: ref.kind, number: ref.number, aliases: [url], linked: linked.has(key), cursor: null, pulls: [], complete: false })
|
|
107
203
|
}
|
|
@@ -109,11 +205,12 @@ export async function lookupIssueLinks(config: ManagedConfig, db: ManagedDb, sna
|
|
|
109
205
|
const withinDeadline: typeof fetch = (input, init) => { signal.throwIfAborted(); return fetchImpl(input, { ...init, signal }) }
|
|
110
206
|
const token = await ensureUserAccessToken(config, db, snapshot.user.id, checkedAt, withinDeadline)
|
|
111
207
|
if (!token) return { fixes: await lookupFixes(config, db, snapshot, urls, withinDeadline), updates: [] }
|
|
112
|
-
const metadata = new Map<string, Metadata>()
|
|
113
|
-
let pending =
|
|
208
|
+
const metadata = new Map<string, Metadata>()
|
|
209
|
+
let pending = [...jobs.values()]
|
|
114
210
|
for (let page = 0; pending.length > 0 && page < 100 && !signal.aborted; page++) {
|
|
115
211
|
const batch = pending.slice(0, 200), query = metadataQuery(batch)
|
|
116
212
|
let data: Record<string, { databaseId: number; nameWithOwner: string; item: Issue | Pull } | null>
|
|
213
|
+
const refused = new Set<string>()
|
|
117
214
|
try {
|
|
118
215
|
const response = await withinDeadline('https://api.github.com/graphql', {
|
|
119
216
|
method: 'POST', redirect: 'error',
|
|
@@ -123,18 +220,25 @@ export async function lookupIssueLinks(config: ManagedConfig, db: ManagedDb, sna
|
|
|
123
220
|
const body = await response.json() as { data?: typeof data; errors?: { path?: unknown[] }[] }
|
|
124
221
|
// Errors attached to an alias invalidate that whole job, including its
|
|
125
222
|
// connection. Other aliases can succeed even if one issue was deleted.
|
|
223
|
+
// One on the issue it duplicates alone, which the viewer may be unable
|
|
224
|
+
// to read, leaves its status, and only what it redirects to unknown.
|
|
126
225
|
if (!body?.data || body.errors != null && (!Array.isArray(body.errors)
|
|
127
226
|
|| body.errors.some(error => typeof error?.path?.[0] !== 'string' || !/^r\d+$/u.test(error.path[0])))) break
|
|
128
227
|
data = body.data
|
|
129
|
-
for (const error of body.errors ?? [])
|
|
228
|
+
for (const error of body.errors ?? []) {
|
|
229
|
+
const [alias, field, reference] = error.path! as string[]
|
|
230
|
+
if (field === 'item' && reference === 'duplicateOf') refused.add(alias!)
|
|
231
|
+
else data[alias!] = null
|
|
232
|
+
}
|
|
130
233
|
} catch { break }
|
|
131
234
|
pending = pending.slice(batch.length)
|
|
132
235
|
for (const [index, job] of batch.entries()) {
|
|
133
236
|
const repository = data[`r${index}`]
|
|
134
237
|
if (repository?.databaseId !== job.repo.repoId || typeof repository.nameWithOwner !== 'string' || repository.nameWithOwner.toLowerCase() !== job.repo.github.toLowerCase()) continue
|
|
135
|
-
if (readPage(job, repository.item, metadata)) pending.push(job)
|
|
238
|
+
if (readPage(job, repository.item, metadata, allowed, refused.has(`r${index}`))) pending.push(job)
|
|
136
239
|
}
|
|
240
|
+
for (const job of duplicateJobs(jobs, byIssue.keys())) if (!pending.includes(job)) pending.push(job)
|
|
137
241
|
}
|
|
138
|
-
const updates = issueUpdates(
|
|
242
|
+
const updates = issueUpdates(jobs, byIssue, allowed, checkedAt, metadata)
|
|
139
243
|
return { fixes: [...metadata.values()], updates }
|
|
140
244
|
}
|