@preventive/triage 1.0.0-alpha.33 → 1.0.0-alpha.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/linked-findings.js +5 -4
- package/common/bundle-metadata.js +4 -3
- package/common/bundle-source-links.d.ts +2 -1
- package/common/bundle-source-links.js +133 -6
- package/common/bundle-sources.d.ts +2 -0
- package/common/bundle-sources.js +2 -4
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/routes.js +20 -1
- package/common/managed/triage.ts +2 -2
- package/out/client-managed-import.js +51 -46
- package/out/client-managed.js +252 -72
- package/out/client-sync.js +14 -14
- package/out/graph.js +11 -11
- package/out/index.html +1 -0
- package/out/terminal.js +320 -212
- package/out/view.css +1 -1
- package/out/view.js +410 -202
- package/package.json +22 -17
- package/server-managed/activity.ts +15 -1
- package/server-managed/bundle-advisories.ts +8 -3
- package/server-managed/bundle-advisory-inventory.ts +27 -25
- package/server-managed/bundle-build-worker.js +45 -9
- package/server-managed/bundle-build.ts +35 -11
- package/server-managed/bundle-cache.ts +1 -1
- package/server-managed/bundle.ts +17 -1
- package/server-managed/config.ts +5 -0
- package/server-managed/db-methods.ts +168 -49
- package/server-managed/db-neon.ts +59 -4
- package/server-managed/db-schema.ts +12 -3
- package/server-managed/db.ts +9 -1
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issues.ts +1 -1
- package/server-managed/http-response.ts +7 -1
- package/server-managed/http.ts +248 -80
- package/server-managed/index.ts +4 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/managed-issues.ts +31 -3
- package/server-managed/management.ts +17 -4
- package/server-managed/public-workspace.ts +5 -2
- package/server-managed/report-sources.ts +6 -5
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/revisions.ts +2 -2
- package/server-managed/sql.ts +3 -2
- package/server-managed/storage.ts +17 -0
- package/server-managed/team-feed.ts +3 -3
- package/server-managed/team-reports.ts +24 -15
- package/server-managed/upstream-cache.ts +73 -0
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
package/server-managed/db.ts
CHANGED
|
@@ -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
|
-
|