@preventive/triage 1.0.0-alpha.32 → 1.0.0-alpha.34

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 (57) hide show
  1. package/client/linked-findings.js +5 -4
  2. package/common/bundle-metadata.js +4 -3
  3. package/common/bundle-source-links.d.ts +2 -1
  4. package/common/bundle-source-links.js +133 -6
  5. package/common/bundle-sources.d.ts +2 -0
  6. package/common/bundle-sources.js +2 -4
  7. package/common/managed/repository-alias.ts +65 -0
  8. package/common/managed/routes.js +20 -1
  9. package/common/managed/triage.ts +2 -2
  10. package/out/client-managed-import.js +51 -46
  11. package/out/client-managed.js +252 -72
  12. package/out/client-sync.js +14 -14
  13. package/out/graph.js +11 -11
  14. package/out/index.html +1 -0
  15. package/out/terminal.js +169 -169
  16. package/out/view.css +1 -1
  17. package/out/view.js +417 -201
  18. package/package.json +23 -17
  19. package/server-managed/activity.ts +15 -1
  20. package/server-managed/brotli.ts +3 -3
  21. package/server-managed/bundle-advisories.ts +8 -3
  22. package/server-managed/bundle-advisory-inventory.ts +27 -25
  23. package/server-managed/bundle-build-worker.js +45 -9
  24. package/server-managed/bundle-build.ts +35 -11
  25. package/server-managed/bundle-cache.ts +1 -1
  26. package/server-managed/bundle.ts +17 -1
  27. package/server-managed/config.ts +7 -2
  28. package/server-managed/db-methods.ts +170 -49
  29. package/server-managed/db-neon.ts +59 -4
  30. package/server-managed/db-schema.ts +12 -3
  31. package/server-managed/db.ts +9 -1
  32. package/server-managed/github-issue-links.ts +127 -0
  33. package/server-managed/github-issues.ts +1 -1
  34. package/server-managed/http-response.ts +7 -1
  35. package/server-managed/http.ts +248 -80
  36. package/server-managed/index.ts +4 -0
  37. package/server-managed/link-reports.ts +158 -0
  38. package/server-managed/managed-issues.ts +31 -3
  39. package/server-managed/management.ts +17 -4
  40. package/server-managed/object-storage-disk.ts +7 -3
  41. package/server-managed/object-storage-vercel.ts +6 -2
  42. package/server-managed/object-storage.ts +3 -2
  43. package/server-managed/public-workspace.ts +5 -2
  44. package/server-managed/report-sources.ts +6 -5
  45. package/server-managed/report-store.ts +47 -0
  46. package/server-managed/repository-aliases.ts +91 -0
  47. package/server-managed/revisions.ts +2 -2
  48. package/server-managed/sql.ts +3 -2
  49. package/server-managed/storage-encryption.ts +72 -6
  50. package/server-managed/storage-maintenance.ts +64 -49
  51. package/server-managed/storage-payload.ts +11 -10
  52. package/server-managed/storage-stores.ts +3 -1
  53. package/server-managed/storage.ts +17 -0
  54. package/server-managed/team-feed.ts +3 -3
  55. package/server-managed/team-reports.ts +24 -15
  56. package/server-managed/upstream-cache.ts +73 -0
  57. package/server-managed/workspace-shares.ts +16 -12
@@ -1,3 +1,5 @@
1
+ import { LINK_REPORT_SCHEMA } from './link-reports.ts'
2
+ import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
1
3
  import { AsyncLocalStorage } from 'node:async_hooks'
2
4
  import { WebSocket } from 'ws'
3
5
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
@@ -7,8 +9,9 @@ import { STORAGE_SCHEMA } from './storage-db.ts'
7
9
  import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
8
10
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
9
11
  import { BUNDLE_BUILD_LEASE_SCHEMA } from './bundle-build-leases.ts'
12
+ import { UPSTREAM_CACHE_SCHEMA } from './upstream-cache.ts'
10
13
  import { COMMENT_SCHEMA } from './comments.ts'
11
- import { ACTIVITY_SCHEMA } from './activity.ts'
14
+ import { ACTIVITY_SCHEMA, uploadAction } from './activity.ts'
12
15
  import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
13
16
  import { postgresSchema, postgresSql } from './sql-postgres.ts'
14
17
  import { managedTableRenames } from './db-table-names.ts'
@@ -31,7 +34,7 @@ async function currentSchema(db: PgConnection): Promise<boolean> {
31
34
  const exists = (await db.query("SELECT to_regclass('managed_schema_version') AS name")).rows[0]?.['name']
32
35
  if (!exists) return false
33
36
  const versions = new Set((await db.query('SELECT version FROM managed_schema_version')).rows.map(row => Number(row['version'])))
34
- return Array.from({ length: 14 }, (_, i) => i + 1).every(version => versions.has(version))
37
+ return Array.from({ length: 22 }, (_, i) => i + 1).every(version => versions.has(version))
35
38
  }
36
39
 
37
40
  async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
@@ -53,6 +56,57 @@ async function migrateGithubMetadata(db: PgConnection): Promise<void> {
53
56
  await db.query('INSERT INTO managed_schema_version VALUES (14) ON CONFLICT DO NOTHING')
54
57
  }
55
58
 
59
+ async function migrateBundleVisibility(db: PgConnection): Promise<void> {
60
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 15')).rows.length === 0) {
61
+ await db.query('ALTER TABLE managed_bundle ADD COLUMN IF NOT EXISTS visible INTEGER NOT NULL DEFAULT 1')
62
+ await db.query('INSERT INTO managed_schema_version VALUES (15)')
63
+ }
64
+ }
65
+
66
+ async function migrateLinkReports(db: PgConnection): Promise<void> {
67
+ await db.query(postgresSchema(revisionSchema(true)))
68
+ await db.query('INSERT INTO managed_schema_version VALUES (16) ON CONFLICT DO NOTHING')
69
+ }
70
+
71
+ async function migrateRepositoryAliases(db: PgConnection): Promise<void> {
72
+ await db.query(postgresSchema(REPOSITORY_ALIAS_SCHEMA))
73
+ await db.query('INSERT INTO managed_schema_version VALUES (17) ON CONFLICT DO NOTHING')
74
+ }
75
+
76
+ async function migrateHiddenTeams(db: PgConnection): Promise<void> {
77
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 18')).rows.length > 0) return
78
+ await db.query('ALTER TABLE managed_team ADD COLUMN IF NOT EXISTS hidden INTEGER NOT NULL DEFAULT 0')
79
+ await db.query('INSERT INTO managed_schema_version VALUES (18)')
80
+ }
81
+
82
+ async function migrateIssueFixes(db: PgConnection): Promise<void> {
83
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 19')).rows.length > 0) return
84
+ await db.query('ALTER TABLE managed_finding_issue ADD COLUMN IF NOT EXISTS auto_fix_url TEXT, ADD COLUMN IF NOT EXISTS auto_fix_checked_at BIGINT')
85
+ await db.query(postgresSchema(revisionSchema(true)))
86
+ await db.query('INSERT INTO managed_schema_version VALUES (19)')
87
+ }
88
+
89
+ // Existing rows stay NULL (unknown): some may have been built on the server.
90
+ async function migrateBundleProvenance(db: PgConnection): Promise<void> {
91
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 20')).rows.length > 0) return
92
+ await db.query('ALTER TABLE managed_bundle ADD COLUMN IF NOT EXISTS provenance TEXT')
93
+ await createUploadTrigger(db, 'bundle', false)
94
+ await db.query('INSERT INTO managed_schema_version VALUES (20)')
95
+ }
96
+
97
+ async function migrateUpstreamCache(db: PgConnection): Promise<void> {
98
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 21')).rows.length > 0) return
99
+ await db.query(postgresSchema(UPSTREAM_CACHE_SCHEMA))
100
+ await db.query('INSERT INTO managed_schema_version VALUES (21)')
101
+ }
102
+
103
+ // Existing builds stay NULL: their conditions were never recorded.
104
+ async function migrateBundleBuildConditions(db: PgConnection): Promise<void> {
105
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 22')).rows.length > 0) return
106
+ await db.query('ALTER TABLE managed_bundle ADD COLUMN IF NOT EXISTS build_conditions TEXT')
107
+ await db.query('INSERT INTO managed_schema_version VALUES (22)')
108
+ }
109
+
56
110
  async function initialize(db: PgConnection): Promise<void> {
57
111
  if (await currentSchema(db)) return
58
112
  await db.query('BEGIN')
@@ -119,13 +173,14 @@ async function initialize(db: PgConnection): Promise<void> {
119
173
  await db.query('ALTER TABLE managed_user ADD COLUMN IF NOT EXISTS gh_tokens_encrypted INTEGER NOT NULL DEFAULT 0')
120
174
  await db.query('INSERT INTO managed_schema_version VALUES (9)')
121
175
  }
176
+ await db.query(postgresSchema(LINK_REPORT_SCHEMA))
122
177
  if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 10')).rows.length === 0) {
123
178
  await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (10)')
124
179
  }
125
180
  if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 11')).rows.length === 0) {
126
181
  await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
127
182
  }
128
- for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases]) await migrate(db)
183
+ for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions]) await migrate(db)
129
184
  await db.query('COMMIT')
130
185
  } catch (err) {
131
186
  await db.query('ROLLBACK')
@@ -139,7 +194,7 @@ async function createUploadTrigger(db: PgConnection, type: string, createTrigger
139
194
  INSERT INTO managed_activity (id, kind, actor, action, repo, report_id, report, at, bundle_id, actor_id)
140
195
  VALUES ('${type}-upload:' || NEW.id, 'upload',
141
196
  COALESCE(NEW.uploaded_by_login, (SELECT login FROM managed_user WHERE id = NEW.uploaded_by)),
142
- 'uploaded a ${type}', (SELECT full_name FROM managed_selected_repo WHERE repo_id = NEW.repo_id),
197
+ ${uploadAction(type, 'NEW')}, (SELECT full_name FROM managed_selected_repo WHERE repo_id = NEW.repo_id),
143
198
  ${type === 'report' ? 'NEW.id' : 'NULL'}, NEW.filename, NEW.uploaded_at,
144
199
  ${type === 'bundle' ? 'NEW.id' : 'NULL'}, NEW.uploaded_by);
145
200
  RETURN NEW;
@@ -1,3 +1,5 @@
1
+ import { LINK_REPORT_SCHEMA } from './link-reports.ts'
2
+ import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
1
3
  import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
2
4
 
3
5
  export const MANAGED_SCHEMA = `
@@ -76,7 +78,13 @@ CREATE TABLE IF NOT EXISTS managed_bundle (
76
78
  uploaded_by_login TEXT,
77
79
  repo_id INTEGER REFERENCES managed_selected_repo(repo_id) ON DELETE SET NULL,
78
80
  repo_directory TEXT NOT NULL DEFAULT '',
79
- uploaded_at INTEGER NOT NULL
81
+ visible INTEGER NOT NULL DEFAULT 1,
82
+ uploaded_at INTEGER NOT NULL,
83
+ -- 'upload' or 'build' (generated on this server). A server build of bytes
84
+ -- already stored promotes the row to 'build'; NULL predates the column.
85
+ provenance TEXT,
86
+ -- JSON {preset, conditions, platforms} of a JS/TS server build; NULL otherwise.
87
+ build_conditions TEXT
80
88
  ) STRICT;
81
89
 
82
90
  CREATE INDEX IF NOT EXISTS managed_bundle_uploaded_at_idx ON managed_bundle(uploaded_at);
@@ -168,12 +176,13 @@ CREATE TABLE IF NOT EXISTS managed_finding_triage_event (
168
176
  CREATE INDEX IF NOT EXISTS finding_triage_event_finding_idx ON managed_finding_triage_event(finding_id, seq);
169
177
  CREATE INDEX IF NOT EXISTS finding_triage_event_actor_at_idx ON managed_finding_triage_event(actor_id, at);
170
178
 
171
- -- Teams group users + repos for access scoping. A team has just a name here;
179
+ -- Teams group users + repos for access scoping. Hidden teams grant no access;
172
180
  -- the two link tables below carry the many-many relations.
173
181
  CREATE TABLE IF NOT EXISTS managed_team (
174
182
  id TEXT PRIMARY KEY,
175
183
  slug TEXT NOT NULL,
176
184
  name TEXT NOT NULL UNIQUE,
185
+ hidden INTEGER NOT NULL DEFAULT 0,
177
186
  created_at INTEGER NOT NULL,
178
187
  updated_at INTEGER NOT NULL
179
188
  ) STRICT;
@@ -203,4 +212,4 @@ CREATE TABLE IF NOT EXISTS managed_team_user (
203
212
  ) STRICT;
204
213
 
205
214
  CREATE INDEX IF NOT EXISTS team_user_user_idx ON managed_team_user(user_id);
206
- ` + WORKSPACE_SHARE_SCHEMA
215
+ ` + WORKSPACE_SHARE_SCHEMA + LINK_REPORT_SCHEMA + REPOSITORY_ALIAS_SCHEMA
@@ -12,6 +12,7 @@ import { STORAGE_SCHEMA } from './storage-db.ts'
12
12
  import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
13
13
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
14
14
  import { BUNDLE_BUILD_LEASE_SCHEMA } from './bundle-build-leases.ts'
15
+ import { UPSTREAM_CACHE_SCHEMA } from './upstream-cache.ts'
15
16
  import { managedTableRenames } from './db-table-names.ts'
16
17
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
17
18
  import { createSqliteDriver, scopeManagedMethods } from './sql.ts'
@@ -68,7 +69,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
68
69
  db.exec('PRAGMA synchronous = FULL;')
69
70
  db.exec('PRAGMA foreign_keys = ON;')
70
71
  migrateManagedTableNames(db)
71
- db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA + BUNDLE_BUILD_LEASE_SCHEMA)
72
+ db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA + BUNDLE_BUILD_LEASE_SCHEMA + UPSTREAM_CACHE_SCHEMA)
72
73
  ensureColumn(db, 'managed_selected_repo', 'cached_default_branch', 'TEXT')
73
74
  for (const table of ['managed_report', 'managed_bundle']) {
74
75
  ensureColumn(db, table, 'data_key', 'TEXT')
@@ -103,6 +104,13 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
103
104
  if (addedVisible) db.prepare('UPDATE managed_report SET visible = 1 WHERE visible = 0').run()
104
105
  ensureColumn(db, 'managed_bundle', 'uploaded_by_login', 'TEXT')
105
106
  ensureColumn(db, 'managed_bundle', 'repo_directory', "TEXT NOT NULL DEFAULT ''")
107
+ ensureColumn(db, 'managed_bundle', 'visible', 'INTEGER NOT NULL DEFAULT 1')
108
+ // Existing rows stay NULL (unknown): some may have been built on the server.
109
+ ensureColumn(db, 'managed_bundle', 'provenance', 'TEXT')
110
+ ensureColumn(db, 'managed_bundle', 'build_conditions', 'TEXT')
111
+ ensureColumn(db, 'managed_team', 'hidden', 'INTEGER NOT NULL DEFAULT 0')
112
+ ensureColumn(db, 'managed_finding_issue', 'auto_fix_url', 'TEXT')
113
+ ensureColumn(db, 'managed_finding_issue', 'auto_fix_checked_at', 'INTEGER')
106
114
  ensureColumn(db, 'managed_selected_repo', 'active', 'INTEGER NOT NULL DEFAULT 1')
107
115
  ensureColumn(db, 'managed_finding_triage_event', 'report_id', 'TEXT')
108
116
  ensureColumn(db, 'managed_finding_triage_event', 'report', 'TEXT')
@@ -0,0 +1,127 @@
1
+ import { type GithubFixResult, githubIssueClosedReason, isGithubRepoName, parseGithubFixUrl } from '../common/github-pr.ts'
2
+ import type { ManagedConfig } from './config.ts'
3
+ import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
4
+ import type { ManagedIssue } from './managed-issues.ts'
5
+ import { ensureUserAccessToken } from './github-oauth.ts'
6
+ import { lookupFixes } from './github-pulls.ts'
7
+
8
+ export type IssueFixUpdate = { findingId: string; issueUrl: string; previous: string | null; next: string | null; checkedAt: number }
9
+ type Pull = { number: number; title: string; body: string | null; state: string; isDraft: boolean; createdAt: string;
10
+ repository: { databaseId: number; nameWithOwner: string } }
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 }
13
+ type Repository = TeamReportAccessSnapshot['repositories'][number]
14
+ type Job = { repo: Repository; kind: 'pull' | 'issue'; number: number; aliases: string[];
15
+ linked: boolean; cursor: string | null; pulls: Pull[]; complete: boolean }
16
+ type Metadata = Exclude<GithubFixResult, { error: string }>
17
+ const pullFields = 'number title body state isDraft createdAt repository { databaseId nameWithOwner }'
18
+
19
+ function metadataQuery(jobs: Job[]) {
20
+ return `{ ${jobs.map((job, index) => {
21
+ 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 } }` : ''}`
24
+ 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
+ }).join('\n')} }`
26
+ }
27
+
28
+ function itemMetadata(item: Issue | Pull, kind: Job['kind']): Omit<Metadata, 'url'> | null {
29
+ if (!item || typeof item.title !== 'string' || !item.title.trim() || item.title.length > 1024
30
+ || item.body != null && typeof item.body !== 'string' || !['OPEN', 'CLOSED', ...(kind === 'pull' ? ['MERGED'] : [])].includes(item.state)) return null
31
+ const status = item.state === 'MERGED' ? 'merged' : item.state === 'CLOSED' ? 'closed' : 'isDraft' in item && item.isDraft === true ? 'draft' : 'open'
32
+ const stateReason = kind === 'issue' && status === 'closed' ? githubIssueClosedReason(String((item as Issue).stateReason).toLowerCase()) : null
33
+ return { title: item.title, description: item.body ?? null, status, stateReason }
34
+ }
35
+
36
+ function readPage(job: Job, item: Issue | Pull, metadata: Map<string, Metadata>): boolean {
37
+ const details = itemMetadata(item, job.kind)
38
+ if (!details || item.number !== job.number) return false
39
+ for (const url of job.aliases) metadata.set(url, { url, ...details })
40
+ if (!job.linked) return false
41
+ const connection = (item as Issue).closedByPullRequestsReferences
42
+ if (!connection || !Array.isArray(connection.nodes) || typeof connection.pageInfo?.hasNextPage !== 'boolean'
43
+ || connection.nodes.some(pr => !pr || !Number.isSafeInteger(pr.number) || pr.number <= 0 || typeof pr.createdAt !== 'string'
44
+ || !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
46
+ job.pulls.push(...connection.nodes)
47
+ if (!connection.pageInfo.hasNextPage) { job.complete = true; return false }
48
+ if (typeof connection.pageInfo.endCursor !== 'string' || !connection.pageInfo.endCursor || connection.pageInfo.endCursor === job.cursor) return false
49
+ job.cursor = connection.pageInfo.endCursor
50
+ return true
51
+ }
52
+
53
+ function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string, Repository>, checkedAt: number, metadata: Map<string, Metadata>) {
54
+ const updates: IssueFixUpdate[] = []
55
+ for (const job of jobs) {
56
+ if (!job.linked || !job.complete) continue
57
+ // "Last" means the newest PR by creation time, never its latest comment.
58
+ 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]
59
+ const repo = latest && allowed.get(latest.repository.nameWithOwner.toLowerCase())
60
+ // The viewer's token can have broader access than this workspace. Never
61
+ // persist or disclose a PR from outside the workspace repository grants.
62
+ if (latest && (!repo || repo.repoId !== latest.repository.databaseId)) continue
63
+ const next = latest ? `https://github.com/${repo!.github}/pull/${latest.number}` : null
64
+ if (latest && next) metadata.set(next, { url: next, ...itemMetadata(latest, 'pull')! })
65
+ for (const issue of issues) {
66
+ const ref = parseGithubFixUrl(issue.issueUrl)
67
+ if (ref?.kind === 'issue' && ref.number === job.number && issue.repoId === job.repo.repoId) {
68
+ updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
69
+ }
70
+ }
71
+ }
72
+ return updates
73
+ }
74
+
75
+ // Fetch issue details, linked PR states and manual Fix metadata in one batch.
76
+ // Pagination and workspaces with over 200 distinct links need further batches.
77
+ // Live GraphQL results prove this viewer's access; never publish them into the
78
+ // REST shared cache under an older public-visibility grant.
79
+ export async function lookupIssueLinks(config: ManagedConfig, db: ManagedDb, snapshot: TeamReportAccessSnapshot,
80
+ urls: string[], issues: ManagedIssue[], fetchImpl: typeof fetch = fetch): Promise<{ fixes: GithubFixResult[]; updates: IssueFixUpdate[] }> {
81
+ const checkedAt = Date.now(), signal = AbortSignal.timeout(10_000)
82
+ const allowed = new Map(snapshot.repositories.filter(repo => isGithubRepoName(repo.github)).map(repo => [repo.github.toLowerCase(), repo]))
83
+ const jobs = new Map<string, Job>()
84
+ const linked = new Set(issues.filter(issue => issue.issueUrl).map(issue => {
85
+ const ref = parseGithubFixUrl(issue.issueUrl)
86
+ return ref?.kind === 'issue' ? `${issue.repoId}:issue:${ref.number}` : ''
87
+ }))
88
+ for (const url of urls) {
89
+ const ref = parseGithubFixUrl(url), repo = ref && allowed.get(ref.repo.toLowerCase())
90
+ if (!ref || !repo) continue
91
+ const key = `${repo.repoId}:${ref.kind}:${ref.number}`, previous = jobs.get(key)
92
+ if (previous) { previous.aliases.push(url); continue }
93
+ jobs.set(key, { repo, kind: ref.kind, number: ref.number, aliases: [url], linked: linked.has(key), cursor: null, pulls: [], complete: false })
94
+ }
95
+ if (jobs.size === 0) return { fixes: [], updates: [] }
96
+ const withinDeadline: typeof fetch = (input, init) => { signal.throwIfAborted(); return fetchImpl(input, { ...init, signal }) }
97
+ const token = await ensureUserAccessToken(config, db, snapshot.user.id, checkedAt, withinDeadline)
98
+ if (!token) return { fixes: await lookupFixes(config, db, snapshot, urls, withinDeadline), updates: [] }
99
+ const metadata = new Map<string, Metadata>(), selected = [...jobs.values()]
100
+ let pending = selected
101
+ for (let page = 0; pending.length > 0 && page < 100 && !signal.aborted; page++) {
102
+ const batch = pending.slice(0, 200), query = metadataQuery(batch)
103
+ let data: Record<string, { databaseId: number; nameWithOwner: string; item: Issue | Pull } | null>
104
+ try {
105
+ const response = await withinDeadline('https://api.github.com/graphql', {
106
+ method: 'POST', redirect: 'error',
107
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'user-agent': 'deepview-triage' }, body: JSON.stringify({ query }),
108
+ })
109
+ if (!response.ok) break
110
+ const body = await response.json() as { data?: typeof data; errors?: { path?: unknown[] }[] }
111
+ // Errors attached to an alias invalidate that whole job, including its
112
+ // connection. Other aliases can succeed even if one issue was deleted.
113
+ if (!body?.data || body.errors != null && (!Array.isArray(body.errors)
114
+ || body.errors.some(error => typeof error?.path?.[0] !== 'string' || !/^r\d+$/u.test(error.path[0])))) break
115
+ data = body.data
116
+ for (const error of body.errors ?? []) data[error.path![0] as string] = null
117
+ } catch { break }
118
+ pending = pending.slice(batch.length)
119
+ for (const [index, job] of batch.entries()) {
120
+ const repository = data[`r${index}`]
121
+ if (repository?.databaseId !== job.repo.repoId || typeof repository.nameWithOwner !== 'string' || repository.nameWithOwner.toLowerCase() !== job.repo.github.toLowerCase()) continue
122
+ if (readPage(job, repository.item, metadata)) pending.push(job)
123
+ }
124
+ }
125
+ const updates = issueUpdates(selected, issues, allowed, checkedAt, metadata)
126
+ return { fixes: [...metadata.values()], updates }
127
+ }
@@ -60,7 +60,7 @@ function issueDetails(raw: unknown, url: string, number: number, fallback: { tit
60
60
  // Report filtering also supplies the full row/link isSecurity classification.
61
61
  export async function prepareGithubIssue(config: ManagedConfig, db: ManagedDb, store: BlobStore,
62
62
  session: ManagedSession, teamId: string, context: Context, fetchImpl: typeof fetch = fetch) {
63
- const snapshot = await teamSnapshot(db, session.id, teamId)
63
+ const snapshot = await teamSnapshot(db, session.id, teamId, context.reportId)
64
64
  if (!snapshot.reports.some(report => report.id === context.reportId)) throw new TeamReportsError(404, 'no-report')
65
65
  const reports = await loadTeamReports(db, store, snapshot)
66
66
  const report = reports.find(item => item.id === context.reportId)!
@@ -2,6 +2,13 @@ import type { ServerResponse } from 'node:http'
2
2
  import { Buffer } from 'node:buffer'
3
3
  import { UPLOAD_CHUNK_BYTES } from './uploads.ts'
4
4
 
5
+ // Keep the header ASCII while filename* carries the original UTF-8 name.
6
+ export function attachmentDisposition(filename: string): string {
7
+ const fallback = filename.replaceAll(/[^\u0020-\u007E]|["\\%]/gu, '_')
8
+ const encoded = encodeURIComponent(filename.toWellFormed()).replaceAll(/['()*]/gu, ch => `%${ch.codePointAt(0)!.toString(16).toUpperCase()}`)
9
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`
10
+ }
11
+
5
12
  // Writing body chunks enables the function's streamed-response path. Data is
6
13
  // already materialized by the authorization/filtering layer before we get here.
7
14
  export function writeResponse(res: ServerResponse, body: string | Buffer): void {
@@ -15,4 +22,3 @@ export function sendJson(res: ServerResponse, status: number, body: unknown, hea
15
22
  res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers })
16
23
  writeResponse(res, JSON.stringify(body))
17
24
  }
18
-