@preventive/triage 1.0.0-alpha.47 → 1.0.0-alpha.49

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 (82) hide show
  1. package/client/dependency-paths.d.ts +2 -1
  2. package/client/dependency-paths.js +16 -4
  3. package/common/managed/agent-tasks.ts +12 -14
  4. package/common/managed/github-agents.ts +7 -6
  5. package/out/activity.js +4 -4
  6. package/out/{chunk-NOZJULWS.js → chunk-6LNQTKAV.js} +1 -1
  7. package/out/chunk-7VAWINQF.js +6 -0
  8. package/out/{chunk-5AHIQFFG.js → chunk-C2AVODGZ.js} +1 -1
  9. package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
  10. package/out/chunk-JEAUAJVF.js +232 -0
  11. package/out/chunk-O6M574JI.js +1 -0
  12. package/out/{chunk-VL43BTSO.js → chunk-PBSOL2SL.js} +1 -1
  13. package/out/{chunk-ZCS4UYHI.js → chunk-PBZPYIG7.js} +1 -1
  14. package/out/{chunk-6WF3KNTL.js → chunk-SZD5CW5R.js} +2 -2
  15. package/out/{chunk-SPEFPML6.js → chunk-TQJDYHVE.js} +1 -1
  16. package/out/{chunk-UENXT2HK.js → chunk-ULIF7A7N.js} +1 -1
  17. package/out/{chunk-FH24VHSJ.js → chunk-WYLQWYLA.js} +1 -1
  18. package/out/client-managed-import.js +1 -1
  19. package/out/client-managed.js +26 -26
  20. package/out/compare.js +13 -13
  21. package/out/dialogs.js +17 -17
  22. package/out/graph.js +1 -1
  23. package/out/index.html +7 -7
  24. package/out/scan.js +1 -1
  25. package/out/terminal.js +214 -214
  26. package/out/view.css +1 -1
  27. package/out/view.js +15 -15
  28. package/package.json +8 -5
  29. package/risk/npm/score.js +1 -1
  30. package/server-managed/agent-merges.ts +38 -22
  31. package/server-managed/app-agents.ts +6 -8
  32. package/server-managed/cache-storage.ts +3 -2
  33. package/server-managed/commit-coauthors.ts +7 -2
  34. package/server-managed/commit-lockfiles.ts +22 -21
  35. package/server-managed/db-methods.ts +52 -38
  36. package/server-managed/db-neon.ts +21 -5
  37. package/server-managed/db.ts +2 -0
  38. package/server-managed/dependabot-access.ts +1 -1
  39. package/server-managed/dependency-review-comments.ts +7 -4
  40. package/server-managed/dependency-reviews.ts +71 -33
  41. package/server-managed/github-accounts.ts +28 -1
  42. package/server-managed/github-app.ts +27 -23
  43. package/server-managed/github-graphql.ts +39 -0
  44. package/server-managed/github-issue-links.ts +8 -1
  45. package/server-managed/github-metadata.ts +2 -2
  46. package/server-managed/http-response.ts +28 -10
  47. package/server-managed/http.ts +751 -273
  48. package/server-managed/index.ts +2 -2
  49. package/server-managed/managed-issues.ts +3 -0
  50. package/server-managed/npm-delta.ts +16 -0
  51. package/server-managed/npm-kept.ts +32 -5
  52. package/server-managed/npm-loads.ts +21 -13
  53. package/server-managed/npm-manifest.ts +1 -1
  54. package/server-managed/npm-packages.ts +13 -4
  55. package/server-managed/object-storage.ts +4 -3
  56. package/server-managed/public-workspace.ts +3 -3
  57. package/server-managed/pull-comment-reviews.ts +1 -1
  58. package/server-managed/pull-dependencies.ts +84 -36
  59. package/server-managed/pull-dependency-reviews.ts +28 -18
  60. package/server-managed/rename-diff-worker.js +1 -1
  61. package/server-managed/repository-agents.ts +9 -6
  62. package/server-managed/repository-browser.ts +13 -3
  63. package/server-managed/repository-code.ts +153 -7
  64. package/server-managed/repository-dependency-reviews.ts +12 -4
  65. package/server-managed/repository-history.ts +85 -152
  66. package/server-managed/repository-issues.ts +27 -13
  67. package/server-managed/repository-pulse.ts +31 -11
  68. package/server-managed/repository-squashes.ts +145 -0
  69. package/server-managed/request-timing.ts +63 -0
  70. package/server-managed/reviewed-dependencies.ts +3 -2
  71. package/server-managed/session.ts +34 -12
  72. package/server-managed/sql.ts +16 -8
  73. package/server-managed/storage-encryption.ts +16 -8
  74. package/server-managed/storage-maintenance.ts +5 -2
  75. package/server-managed/storage-payload.ts +18 -0
  76. package/server-managed/storage-stores.ts +3 -2
  77. package/server-managed/team-feed.ts +16 -11
  78. package/server-managed/team-npm-scopes.ts +29 -0
  79. package/server-managed/team-reports.ts +68 -16
  80. package/out/chunk-6ANSHRFQ.js +0 -6
  81. package/out/chunk-S6U2J62U.js +0 -232
  82. package/out/chunk-ZGNMDGSB.js +0 -1
@@ -7,11 +7,15 @@ export interface ReviewContext {
7
7
  ecosystem: 'npm'; package: string; version: string; integrity: string
8
8
  basePackage: string | null; baseVersion: string | null; baseIntegrity: string | null
9
9
  }
10
+ // The shasums (sha1s, in hex) of the tarballs a review reads, its version's
11
+ // and its base's, where npm tells them: each held, with its integrity, to the
12
+ // tarball its reviewer was shown, as a review is written (http.ts).
13
+ export interface ReviewedTarballs { shasum: string | null; baseShasum: string | null }
10
14
  export interface ReviewInput { file: string; comment: string; status: ReviewVerdict | null }
11
15
  export interface ReviewEntry extends ReviewInput { id: string; time: number }
12
16
  export interface ReviewTeam { id: string; name: string }
13
- export interface DependencyReview extends ReviewContext, ReviewSummary {
14
- id: string; user: string | null; userLogin: string; time: number; revision: number
17
+ export interface DependencyReview extends ReviewContext, ReviewedTarballs, ReviewSummary {
18
+ id: string; user: string | null; userLogin: string; userName: string | null; time: number; revision: number
15
19
  publishedAt: number | null; revokedAt: number | null; entries: ReviewEntry[]; teams: ReviewTeam[]; files: string[] | null
16
20
  nonTextFiles: string[] | null
17
21
  status: ReviewVerdict | null; comment: string
@@ -30,14 +34,15 @@ export interface DependencyReviewStore {
30
34
  // The reviews published to team `teamId`, revoked ones too, the latest
31
35
  // published first, a page on from `before` (when it was published, and
32
36
  // its id), for one who reads them there (reviewsIn); null for another.
37
+ // In one statement.
33
38
  listTeamDependencyReviews(teamId: string, user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null } | null>
34
- // Whether `user` reads the reviews published to team `teamId` (reviewsIn).
35
- readsTeamDependencyReviews(teamId: string, user: string): Promise<boolean>
36
39
  listDependencyReviewTeams(user: string): Promise<ReviewTeam[]>
37
40
  listDependencyReviewEnabledTeams(): Promise<string[]>
38
41
  setTeamDependencyReview(sessionId: string, teamId: string, enabled: boolean): Promise<boolean>
39
42
  discardDependencyReview(sessionId: string, reviewId: string, revision: number): Promise<void>
40
- changeDependencyReview(context: ReviewContext, sessionId: string, change: ReviewChange, now: number): Promise<DependencyReview>
43
+ // Written with the shasums of the tarballs read (`tarballs`), which a
44
+ // draft keeps as it is begun, saved and published; none where not told.
45
+ changeDependencyReview(context: ReviewContext, sessionId: string, change: ReviewChange, now: number, tarballs?: ReviewedTarballs): Promise<DependencyReview>
41
46
  }
42
47
 
43
48
  // An unpublished review belongs only to its author. Publication freezes all
@@ -51,7 +56,8 @@ CREATE TABLE IF NOT EXISTS managed_dependency_review (
51
56
  package TEXT NOT NULL, version TEXT NOT NULL, integrity TEXT NOT NULL,
52
57
  base_package TEXT NOT NULL, base_version TEXT NOT NULL, base_integrity TEXT NOT NULL,
53
58
  time INTEGER NOT NULL, revision INTEGER NOT NULL, published_at INTEGER, revoked_at INTEGER, files TEXT,
54
- status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), comment TEXT NOT NULL DEFAULT ''
59
+ status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), comment TEXT NOT NULL DEFAULT '',
60
+ shasum TEXT NOT NULL DEFAULT '', base_shasum TEXT NOT NULL DEFAULT ''
55
61
  ) STRICT;
56
62
  CREATE INDEX IF NOT EXISTS managed_dependency_review_context_idx
57
63
  ON managed_dependency_review(ecosystem, package, version, integrity, base_package, base_version, base_integrity, time);
@@ -72,13 +78,19 @@ CREATE TABLE IF NOT EXISTS managed_dependency_review_team (
72
78
  ) STRICT;
73
79
  CREATE INDEX IF NOT EXISTS managed_dependency_review_team_idx ON managed_dependency_review_team(team_id, review_id);
74
80
  `
81
+ // The columns added since the table was, for a database that has it already.
82
+ export const DEPENDENCY_REVIEW_COLUMNS = [['shasum', "TEXT NOT NULL DEFAULT ''"], ['base_shasum', "TEXT NOT NULL DEFAULT ''"]] as const
75
83
  const values = (c: ReviewContext) => [c.ecosystem, c.package, c.version, c.integrity, c.basePackage ?? '', c.baseVersion ?? '', c.baseIntegrity ?? '']
76
84
  const targetWhere = 'ecosystem = ? AND package = ? AND version = ? AND integrity = ?'
77
85
  const contextWhere = `${targetWhere} AND base_package = ? AND base_version = ? AND base_integrity = ?`
78
- const columns = `id, user_id AS user, user_login AS userLogin, ecosystem, package, version, integrity,
86
+ // Its author by the login they reviewed as, and by the name the app's user
87
+ // has now (null once they are none, or have none), as the lists show them.
88
+ const columns = `id, user_id AS user, user_login AS userLogin, (SELECT n.name FROM managed_user n WHERE n.id = user_id) AS userName, ecosystem, package, version, integrity,
79
89
  NULLIF(base_package, '') AS basePackage, NULLIF(base_version, '') AS baseVersion, NULLIF(base_integrity, '') AS baseIntegrity,
90
+ NULLIF(shasum, '') AS shasum, NULLIF(base_shasum, '') AS baseShasum,
80
91
  time, revision, published_at AS publishedAt, revoked_at AS revokedAt, files, status, comment`
81
92
  const conflict = () => { throw new ManagedMutationError(409, 'review-conflict') }
93
+ const shasums = (tarballs: ReviewedTarballs | undefined) => [tarballs?.shasum ?? '', tarballs?.baseShasum ?? '']
82
94
 
83
95
  // Who reviews dependencies in team `t`, user `u` as the query joins them: an
84
96
  // admin, in any team; a member who manages, in theirs; a member who triages,
@@ -124,8 +136,9 @@ async function requireReviewer(db: ManagedSql, user: string) {
124
136
  if ((await teams(db, user)).length === 0) throw new ManagedMutationError(403, 'review-access-required')
125
137
  }
126
138
 
139
+ type ReviewRow = Omit<DependencyReview, 'files' | 'entries' | 'teams' | keyof ReviewSummary> & { files: string | null }
127
140
  async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Promise<DependencyReview[]> {
128
- const reviews = rows as (Omit<DependencyReview, 'files'> & { files: string | null })[]
141
+ const reviews = rows as ReviewRow[]
129
142
  if (reviews.length === 0) return []
130
143
  const ids = reviews.map(review => review.id), placeholders = ids.map(() => '?').join(',')
131
144
  const entries = await db.prepare(`SELECT review_id AS reviewId, id, file, comment, status, time
@@ -145,16 +158,24 @@ async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Pro
145
158
  return of
146
159
  }
147
160
  const [entriesOf, teamsOf] = [byReview(entries), byReview(linked)]
148
- return reviews.map(review => {
149
- const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
150
- const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
151
- const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
152
- const ownEntries = fileEntries((entriesOf.get(review.id) ?? []) as ReviewEntry[])
153
- return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: (teamsOf.get(review.id) ?? []) as ReviewTeam[] }
154
- })
161
+ return reviews.map(review => expanded(review, (entriesOf.get(review.id) ?? []) as ReviewEntry[], (teamsOf.get(review.id) ?? []) as ReviewTeam[]))
155
162
  }
163
+ // A review as its row, its entries and the teams its reader reads it in
164
+ // tell it: its files' snapshot, its entries a file's each (fileEntries), and
165
+ // what they sum up to.
166
+ function expanded(review: ReviewRow, entries: ReviewEntry[], readIn: ReviewTeam[]): DependencyReview {
167
+ const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
168
+ const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
169
+ const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
170
+ const ownEntries = fileEntries(entries)
171
+ return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: readIn }
172
+ }
173
+ // Rows gathered into one JSON array value (json_group_array): its text on
174
+ // SQLite, parsed already on Postgres, where none gather as NULL.
175
+ export const gathered = <T>(value: unknown): T[] => value == null ? [] : (typeof value === 'string' ? JSON.parse(value) : value) as T[]
156
176
 
157
- async function publish(db: ManagedSql, current: DependencyReview | undefined, user: string, change: Extract<ReviewChange, { action: 'publish' }>, now: number) {
177
+ async function publish(db: ManagedSql, current: DependencyReview | undefined, user: string, change: Extract<ReviewChange, { action: 'publish' }>, now: number,
178
+ tarballs: ReviewedTarballs | undefined) {
158
179
  if (!current || current.revokedAt !== null) return conflict()
159
180
  const allowed = new Set((await teams(db, user)).map(team => team.id))
160
181
  const selected = [...new Set(change.teamIds)]
@@ -164,8 +185,9 @@ async function publish(db: ManagedSql, current: DependencyReview | undefined, us
164
185
  if (!summarizeReview(entries, change.files, change.nonTextFiles).checkedFiles) throw new ManagedMutationError(400, 'no-reviewed-files')
165
186
  if (!change.final || !allowedReviewVerdicts(entries.filter(entry => !change.nonTextFiles.includes(entry.file))).includes(change.final.status)) throw new ManagedMutationError(400, 'bad-final-verdict')
166
187
  if (needsPackageJsonReview(entries, change.files, change.nonTextFiles)) throw new ManagedMutationError(400, 'package-json-not-reviewed')
167
- await db.prepare(`UPDATE managed_dependency_review SET published_at = ?, files = ?, status = ?, comment = ?, revision = revision + 1 WHERE id = ?`)
168
- .run(now, JSON.stringify({ paths: [...new Set(change.files)].toSorted(), nonTextFiles: [...new Set(change.nonTextFiles)].toSorted() }), change.final.status, change.final.comment, current.id)
188
+ await db.prepare(`UPDATE managed_dependency_review SET published_at = ?, files = ?, status = ?, comment = ?, shasum = ?, base_shasum = ?, revision = revision + 1
189
+ WHERE id = ?`).run(now, JSON.stringify({ paths: [...new Set(change.files)].toSorted(), nonTextFiles: [...new Set(change.nonTextFiles)].toSorted() }),
190
+ change.final.status, change.final.comment, ...shasums(tarballs), current.id)
169
191
  } else {
170
192
  if (change.final) return conflict()
171
193
  await db.prepare('UPDATE managed_dependency_review SET revision = revision + 1 WHERE id = ?').run(current.id)
@@ -193,17 +215,34 @@ async function listContext(db: ManagedSql, context: ReviewContext, user: string,
193
215
  return expandReviews(db, rows, user)
194
216
  }
195
217
 
196
- const readsTeam = async (db: ManagedSql, teamId: string, user: string) =>
197
- Boolean(await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ? WHERE t.id = ? AND ${reviewsIn(false)}`).get(user, teamId))
198
-
218
+ // A team's page of reviews in one statement: a row of its reader `v` where
219
+ // they read them there (reviewsIn), none for another, with each review on
220
+ // a row of its own, its entries and the teams `v` reads it in (as
221
+ // expandReviews reads them) each one JSON array.
199
222
  async function listTeam(db: ManagedSql, teamId: string, user: string, before: { time: number; id: string } | null) {
200
- if (!await readsTeam(db, teamId, user)) return null
201
- const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review r JOIN managed_dependency_review_team rt ON rt.review_id = r.id
202
- WHERE rt.team_id = ? AND r.published_at IS NOT NULL ${before ? 'AND (r.published_at < ? OR (r.published_at = ? AND r.id < ?))' : ''}
203
- ORDER BY r.published_at DESC, r.id DESC LIMIT 51`).all(teamId, ...before ? [before.time, before.time, before.id] : []) as DependencyReview[]
204
- const page = rows.slice(0, 50)
223
+ const rows = await db.prepare(`WITH reader AS (
224
+ SELECT u.id AS id FROM managed_team t JOIN managed_user u ON u.id = ? WHERE t.id = ? AND ${reviewsIn(false)}
225
+ ), page AS (
226
+ SELECT ${columns} FROM managed_dependency_review r JOIN managed_dependency_review_team rt ON rt.review_id = r.id
227
+ WHERE rt.team_id = ? AND r.published_at IS NOT NULL AND EXISTS (SELECT 1 FROM reader)
228
+ ${before ? 'AND (r.published_at < ? OR (r.published_at = ? AND r.id < ?))' : ''}
229
+ ORDER BY r.published_at DESC, r.id DESC LIMIT 51
230
+ )
231
+ SELECT v.id AS reader, p.*,
232
+ (SELECT json_group_array(json_object('id', e.id, 'file', e.file, 'comment', e.comment, 'status', e.status, 'time', e.time) ORDER BY e.time, e.id)
233
+ FROM managed_dependency_file_review e WHERE e.review_id = p.id) AS entries,
234
+ (SELECT json_group_array(json_object('id', t.id, 'name', t.name) ORDER BY t.name, t.id)
235
+ FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = v.id
236
+ WHERE rt.review_id = p.id AND (p.user = u.id OR (${reviewsIn(false)}))) AS teams
237
+ FROM reader v LEFT JOIN page p ON 1 = 1
238
+ ORDER BY p.publishedAt DESC, p.id DESC`).all(user, teamId, teamId, ...before ? [before.time, before.time, before.id] : []) as
239
+ (ReviewRow & { reader: string; entries: unknown; teams: unknown })[]
240
+ if (rows.length === 0) return null
241
+ const reviews = rows.filter(row => row.id != null)
242
+ const page = reviews.slice(0, 50)
205
243
  const last = page.at(-1)
206
- return { reviews: await expandReviews(db, page, user), next: rows.length > 50 && last ? { time: Number(last.publishedAt), id: last.id } : null }
244
+ return { reviews: page.map(({ reader: _reader, entries, teams: readIn, ...review }) => expanded(review, gathered(entries), gathered(readIn))),
245
+ next: reviews.length > 50 && last ? { time: Number(last.publishedAt), id: last.id } : null }
207
246
  }
208
247
 
209
248
  async function saveFile(db: ManagedSql, reviewId: string, entry: ReviewInput, requestedId: string | null, now: number) {
@@ -236,7 +275,6 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
236
275
  ORDER BY time, id`).all(user, ...ids), user)
237
276
  },
238
277
  listTeamDependencyReviews: (teamId, user, before) => listTeam(db, teamId, user, before),
239
- readsTeamDependencyReviews: (teamId, user) => readsTeam(db, teamId, user),
240
278
  async getLatestOwnDependencyReview(user, target) {
241
279
  const row = await db.prepare(`SELECT ${columns} FROM managed_dependency_review
242
280
  WHERE user_id = ? AND ecosystem = ? AND package = ? AND version = ? AND integrity = ? ORDER BY time DESC, id DESC LIMIT 1`)
@@ -258,7 +296,7 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
258
296
  },
259
297
  listDependencyReviewTeams: user => teams(db, user),
260
298
  listDependencyReviews: (context, user, allBases = false) => listContext(db, context, user, allBases),
261
- async changeDependencyReview(context, sessionId, change, now) {
299
+ async changeDependencyReview(context, sessionId, change, now, tarballs) {
262
300
  const user = await actor(db, sessionId)
263
301
  if (change.action !== 'revoke' && change.action !== 'publish') await requireReviewer(db, user.id)
264
302
  let id = change.reviewId
@@ -274,8 +312,8 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
274
312
  if (await unpublished()) return conflict()
275
313
  const next = randomUUID()
276
314
  await db.prepare(`INSERT INTO managed_dependency_review
277
- (id, user_id, user_login, ecosystem, package, version, integrity, base_package, base_version, base_integrity, time, revision)
278
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(next, user.id, user.login, ...values(context), now)
315
+ (id, user_id, user_login, ecosystem, package, version, integrity, base_package, base_version, base_integrity, shasum, base_shasum, time, revision)
316
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(next, user.id, user.login, ...values(context), ...shasums(tarballs), now)
279
317
  return next
280
318
  }
281
319
  if (change.action === 'start') {
@@ -294,11 +332,11 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
294
332
  if (!current || current.publishedAt === null || current.revokedAt !== null) return conflict()
295
333
  await db.prepare('UPDATE managed_dependency_review SET revoked_at = ?, revision = revision + 1 WHERE id = ?').run(now, id)
296
334
  } else if (change.action === 'publish') {
297
- await publish(db, current, user.id, change, now)
335
+ await publish(db, current, user.id, change, now, tarballs)
298
336
  } else {
299
337
  if (current?.publishedAt != null) return conflict()
300
338
  if (change.entry.status === null && !change.entryId) throw new ManagedMutationError(400, 'bad-review')
301
- if (id) await db.prepare('UPDATE managed_dependency_review SET time = ?, revision = revision + 1 WHERE id = ?').run(now, id)
339
+ if (id) await db.prepare('UPDATE managed_dependency_review SET time = ?, shasum = ?, base_shasum = ?, revision = revision + 1 WHERE id = ?').run(now, ...shasums(tarballs), id)
302
340
  else { if (change.entryId) return conflict(); id = await create() }
303
341
  await saveFile(db, id, change.entry, change.entryId, now)
304
342
  }
@@ -1,6 +1,8 @@
1
1
  // GitHub accounts as GitHub's REST and GraphQL APIs name them, where a
2
2
  // repository's activity names its authors (repository-history.ts and the
3
3
  // Commits, Issues, Agents and Pulse tabs' readers).
4
+ import { githubGraphql } from './github-graphql.ts'
5
+
4
6
  const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
5
7
 
6
8
  // An account as GitHub names it: its login, else null, and its numeric id
@@ -23,7 +25,8 @@ export const actorOf = (actor: GraphAccount) => accountOf({ login: actor?.login,
23
25
  // GitHub knows its address for; else, as GitHub's REST API does, the account
24
26
  // whose noreply address its address is, `<id>+<login>@users.noreply.github.com`:
25
27
  // a GitHub App's bot's, which is no user (GraphQL's `user` is a User's, never
26
- // a bot's).
28
+ // a bot's). One by GitHub's older no-reply address, `<login>@users.noreply.github.com`,
29
+ // naming no id, its user looked up by its login (repository-history.ts linkNoreplyUsers).
27
30
  const NOREPLY = /^([1-9]\d{0,11})\+([^@]+)@users\.noreply\.github\.com$/iu
28
31
  export type GraphActor = { email?: unknown; user?: GraphAccount | null }
29
32
  export function commitAccount(author: GraphActor | null | undefined): NamedAccount {
@@ -31,3 +34,27 @@ export function commitAccount(author: GraphActor | null | undefined): NamedAccou
31
34
  const noreply = user.id === null && typeof author?.email === 'string' ? NOREPLY.exec(author.email) : null
32
35
  return noreply ? accountOf({ login: noreply[2], id: Number(noreply[1]) }) : user
33
36
  }
37
+
38
+ // GitHub's no-reply address of old, `<login>@users.noreply.github.com`, which
39
+ // names no id: GitHub links the commits it authors to the account of its
40
+ // login, as its REST API names them, but its GraphQL API names no user for it.
41
+ const NOREPLY_LOGIN = /^([A-Za-z\d][A-Za-z\d-]{0,38})@users\.noreply\.github\.com$/iu
42
+ const MAX_NOREPLY_LOGINS = 100
43
+
44
+ // Commits' authors and co-authors (`actors`), as GitHub's GraphQL API names
45
+ // them, those it names no user for by such an address named the user of its
46
+ // login, as GitHub links them: those logins, as many as MAX_NOREPLY_LOGINS,
47
+ // looked up in one query, where any is.
48
+ export async function linkNoreplyUsers(actors: (GraphActor | null | undefined)[], token: string, fetchImpl: typeof fetch): Promise<void> {
49
+ const unnamed = actors.flatMap(actor => {
50
+ const login = actor && !actor.user && typeof actor.email === 'string' ? NOREPLY_LOGIN.exec(actor.email)?.[1]?.toLowerCase() : undefined
51
+ return login ? [{ actor: actor!, login }] : []
52
+ })
53
+ const logins = [...new Set(unnamed.map(({ login }) => login))].slice(0, MAX_NOREPLY_LOGINS)
54
+ if (logins.length === 0) return
55
+ const query = `query(${logins.map((_, at) => `$l${at}: String!`).join(', ')}) { ${logins.map((_, at) => `u${at}: user(login: $l${at}) { login databaseId }`).join(' ')} }`
56
+ const data = await githubGraphql(query, Object.fromEntries(logins.map((login, at) => [`l${at}`, login])), token, fetchImpl,
57
+ error => error?.type === 'NOT_FOUND') as Record<string, GraphAccount> | null
58
+ const users = new Map(logins.map((login, at) => [login, data?.[`u${at}`] ?? null]))
59
+ for (const { actor, login } of unnamed) actor.user = users.get(login) ?? null
60
+ }
@@ -8,6 +8,7 @@ import { createClient } from '@preventive/upstream/github.js'
8
8
  import type { ManagedConfig } from './config.ts'
9
9
  import { GithubApiError, githubUnreachable, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
10
10
  import { onTokenRejected, shareGithubRead, tokenRejected } from './github-requests.ts'
11
+ import { timed } from './request-timing.ts'
11
12
 
12
13
  export { GithubApiError } from './github-errors.ts'
13
14
  export { onTokenRejected } from './github-requests.ts'
@@ -74,31 +75,34 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
74
75
  // GithubApiError (401 passes through for the caller to handle). Identical GETs
75
76
  // share an outstanding read through its body. POSTs only opt in for read-only
76
77
  // GraphQL queries (repository-history.ts), never for mutations or token mints.
77
- export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
78
+ export function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
78
79
  readOnly = method === 'GET'): Promise<unknown> {
79
80
  if (readOnly) return shareGithubRead(fetchImpl, JSON.stringify([url, token, method, body]), () => githubJson(url, token, fetchImpl, method, body, false))
80
- let res: Response
81
- const started = Date.now()
82
- try {
83
- res = await fetchImpl(url, {
84
- method,
85
- signal: AbortSignal.timeout(body === undefined ? 10_000 : 20_000),
86
- redirect: 'error',
87
- headers: {
88
- ...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
89
- ...(body === undefined ? {} : { 'content-type': 'application/json' }), 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
90
- },
91
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
92
- })
93
- } catch (error) { throw githubUnreachable(error, started) }
94
- if (res.status === 401 && token) tokenRejected(token, fetchImpl)
95
- if (!res.ok) await throwGithubResponseError(res)
96
- // The answer's body is read within the same timeout: one not read whole is
97
- // GitHub not reached, as one not answered is; only one that isn't JSON is
98
- // malformed.
99
- try { return await res.json() } catch (error) {
100
- throw error instanceof SyntaxError ? new GithubApiError(502, 'github-malformed') : githubUnreachable(error, started)
101
- }
81
+ // Each call told in its request's Server-Timing (request-timing.ts).
82
+ return timed('gh', async () => {
83
+ let res: Response
84
+ const started = Date.now()
85
+ try {
86
+ res = await fetchImpl(url, {
87
+ method,
88
+ signal: AbortSignal.timeout(body === undefined ? 10_000 : 20_000),
89
+ redirect: 'error',
90
+ headers: {
91
+ ...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
92
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }), 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
93
+ },
94
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
95
+ })
96
+ } catch (error) { throw githubUnreachable(error, started) }
97
+ if (res.status === 401 && token) tokenRejected(token, fetchImpl)
98
+ if (!res.ok) await throwGithubResponseError(res)
99
+ // The answer's body is read within the same timeout: one not read whole is
100
+ // GitHub not reached, as one not answered is; only one that isn't JSON is
101
+ // malformed.
102
+ try { return await res.json() } catch (error) {
103
+ throw error instanceof SyntaxError ? new GithubApiError(502, 'github-malformed') : githubUnreachable(error, started)
104
+ }
105
+ })
102
106
  }
103
107
 
104
108
  // Validate one repo object into a ConnectedRepo (or null to skip), tagging it
@@ -0,0 +1,39 @@
1
+ // GitHub's GraphQL API, a query at a time, as the repository readers ask it
2
+ // (repository-history.ts, repository-agents.ts, repository-pulse.ts and the rest).
3
+ import { GithubApiError, githubJson } from './github-app.ts'
4
+
5
+ export type GraphqlError = { type?: unknown; path?: unknown; message?: unknown }
6
+
7
+ const NAMED_ERRORS = new Set(['RATE_LIMITED', 'NOT_FOUND', 'FORBIDDEN'])
8
+ // GitHub's own words for a query it stopped past its time (ten seconds):
9
+ // "Something went wrong while executing your query. This may be the result
10
+ // of a timeout, …", with no type.
11
+ const TIMED_OUT = /\btimeout\b/iu
12
+
13
+ // One GraphQL query with `token`, answered with its `data`: one GitHub answers
14
+ // with errors, but for those the caller reads past (`tolerated`, the fields
15
+ // they name then null), is rate limited, names what isn't there, is refused,
16
+ // as one a GitHub App lacks the permission for is, or a token the scope for
17
+ // (`INSUFFICIENT_SCOPES`, a sign-in's having none), took GitHub too long, or
18
+ // else is malformed, as one GitHub rejects unread is (a field or type its API
19
+ // doesn't know). GitHub's words for errors of none of those first three types
20
+ // are logged, those read past too.
21
+ export async function githubGraphql(query: string, variables: Record<string, unknown>, token: string, fetchImpl: typeof fetch,
22
+ tolerated: (error: GraphqlError) => boolean = () => false) {
23
+ const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }, true) as { data?: unknown; errors?: GraphqlError[] } | null
24
+ const all = Array.isArray(body?.errors) ? body.errors : []
25
+ const unnamed = all.filter(error => !NAMED_ERRORS.has(String(error?.type)))
26
+ if (unnamed.length > 0) {
27
+ console.warn('managed: GitHub answered a query with errors:', JSON.stringify(unnamed.slice(0, 3)
28
+ .map(error => ({ type: error?.type, message: String(error?.message).slice(0, 300), path: error?.path }))))
29
+ }
30
+ const errors = all.filter(error => !tolerated(error))
31
+ if (errors.length > 0) {
32
+ if (errors.some(error => error?.type === 'RATE_LIMITED')) throw new GithubApiError(429, 'github-rate-limited', 60)
33
+ if (errors.some(error => error?.type === 'NOT_FOUND')) throw new GithubApiError(404, 'github-not-found')
34
+ if (errors.some(error => error?.type === 'FORBIDDEN' || error?.type === 'INSUFFICIENT_SCOPES')) throw new GithubApiError(502, 'github-status-403')
35
+ if (errors.some(error => typeof error?.message === 'string' && TIMED_OUT.test(error.message))) throw new GithubApiError(502, 'github-timeout')
36
+ throw new GithubApiError(502, 'github-malformed')
37
+ }
38
+ return body?.data
39
+ }
@@ -71,8 +71,15 @@ function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string,
71
71
  if (latest && (!repo || repo.repoId !== latest.repository.databaseId)) continue
72
72
  const next = latest ? `https://github.com/${repo!.github}/pull/${latest.number}` : null
73
73
  if (latest && next) metadata.set(next, { url: next, ...itemMetadata(latest, 'pull')! })
74
+ // Only a changed derived Fix is written: an unchanged one's check time
75
+ // alone would bump the annotations revision that every open team feed
76
+ // polls, for nothing they show. Changes still stamp it, so an older lookup
77
+ // never replaces a newer change (managed-issues.ts). An older lookup that
78
+ // differs can now land after a newer unchanged one, as it could already
79
+ // land before it (whose compare-and-set then fails); either way the next
80
+ // lookup finds the stored Fix differs, and writes the current one.
74
81
  for (const issue of byIssue.get(`${job.repo.repoId}:${job.number}`) ?? []) {
75
- updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
82
+ if (issue.autoFixUrl !== next) updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
76
83
  }
77
84
  }
78
85
  return updates
@@ -77,8 +77,8 @@ CREATE TABLE IF NOT EXISTS managed_github_repository_visibility (
77
77
  `
78
78
  // Bundles show their commit's details and tags from these tables. Readers rely
79
79
  // on bundle access alone; only a GitHub read made with access fills them.
80
- // Commits are retained without eviction. Create a bundle refreshes a
81
- // repository's tags, which go with the repository.
80
+ // Commits are retained without eviction. Every refs read of a repository
81
+ // (Create a bundle's, the code viewer's) refreshes its tags, which go with it.
82
82
  export const GITHUB_COMMIT_SCHEMA = `
83
83
  CREATE TABLE IF NOT EXISTS managed_github_commit (
84
84
  cache_key TEXT PRIMARY KEY,
@@ -38,21 +38,39 @@ export function sendJson(res: ServerResponse, status: number, body: unknown, hea
38
38
  // before a line, has the response cut short, as is one whose lines fail: a
39
39
  // response that ends is one with every line.
40
40
  export async function sendLines(res: ServerResponse, lines: AsyncIterable<unknown>, allowed: () => Promise<boolean>) {
41
- res.writeHead(200, { 'content-type': 'application/x-ndjson', 'content-encoding': 'br', 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' })
42
- const encoder = createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } })
43
- const sent = pipeline(encoder, res).catch(() => {})
44
- const closed = new Promise(resolve => { res.once('close', resolve) })
41
+ const out = openLines(res)
45
42
  try {
46
43
  for await (const line of lines) {
47
- if (res.destroyed || !await allowed()) { res.destroy(); return }
48
- encoder.write(`${JSON.stringify(line)}\n`)
49
- await Promise.race([new Promise<void>(resolve => { encoder.flush(() => { resolve() }) }), closed])
44
+ if (res.destroyed || !await allowed()) { out.cut(); return }
45
+ await out.write(line)
50
46
  }
51
47
  } catch (err) {
52
48
  console.warn('managed: a streamed response failed:', err)
53
- res.destroy()
49
+ out.cut()
54
50
  return
55
51
  }
56
- encoder.end()
57
- await sent
52
+ await out.end()
53
+ }
54
+
55
+ // A response of lines of JSON as sendLines sends them, written as its caller
56
+ // has each: `write` sends one, flushed, false where the reader is gone; `end`
57
+ // ends it with every line; `cut` ends it short.
58
+ export function openLines(res: ServerResponse) {
59
+ res.writeHead(200, { 'content-type': 'application/x-ndjson', 'content-encoding': 'br', 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' })
60
+ const encoder = createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } })
61
+ const sent = pipeline(encoder, res).catch(() => {})
62
+ const closed = new Promise(resolve => { res.once('close', resolve) })
63
+ return {
64
+ async write(line: unknown): Promise<boolean> {
65
+ if (res.destroyed) return false
66
+ encoder.write(`${JSON.stringify(line)}\n`)
67
+ await Promise.race([new Promise<void>(resolve => { encoder.flush(() => { resolve() }) }), closed])
68
+ return !res.destroyed
69
+ },
70
+ async end() {
71
+ encoder.end()
72
+ await sent
73
+ },
74
+ cut() { res.destroy() },
75
+ }
58
76
  }