@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.
Files changed (112) hide show
  1. package/common/bundle-compare-diff.js +28 -9
  2. package/common/bundle-compare-inputs.d.ts +1 -0
  3. package/common/bundle-compare-inputs.js +91 -15
  4. package/common/bundle-comparison.js +14 -7
  5. package/common/default-scan-models.ts +3 -0
  6. package/common/dependency-reviews.d.ts +1 -1
  7. package/common/dependency-reviews.js +1 -1
  8. package/common/managed/finding-detail.d.ts +4 -2
  9. package/common/managed/finding-detail.js +27 -15
  10. package/common/managed/team-app.d.ts +9 -0
  11. package/common/managed/team-app.js +59 -6
  12. package/common/workspace-app.js +38 -6
  13. package/out/activity.js +4 -4
  14. package/out/chunk-4EMM6UCD.js +1 -0
  15. package/out/chunk-4ISO5F7Y.js +7 -0
  16. package/out/chunk-4XIH7YTX.js +1 -0
  17. package/out/chunk-6W5BMATD.js +1 -0
  18. package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
  19. package/out/chunk-DEVAORP2.js +1 -0
  20. package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
  21. package/out/chunk-E3LFSUDJ.js +9 -0
  22. package/out/chunk-FHIVVVUA.js +6 -0
  23. package/out/chunk-G3LY2FV4.js +1 -0
  24. package/out/chunk-HAZKDK4M.js +4 -0
  25. package/out/chunk-HOB3YPS5.js +1 -0
  26. package/out/chunk-I47TYPXO.js +52 -0
  27. package/out/chunk-J7A5OSZE.js +244 -0
  28. package/out/chunk-JFL7WLRV.js +1 -0
  29. package/out/chunk-JMG5DXII.js +2 -0
  30. package/out/chunk-JOXLZXMI.js +1 -0
  31. package/out/chunk-L4CTEMRG.js +2 -0
  32. package/out/chunk-M37X4LCF.js +14 -0
  33. package/out/chunk-M3Y6SLC4.js +1 -0
  34. package/out/chunk-NXATZJQ2.js +1 -0
  35. package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
  36. package/out/chunk-PAD2JHZT.js +1 -0
  37. package/out/chunk-QMVOEBWC.js +13 -0
  38. package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
  39. package/out/chunk-RRBIJL2B.js +1 -0
  40. package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
  41. package/out/chunk-TOXMUOIW.js +5 -0
  42. package/out/chunk-UBOCLLF2.js +1 -0
  43. package/out/chunk-UCP3JRE2.js +1 -0
  44. package/out/{chunk-NCN3M5EP.js → chunk-VC72ZO3P.js} +2 -2
  45. package/out/chunk-VRX2APNA.js +9 -0
  46. package/out/{chunk-IMM274IW.js → chunk-VZMELPVL.js} +4 -3
  47. package/out/{chunk-CHRBUGVN.js → chunk-WHVXTJHK.js} +1 -1
  48. package/out/chunk-WMFDQS2L.js +11 -0
  49. package/out/chunk-WNSVBOSO.js +41 -0
  50. package/out/chunk-WREU7JAG.js +1 -0
  51. package/out/chunk-Y5ZHFNBD.js +2 -0
  52. package/out/chunk-YF4M5OCI.js +1 -0
  53. package/out/chunk-Z667U2UJ.js +1 -0
  54. package/out/chunk-ZQHLECTI.js +1 -0
  55. package/out/client-managed-import.js +1 -1
  56. package/out/client-managed.js +27 -26
  57. package/out/compare.js +14 -14
  58. package/out/dialogs.js +36 -51
  59. package/out/graph.js +17 -15
  60. package/out/index.html +26 -10
  61. package/out/prism.js +1 -1
  62. package/out/scan.js +2 -2
  63. package/out/terminal.js +232 -232
  64. package/out/view.css +1 -1
  65. package/out/view.js +15 -15
  66. package/package.json +12 -7
  67. package/risk/actions/resolve.js +36 -5
  68. package/risk/index.d.ts +20 -2
  69. package/risk/npm/malware.js +6 -4
  70. package/risk/npm/registry.js +8 -6
  71. package/server-managed/bundle-advisories.ts +56 -74
  72. package/server-managed/bundle-build-worker.js +61 -4
  73. package/server-managed/bundle-build.ts +13 -4
  74. package/server-managed/bundle-dependency-reviews.ts +74 -0
  75. package/server-managed/bundle-validation.ts +32 -0
  76. package/server-managed/db-methods.ts +51 -16
  77. package/server-managed/db-neon.ts +18 -2
  78. package/server-managed/db.ts +11 -2
  79. package/server-managed/github-issue-links.ts +151 -47
  80. package/server-managed/github-pulls.ts +34 -15
  81. package/server-managed/history-windows.ts +172 -0
  82. package/server-managed/http.ts +227 -12
  83. package/server-managed/index.ts +13 -1
  84. package/server-managed/managed-issues.ts +29 -15
  85. package/server-managed/npm-insights.ts +4 -3
  86. package/server-managed/npm-kept.ts +4 -0
  87. package/server-managed/package-entry-points.ts +37 -9
  88. package/server-managed/pretty-print.ts +7 -5
  89. package/server-managed/public-workspace.ts +2 -2
  90. package/server-managed/repository-actions.ts +7 -4
  91. package/server-managed/repository-browser.ts +11 -4
  92. package/server-managed/repository-history.ts +2 -2
  93. package/server-managed/repository-pulse.ts +11 -2
  94. package/server-managed/repository-risk-audit.ts +4 -5
  95. package/server-managed/repository-risk.ts +4 -4
  96. package/server-managed/sql.ts +2 -1
  97. package/server-managed/stasis-config.ts +46 -0
  98. package/server-managed/team-app-summaries.ts +54 -0
  99. package/server-managed/team-app.ts +52 -31
  100. package/server-managed/team-reports.ts +34 -13
  101. package/server-managed/upstream-cache.ts +41 -32
  102. package/server-managed/workspace-shares.ts +3 -1
  103. package/out/chunk-2ZDZWLFY.js +0 -1
  104. package/out/chunk-7VAWINQF.js +0 -6
  105. package/out/chunk-FMXWXDR4.js +0 -36
  106. package/out/chunk-IZ7POWI3.js +0 -18
  107. package/out/chunk-LJ7XRXFU.js +0 -52
  108. package/out/chunk-OGAYMEER.js +0 -232
  109. package/out/chunk-QB4CBOX2.js +0 -1
  110. package/out/chunk-TQJDYHVE.js +0 -2
  111. package/out/chunk-VH4XMZIW.js +0 -2
  112. 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
- const cacheKey = createHash('sha256').update(JSON.stringify([
650
- r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role, links,
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) ORDER BY i.finding_id)
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
- return { user, teamId, reportId, hiddenReportId: hidden?.id ?? null, repositories, linkRevision: linkRevisionOf(jsonRows(session.links)), reports: selected.map(row => ({
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.setManagedIssueAutoFix(update.findingId, update.issueUrl, update.previous, update.next, update.checkedAt)
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 = 33
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]) await migrate(db)
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')
@@ -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
- db.exec('PRAGMA synchronous = FULL;')
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: string | null; next: string | null; checkedAt: number }
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 Issue = { number: number; title: string; body: string | null; state: string; stateReason: string | null; closedByPullRequestsReferences?: Connection }
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
- function readPage(job: Job, item: Issue | Pull, metadata: Map<string, Metadata>): boolean {
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
- || !Number.isSafeInteger(pr.repository?.databaseId) || typeof pr.repository?.nameWithOwner !== 'string' || !isGithubRepoName(pr.repository.nameWithOwner))) return false
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
- function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string, Repository>, checkedAt: number, metadata: Map<string, Metadata>) {
54
- const updates: IssueFixUpdate[] = []
55
- // The issues each job's issue is, by repository and number, read once.
56
- const byIssue = new Map<string, ManagedIssue[]>()
57
- for (const issue of issues) {
58
- const ref = parseGithubFixUrl(issue.issueUrl)
59
- if (ref?.kind !== 'issue') continue
60
- const key = `${issue.repoId}:${ref.number}`, same = byIssue.get(key)
61
- if (same) same.push(issue)
62
- else byIssue.set(key, [issue])
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
- for (const job of jobs) {
65
- if (!job.linked || !job.complete) continue
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').toSorted((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || b.number - a.number)[0]
68
- const repo = latest && allowed.get(latest.repository.nameWithOwner.toLowerCase())
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
- if (latest && (!repo || repo.repoId !== latest.repository.databaseId)) continue
72
- const next = latest ? `https://github.com/${repo!.github}/pull/${latest.number}` : null
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.
81
- for (const issue of byIssue.get(`${job.repo.repoId}:${job.number}`) ?? []) {
82
- if (issue.autoFixUrl !== next) updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
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 and workspaces with over 200 distinct links need further batches.
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
- const linked = new Set(issues.filter(issue => issue.issueUrl).map(issue => {
98
- const ref = parseGithubFixUrl(issue.issueUrl)
99
- return ref?.kind === 'issue' ? `${issue.repoId}:issue:${ref.number}` : ''
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 = `${repo.repoId}:${ref.kind}:${ref.number}`, previous = jobs.get(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>(), selected = [...jobs.values()]
113
- let pending = selected
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 ?? []) data[error.path![0] as string] = null
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(selected, issues, allowed, checkedAt, metadata)
242
+ const updates = issueUpdates(jobs, byIssue, allowed, checkedAt, metadata)
139
243
  return { fixes: [...metadata.values()], updates }
140
244
  }