@preventive/triage 1.0.0-alpha.50 → 1.0.0-alpha.52

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 (61) hide show
  1. package/common/managed/finding-detail.d.ts +2 -1
  2. package/common/managed/finding-detail.js +42 -11
  3. package/common/managed/finding-values.d.ts +2 -0
  4. package/common/managed/finding-values.js +44 -0
  5. package/common/managed/lite-name.d.ts +2 -0
  6. package/common/managed/lite-name.js +20 -0
  7. package/common/managed/npm-packages.d.ts +2 -0
  8. package/common/managed/npm-packages.js +18 -1
  9. package/common/managed/routes.js +19 -7
  10. package/common/managed/team-app.d.ts +1 -0
  11. package/common/managed/team-app.js +17 -1
  12. package/out/activity.js +4 -4
  13. package/out/{chunk-75YLHBCM.js → chunk-2K6UN3PC.js} +1 -1
  14. package/out/{chunk-HTBYHSPG.js → chunk-CHRBUGVN.js} +1 -1
  15. package/out/{chunk-4XMP7YVH.js → chunk-FMXWXDR4.js} +1 -1
  16. package/out/chunk-HT2SSA3V.js +1 -0
  17. package/out/{chunk-2WDZPXVL.js → chunk-IMM274IW.js} +1 -1
  18. package/out/{chunk-FKBXNUW4.js → chunk-KOGNQDSJ.js} +1 -1
  19. package/out/chunk-LJ7XRXFU.js +52 -0
  20. package/out/{chunk-PBZPYIG7.js → chunk-MYGJBYYJ.js} +1 -1
  21. package/out/chunk-NCN3M5EP.js +3 -0
  22. package/out/chunk-OGAYMEER.js +232 -0
  23. package/out/client-managed-import.js +1 -1
  24. package/out/client-managed.js +26 -26
  25. package/out/compare.js +14 -14
  26. package/out/dialogs.js +31 -31
  27. package/out/graph.js +1 -1
  28. package/out/index.html +7 -7
  29. package/out/scan.js +2 -2
  30. package/out/view.css +1 -1
  31. package/out/view.js +11 -11
  32. package/package.json +8 -1
  33. package/server-managed/agent-merges.ts +1 -1
  34. package/server-managed/app-agents.ts +4 -2
  35. package/server-managed/commit-lockfiles.ts +7 -4
  36. package/server-managed/comparison.ts +13 -2
  37. package/server-managed/db-neon.ts +13 -3
  38. package/server-managed/db.ts +2 -1
  39. package/server-managed/dependency-review-comments.ts +16 -18
  40. package/server-managed/dependency-reviews.ts +45 -27
  41. package/server-managed/github-app.ts +3 -42
  42. package/server-managed/github-requests.ts +127 -0
  43. package/server-managed/http.ts +433 -129
  44. package/server-managed/listed-activity.ts +39 -0
  45. package/server-managed/locked-packages.ts +28 -5
  46. package/server-managed/npm-kept.ts +18 -4
  47. package/server-managed/npm-patch.ts +325 -0
  48. package/server-managed/pull-dependencies.ts +9 -6
  49. package/server-managed/pull-dependency-reviews.ts +19 -13
  50. package/server-managed/repository-agents.ts +162 -69
  51. package/server-managed/repository-dependency-reviews.ts +6 -5
  52. package/server-managed/repository-pulls.ts +114 -58
  53. package/server-managed/repository-pulse.ts +5 -3
  54. package/server-managed/repository-stamp.ts +41 -0
  55. package/server-managed/reviewed-dependencies.ts +4 -5
  56. package/server-managed/team-app.ts +3 -1
  57. package/server-managed/team-reports.ts +29 -10
  58. package/out/chunk-6LNQTKAV.js +0 -52
  59. package/out/chunk-6S2U2UYG.js +0 -232
  60. package/out/chunk-FC65TPUB.js +0 -1
  61. package/out/chunk-UK7RGJT6.js +0 -3
@@ -1,34 +1,43 @@
1
- // A repository's pull requests, the most recently updated first, as kept
2
- // once for both of those that read them so (http.ts keptPulls): the Pulse,
3
- // telling whose its year's commits are by those merged (agent-merges.ts
4
- // agentMerges), and the Agents tab, its pages of them (repository-agents.ts
5
- // agentPulls). Those updated since its year's start, a few queries an
6
- // answer, the rest read by the next, and those updated since read as they
7
- // are. Each kept as the Agents tab shows it (repository-agents.ts pull: the
8
- // marks an agent left in its texts, never the texts) and, one merged, what
9
- // it brought the default branch, as the Pulse tells it: an agent that
1
+ // A repository's pull requests and its issues, the most recently updated
2
+ // first, each kept once for all of those that read them so (http.ts
3
+ // keptList): the Pulse, telling whose its year's commits are by the pull
4
+ // requests merged (agent-merges.ts agentMerges) and counting those of a
5
+ // period (repository-pulse.ts listedActivity), and the Agents tab, its pages
6
+ // of pull requests (repository-agents.ts agentPulls) and its latest issues.
7
+ // Those updated since the Pulse's year's start, a few queries an answer, the
8
+ // rest read by the next, and those updated since read as they are. An issue
9
+ // kept as both show it; a pull request as the Agents tab shows it
10
+ // (repository-agents.ts pull: the marks an agent left in its texts, never
11
+ // the texts) and, one merged, what it brought the default branch, as the
12
+ // Pulse tells it: an agent that
10
13
  // commits as whoever ran it (Codex's own) leaves its commits no mark, its
11
14
  // pull request's branch alone naming it (app-agents.ts
12
15
  // AGENT_BRANCH_PREFIXES), or a label one who ran it gave it naming the agent
13
16
  // (agent-tasks.ts labelAgents), as the Agents tab tells an agent's work; or
14
17
  // its author, an account agents work as (github-agents.ts AGENT_OPERATED),
15
18
  // or any GitHub App's bot.
16
- import { type AgentPull, labelAgents } from '../common/managed/agent-tasks.ts'
19
+ import { type AgentIssue, type AgentPull, labelAgents } from '../common/managed/agent-tasks.ts'
17
20
  import { GITHUB_AGENTS, agentOperated } from '../common/managed/github-agents.ts'
18
21
  import { AGENT_BRANCH_PREFIXES } from './app-agents.ts'
19
- import { AGENT_PULL_FIELDS, type PullNode, pull } from './repository-agents.ts'
22
+ import { AGENT_ISSUE_FIELDS, AGENT_PULL_FIELDS, type IssueNode, type PullNode, issue, pull } from './repository-agents.ts'
20
23
  import { githubGraphql } from './repository-history.ts'
21
24
 
22
25
  // What the pull requests kept are read by, its version: bumped as what a
23
26
  // page reads, or how one is told, changes, its pull requests read again
24
- // (http.ts keptPulls keys them by AGENTS_READ_VERSION and the Pulse's
25
- // PULSE_YEAR_VERSION too, as either changes how it tells an agent's).
26
- export const PULLS_READ_VERSION = 1
27
- // The pull requests read an answer, a page a query, as many queries as
28
- // PULLS_QUERIES, the rest left for the next to ask; of each merged, as many
29
- // of its latest commits as MERGED_COMMITS.
30
- const PULLS_PAGE = 100
31
- const PULLS_QUERIES = 5
27
+ // (http.ts keptList keys them by AGENTS_READ_VERSION and the Pulse's
28
+ // PULSE_YEAR_VERSION too, as either changes how it tells an agent's); and
29
+ // the issues kept.
30
+ export const PULLS_READ_VERSION = 2
31
+ export const ISSUES_LIST_READ_VERSION = 1
32
+ // The pull requests or issues read an answer, a page a query, as many
33
+ // queries as LIST_QUERIES, the rest left for the next to ask; of each pull
34
+ // request merged, as many of its latest commits as MERGED_COMMITS. Those
35
+ // updated since the newest read, few as they mostly are, read TOP_UP_PAGE
36
+ // first, the rest LIST_PAGE a page (GitHub reading on from a page's end
37
+ // whatever its size).
38
+ const LIST_PAGE = 100
39
+ const TOP_UP_PAGE = 10
40
+ const LIST_QUERIES = 5
32
41
  const MERGED_COMMITS = 100
33
42
  const LABEL_AGENTS = Object.keys(GITHUB_AGENTS)
34
43
  const SHA = /^[a-f\d]{40}$/u
@@ -39,9 +48,10 @@ const timeOf = (value: unknown) => {
39
48
  return Number.isSafeInteger(at) ? at : null
40
49
  }
41
50
 
42
- // A pull request as read: its row on the Agents tab, and one merged, what
43
- // it brought the default branch (`merged`).
44
- export type KeptPull = AgentPull & { merged?: Merged }
51
+ // A pull request as read: its row on the Agents tab, whether a GitHub App's
52
+ // bot opened it (`bot`, GitHub's GraphQL API naming one by its app's login
53
+ // alone), and one merged, what it brought the default branch (`merged`).
54
+ export type KeptPull = AgentPull & { bot?: true; merged?: Merged }
45
55
  // What a merged pull request brought: its merge commit (`sha`), when it was
46
56
  // merged (`at`) and whether it was an agent's (agentPull); an agent's merged
47
57
  // with a merge commit, which a year counts none of, its own commits too
@@ -51,25 +61,33 @@ export type KeptPull = AgentPull & { merged?: Merged }
51
61
  // its empty commits left out, as a rebase leaves them, and how many more it
52
62
  // had than those read (`more`).
53
63
  export interface Merged { sha: string; at: number; agent: boolean; own?: string[]; subjects?: string[]; more?: number }
54
- // The pull requests updated since a year's start (`since`) as read so far
55
- // (readPulls), the most recently updated first; when the newest read was
56
- // last updated, those updated since read again (`through`, null till all
57
- // are read once), and where those read reach back no further than was
58
- // needed, when the oldest of them was updated (`reach`); whether every one
59
- // the repository has is read (`all`); and a read not finished: where it goes
60
- // on (`after`), when the newest it began with was updated (`from`), the
61
- // oldest it reads to (`stop`), how many it has read (`read`) and when the
62
- // oldest of them was updated (`to`).
63
- export interface PullList { pulls: KeptPull[]; since: number; through: number | null; reach?: number; all?: true
64
+ // An issue as read: as the Agents tab shows it, whether a bot opened it
65
+ // (`bot`, as a pull request's), when it was closed, and why (`reason`, as
66
+ // GitHub tells it: completed, not planned or a duplicate).
67
+ export type KeptIssue = AgentIssue & { bot?: true; closedAt: number | null; reason: IssueReason | null }
68
+ export type IssueReason = 'completed' | 'not-planned' | 'duplicate'
69
+ export const ISSUE_REASONS: Record<string, IssueReason> = { COMPLETED: 'completed', NOT_PLANNED: 'not-planned', DUPLICATE: 'duplicate' }
70
+ // The pull requests or issues updated since a year's start (`since`) as
71
+ // read so far (readList), the most recently updated first (`rows`); when the
72
+ // newest read was last updated, those updated since read again (`through`,
73
+ // null till all are read once), and where those read reach back no further
74
+ // than was needed, when the oldest of them was updated (`reach`); whether
75
+ // every one the repository has is read (`all`); and a read not finished:
76
+ // where it goes on (`after`), when the newest it began with was updated
77
+ // (`from`), the oldest it reads to (`stop`), how many it has read (`read`)
78
+ // and when the oldest of them was updated (`to`).
79
+ export interface Listed<Row> { rows: Row[]; since: number; through: number | null; reach?: number; all?: true
64
80
  next: { after: string; from: number; stop: number; read: number; to?: number } | null }
81
+ export type PullList = Listed<KeptPull>
82
+ export type IssueList = Listed<KeptIssue>
65
83
 
66
- // Since when every pull request updated is read: all of them since the
67
- // year's start, -∞; none yet, ∞.
68
- export const pullsReadTo = (list: Pick<PullList, 'next' | 'reach'>) => list.next ? list.next.to ?? Number.POSITIVE_INFINITY : list.reach ?? Number.NEGATIVE_INFINITY
84
+ // Since when every one updated is read: all of them since the year's
85
+ // start, -∞; none yet, ∞.
86
+ export const pullsReadTo = (list: Pick<Listed<unknown>, 'next' | 'reach'>) => list.next ? list.next.to ?? Number.POSITIVE_INFINITY : list.reach ?? Number.NEGATIVE_INFINITY
69
87
 
70
- const PULLS_QUERY = `query($owner: String!, $name: String!, $after: String) {
88
+ const PULLS_QUERY = `query($owner: String!, $name: String!, $after: String, $first: Int!) {
71
89
  repository(owner: $owner, name: $name) {
72
- pullRequests(first: ${PULLS_PAGE}, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
90
+ pullRequests(first: $first, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
73
91
  pageInfo { hasNextPage endCursor }
74
92
  nodes { ${AGENT_PULL_FIELDS} mergedAt mergeCommit { oid parents { totalCount } }
75
93
  commits(last: ${MERGED_COMMITS}) { totalCount nodes { commit { oid messageHeadline changedFilesIfAvailable } } } }
@@ -79,7 +97,17 @@ const PULLS_QUERY = `query($owner: String!, $name: String!, $after: String) {
79
97
  type MergedNode = PullNode & { mergedAt?: unknown; mergeCommit?: { oid?: unknown; message?: unknown; parents?: { totalCount?: unknown } | null } | null
80
98
  author?: PullNode['author'] & { __typename?: unknown }
81
99
  commits?: { totalCount?: unknown; nodes?: ({ commit?: { oid?: unknown; messageHeadline?: unknown; changedFilesIfAvailable?: unknown } | null } | null)[] } | null }
82
- type PullsPage = { repository?: { pullRequests?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (MergedNode | null)[] } | null } | null }
100
+ const ISSUES_QUERY = `query($owner: String!, $name: String!, $after: String, $first: Int!) {
101
+ repository(owner: $owner, name: $name) {
102
+ issues(first: $first, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
103
+ pageInfo { hasNextPage endCursor }
104
+ nodes { ${AGENT_ISSUE_FIELDS} stateReason closedAt }
105
+ }
106
+ }
107
+ }`
108
+ type ClosedNode = IssueNode & { stateReason?: unknown; closedAt?: unknown; author?: IssueNode['author'] & { __typename?: unknown } }
109
+ const botOf = (author: { __typename?: unknown } | null | undefined) => author?.__typename === 'Bot' ? { bot: true as const } : {}
110
+ type ListPage<Node> = { repository?: Record<string, { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (Node | null)[] } | null> | null }
83
111
 
84
112
  const agentBranch = (name: unknown) => typeof name === 'string' && AGENT_BRANCH_PREFIXES.some(prefix => name.startsWith(prefix))
85
113
  // Whether a pull request is an agent's: by its branch, a label naming one,
@@ -105,31 +133,60 @@ function mergedOf(node: MergedNode): Merged | null {
105
133
  return { sha, at, agent, ...own.length > 0 ? { own } : {}, ...subjects ? { subjects, ...more > 0 ? { more } : {} } : {} }
106
134
  }
107
135
 
136
+ // What an issue closed tells beside its row (KeptIssue).
137
+ function closedOf(node: ClosedNode): KeptIssue | null {
138
+ const row = issue(node)
139
+ return row ? { ...row, ...botOf(node.author), closedAt: timeOf(node.closedAt), reason: ISSUE_REASONS[String(node.stateReason)] ?? null } : null
140
+ }
141
+
108
142
  // The most recently updated first, the higher number first of those
109
143
  // updated together.
110
- const newestFirst = (a: AgentPull, b: AgentPull) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || b.number - a.number
144
+ const newestFirst = (a: { number: number; updatedAt: number | null }, b: { number: number; updatedAt: number | null }) =>
145
+ (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || b.number - a.number
146
+
147
+ // How a list is read: the query of its pages, the repository's connection
148
+ // they are of, and what each of them is kept as.
149
+ interface ListSpec<Node, Row> { query: string; connection: 'pullRequests' | 'issues'; row(node: Node): Row | null }
150
+ const PULLS: ListSpec<MergedNode, KeptPull> = { query: PULLS_QUERY, connection: 'pullRequests', row: node => {
151
+ const row = pull(node)
152
+ const merged = mergedOf(node)
153
+ return row ? { ...row, ...botOf(node.author), ...merged ? { merged } : {} } : null
154
+ } }
155
+ const ISSUES: ListSpec<ClosedNode, KeptIssue> = { query: ISSUES_QUERY, connection: 'issues', row: closedOf }
156
+
157
+ // The pull requests updated since `since` (readList), and what each merged
158
+ // brought.
159
+ export const readPulls = (fullName: string, token: string, since: number, kept: PullList | null, fetchImpl: typeof fetch = globalThis.fetch, need = since) =>
160
+ readList(PULLS, fullName, token, since, kept, fetchImpl, need)
161
+ // The issues updated since `since` (readList), and when and why each closed was.
162
+ export const readIssues = (fullName: string, token: string, since: number, kept: IssueList | null, fetchImpl: typeof fetch = globalThis.fetch, need = since) =>
163
+ readList(ISSUES, fullName, token, since, kept, fetchImpl, need)
111
164
 
112
- // The pull requests updated since `since`, read the most recently updated
113
- // first (a pull request is updated when merged, or after), on from what was
114
- // `kept`: till one updated before the year, or before the newest read before
115
- // was, as many queries an answer as PULLS_QUERIES, a read not finished going
116
- // on from where it was left by the next, or once those updated since `need`
117
- // are all read; where those read reached no further back than they needed,
118
- // and `need` is further, read again from the newest on, as far back as it
119
- // needs. Each kept as last read, one updated before the year forgotten.
120
- export async function readPulls(fullName: string, token: string, since: number, kept: PullList | null,
121
- fetchImpl: typeof fetch = globalThis.fetch, need = since): Promise<PullList> {
165
+ // Those of a list (`spec`) updated since `since`, read the most recently
166
+ // updated first (a pull request is updated when merged, or after, an issue
167
+ // when closed), on from what was `kept`: till one updated before the year,
168
+ // or before the newest read before was, as many queries an answer as
169
+ // LIST_QUERIES, a read not finished going on from where it was left by the
170
+ // next, or once those updated since `need` are all read; where those read
171
+ // reached no further back than they needed, and `need` is further, read
172
+ // again from the newest on, as far back as it needs. Each kept as last read,
173
+ // one updated before the year forgotten.
174
+ async function readList<Node extends { updatedAt?: unknown }, Row extends { number: number; updatedAt: number | null }>(spec: ListSpec<Node, Row>,
175
+ fullName: string, token: string, since: number, kept: Listed<Row> | null, fetchImpl: typeof fetch, need: number): Promise<Listed<Row>> {
122
176
  const [owner, name] = fullName.split('/') as [string, string]
123
- const pulls = new Map((kept?.pulls ?? []).filter(item => (item.updatedAt ?? 0) >= since).map(item => [item.number, item]))
177
+ const rows = new Map((kept?.rows ?? []).filter(item => (item.updatedAt ?? 0) >= since).map(item => [item.number, item]))
124
178
  // Every one read, as long as none was forgotten, the year begun since.
125
- const whole = kept?.all === true && pulls.size === kept.pulls.length
179
+ const whole = kept?.all === true && rows.size === kept.rows.length
126
180
  const further = !kept?.next && kept?.reach !== undefined && kept.reach > need
127
181
  const stop = kept?.next?.stop ?? (further ? since : Math.max(since, kept?.through ?? since))
128
- const result = (through: number | null, next: PullList['next'], reach: number | undefined, all = whole): PullList => ({
129
- pulls: [...pulls.values()].toSorted(newestFirst), since, through, ...reach === undefined ? {} : { reach }, ...all ? { all: true as const } : {}, next })
182
+ const result = (through: number | null, next: Listed<Row>['next'], reach: number | undefined, all = whole): Listed<Row> => ({
183
+ rows: [...rows.values()].toSorted(newestFirst), since, through, ...reach === undefined ? {} : { reach }, ...all ? { all: true as const } : {}, next })
184
+ // A read of those updated since the newest read alone.
185
+ const topping = !kept?.next && !further && kept?.through !== null && kept?.through !== undefined
130
186
  let { after = null, from = null, read = 0, to = null }: { after?: string | null; from?: number | null; read?: number; to?: number | null } = kept?.next ?? {}
131
- for (let queries = 0; queries < PULLS_QUERIES; queries++) {
132
- const page = (await githubGraphql(PULLS_QUERY, { owner, name, after }, token, fetchImpl) as PullsPage | null)?.repository?.pullRequests
187
+ for (let queries = 0; queries < LIST_QUERIES; queries++) {
188
+ const first = topping && queries === 0 ? TOP_UP_PAGE : LIST_PAGE
189
+ const page = (await githubGraphql(spec.query, { owner, name, after, first }, token, fetchImpl) as ListPage<Node> | null)?.repository?.[spec.connection]
133
190
  const end = page?.pageInfo?.hasNextPage !== true || typeof page.pageInfo.endCursor !== 'string'
134
191
  let crossed = false
135
192
  for (const node of page?.nodes ?? []) {
@@ -139,9 +196,8 @@ export async function readPulls(fullName: string, token: string, since: number,
139
196
  from ??= updated
140
197
  to = updated
141
198
  read++
142
- const row = pull(node)
143
- const merged = mergedOf(node)
144
- if (row) pulls.set(row.number, merged ? { ...row, merged } : row)
199
+ const row = spec.row(node)
200
+ if (row) rows.set(row.number, row)
145
201
  }
146
202
  // Read as far back as it reads to: the year's start, every one since
147
203
  // read; else as far as those read before reached. Read to the last
@@ -26,7 +26,7 @@ const WEEK_MS = 7 * 24 * 60 * 60_000
26
26
  const DAY_MS = 24 * 60 * 60_000
27
27
  const MAX_CONTRIBUTORS = 100
28
28
  const DIRECT_LISTED = 500
29
- const LISTED = 10
29
+ export const LISTED = 10
30
30
  // Merged pull requests and closed issues are searched by when each was last
31
31
  // updated, GitHub sorting by no merge or close: as many as this are read, and
32
32
  // the latest merged or closed of them listed.
@@ -211,9 +211,10 @@ export async function defaultHead(fullName: string, token: string, fetchImpl: ty
211
211
  // before it, a clock behind, the branch's commits since beneath them. Read
212
212
  // no further than `need` asks, those since it read whole (`complete`, `from`
213
213
  // the newest of those read whose parents are not, every commit after it
214
- // read; null, the year read whole).
214
+ // read; null, the year read whole). How many are read so far `told` as they
215
+ // are.
215
216
  export async function yearCommits(fullName: string, token: string, head: string, since: number, pages: HistoryPages,
216
- fetchImpl: typeof fetch = globalThis.fetch, need = since): Promise<YearCommits> {
217
+ fetchImpl: typeof fetch = globalThis.fetch, need = since, told: (read: number) => void = () => {}): Promise<YearCommits> {
217
218
  const found = new Map<string, HistoryCommit>()
218
219
  const tried = new Set<string>()
219
220
  const within = (commit: HistoryCommit) => commit.committedAt === null || commit.committedAt >= since - YEAR_SKEW_MS
@@ -236,6 +237,7 @@ export async function yearCommits(fullName: string, token: string, head: string,
236
237
  const got = await historyFrom(fullName, token, batch, pages, fetchImpl)
237
238
  if (got.read > 0) queries++
238
239
  for (const commit of got.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
240
+ told(read())
239
241
  if (read() > YEAR_COMMITS) return { complete: false, read: read(), tooMany: true }
240
242
  starts = [...new Set([...found.values()].filter(within).flatMap(commit => commit.parents))].filter(sha => !found.has(sha) && !tried.has(sha))
241
243
  }
@@ -0,0 +1,41 @@
1
+ // A repository's change stamp: what moves as anything its tabs read of it
2
+ // changes, but the checks its pull requests run, which move no pull
3
+ // request's update time, read in one cheap GraphQL query: when it was last
4
+ // pushed to, which branch is its default, how many branches and tags it
5
+ // has, and how many pull requests and issues it has and when the one
6
+ // updated last was. An answer kept of it
7
+ // is read again behind its readers only where its stamp moved since the
8
+ // answer was read (http.ts keptAnswer).
9
+ import { GithubApiError } from './github-errors.ts'
10
+ import { githubGraphql } from './github-graphql.ts'
11
+
12
+ // The rules a stamp is read by: its key's, so that a stamp read by others is
13
+ // none's (http.ts stampOf).
14
+ export const STAMP_READ = 2
15
+
16
+ const STAMP_QUERY = `query($owner: String!, $name: String!) {
17
+ repository(owner: $owner, name: $name) {
18
+ pushedAt
19
+ defaultBranchRef { name }
20
+ heads: refs(refPrefix: "refs/heads/", first: 1) { totalCount }
21
+ tags: refs(refPrefix: "refs/tags/", first: 1) { totalCount }
22
+ pullRequests(first: 1, orderBy: { field: UPDATED_AT, direction: DESC }) { totalCount nodes { updatedAt } }
23
+ issues(first: 1, orderBy: { field: UPDATED_AT, direction: DESC }) { totalCount nodes { updatedAt } }
24
+ }
25
+ }`
26
+ type Counted = { totalCount?: unknown; nodes?: ({ updatedAt?: unknown } | null)[] | null } | null
27
+ type Stamped = { repository?: { pushedAt?: unknown; defaultBranchRef?: { name?: unknown } | null; heads?: Counted; tags?: Counted; pullRequests?: Counted; issues?: Counted } | null }
28
+ | null
29
+
30
+ // Repository `fullName`'s stamp, read with `token`, as text to compare.
31
+ export async function repositoryStamp(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<{ stamp: string }> {
32
+ const [owner, name] = fullName.split('/') as [string, string]
33
+ const repository = (await githubGraphql(STAMP_QUERY, { owner, name }, token, fetchImpl) as Stamped)?.repository
34
+ if (!repository) throw new GithubApiError(404, 'github-not-found')
35
+ // One missing a count tells nothing: none, never one that would move with nothing.
36
+ const { heads, tags, pullRequests, issues } = repository
37
+ if (![heads, tags, pullRequests, issues].every(connection => Number.isSafeInteger(connection?.totalCount))) throw new GithubApiError(502, 'github-malformed')
38
+ const latest = (connection: Counted | undefined) => [connection?.totalCount, connection?.nodes?.[0]?.updatedAt ?? null]
39
+ return { stamp: JSON.stringify([repository.pushedAt ?? null, repository.defaultBranchRef?.name ?? null, heads?.totalCount, tags?.totalCount, ...latest(pullRequests),
40
+ ...latest(issues)]) }
41
+ }
@@ -9,11 +9,10 @@
9
9
  // is reviewed since (pull-dependency-reviews.ts): a version passes by the
10
10
  // reviews its teams publish, and those its pull request's comments make, as
11
11
  // many as the repository requires, or by `lookup`'s saying so, and is
12
- // otherwise unreviewed. A patched version (pnpm's patchedDependencies)
13
- // passes by `lookup`, or reviews its comments make of its patch, alone, the
14
- // app reviewing no patch yet. Those comments' reviews count for the pull
15
- // request alone, as it is open: the host's to keep, as it is merged, among
16
- // those `lookup` tells of.
12
+ // otherwise unreviewed. A patched version's (pnpm's patchedDependencies)
13
+ // reviews are of its patch, its teams' and its comments' alike. Those
14
+ // comments' reviews count for the pull request alone, as it is open: the
15
+ // host's to keep, as it is merged, among those `lookup` tells of.
17
16
  //
18
17
  // Each version is asked of `lookup` once a request, as many at once as
19
18
  // ASK_AT_ONCE, its answer kept in this instance KEPT_MS; one it fails to
@@ -17,6 +17,7 @@
17
17
  // findings, and only expanded does it show that they fail to combine.
18
18
  import { reportEntries } from '@preventive/report'
19
19
  import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
20
+ import { expandFindingValues } from '../common/managed/finding-values.js'
20
21
  import type { BlobStore } from './blob-store.ts'
21
22
  import type { ManagedDb, TeamReportAccessSnapshot, UserTeam } from './db.ts'
22
23
  import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
@@ -86,8 +87,9 @@ async function classify(db: ManagedDb, store: BlobStore, key: string, team: User
86
87
  // A catalog read before a concurrent publication names another report set.
87
88
  if (!access?.teamId || access.reports.length !== ids.size || access.reports.some(report => !ids.has(report.id))) return null
88
89
  const body = JSON.parse((await loadTeamReportsResponse(db, store, access)).toString('utf8')) as {
89
- reports: { id: string; filename: string; data: unknown }[]; links?: string[][]
90
+ reports: { id: string; filename: string; data: unknown }[]; links?: string[][]; values?: unknown[]
90
91
  }
92
+ for (const report of body.reports) expandFindingValues(report.data, body.values)
91
93
  const restricted = access.reports.some(report => !report.permissions.dependencies || !report.permissions.security)
92
94
  const empty = restricted ? body.reports.filter(report => !hasContent(report.data)).map(report => report.id) : []
93
95
  const links = body.links ?? []
@@ -8,7 +8,8 @@ import { managedFindingSourcePaths, readManagedReport } from '../common/managed/
8
8
  import { filterReportData, projectFinding } from '../common/managed/report-filter.ts'
9
9
  import { revalidationLinks, securityLinks } from '../common/security-links.js'
10
10
  import { treeDependencyDirectory } from '../client/dependency-paths.js'
11
- import { liteReportData } from '../common/managed/finding-detail.js'
11
+ import { liteWorkspace } from '../common/managed/finding-detail.js'
12
+ import { expandFindingValues, findingValueIndex } from '../common/managed/finding-values.js'
12
13
  import type { BlobStore } from './blob-store.ts'
13
14
  import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
14
15
  import { triageWireEntry } from './triage-response.ts'
@@ -158,13 +159,23 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
158
159
  for (let i = 0; i < reports.length; i++) {
159
160
  const access = snapshot.reports[i]!, report = reports[i]!
160
161
  report.data = filterReportData(report.data, access.permissions, access.repo, securityIds)
162
+ if (report.data && typeof report.data === 'object' && ('think' in report.data || 'reportRepo' in report.data)) {
163
+ const { think: _think, reportRepo: _reportRepo, ...data } = report.data as Finding
164
+ report.data = data
165
+ }
161
166
  const ids = new Set<string>()
162
167
  for (const finding of groupsOf(report.data).flat()) {
163
168
  // Preserve linked security classification for the UI lens, including a
164
169
  // linked component that a dependency permission will hide from the UI.
165
170
  finding['isSecurity'] = securityIds.has(String(finding['id']))
166
- // The hash of its file's tree, which nothing a team view shows reads.
171
+ // The hash of its file's tree, the fingerprint its ID was derived from,
172
+ // whether its run thought and the repository its report named, which
173
+ // nothing a team view reads, nor an export (@preventive/report
174
+ // write-md-finding.js) writes.
167
175
  delete finding['treeHash']
176
+ delete finding['_idBasis']
177
+ delete finding['think']
178
+ delete finding['reportRepo']
168
179
  if (typeof finding['id'] === 'string') { ids.add(finding['id']); allIds.add(finding['id']) }
169
180
  }
170
181
  visible.set(report.id, { ids, sourcePaths: managedFindingSourcePaths(groupsOf(report.data).flat()) })
@@ -177,11 +188,13 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
177
188
  }
178
189
  const trees = Buffer.from(`{"trees":{${reports.map(withoutTree).filter(tree => tree !== null).join(',')}}}`)
179
190
  const links = mergeLinkGroups(globalLinks, allIds)
191
+ // The board's: its findings without their detail, and in App mode its rows
192
+ // as the App view shows them (common/managed/finding-detail.js).
193
+ const lite = liteWorkspace(reports, links)
180
194
  const encoded = {
181
195
  // Bounded together, as when the trees were sent with their reports.
182
196
  reports: encodeReports(reports, links, trees.length),
183
- // The board's, its findings without their detail (common/managed/finding-detail.js).
184
- lite: encodeReports(reports.map(report => ({ ...report, data: liteReportData(report.data) })), links, 0),
197
+ lite: encodeReports(lite.reports, links, 0, lite.folded),
185
198
  trees,
186
199
  }
187
200
  retainVisibility(db, workspaceKey(snapshot), visible)
@@ -189,18 +202,23 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
189
202
  return { ...encoded, visible }
190
203
  }
191
204
  // `{ reports, links }` as a response sends them, `spent` bytes already
192
- // counted against the bound.
193
- function encodeReports(reports: readonly TeamReport[], links: readonly string[][], spent: number): Buffer {
205
+ // counted against the bound; `folded` where the board's copy left out rows
206
+ // App mode does not show (common/managed/finding-detail.js). Its findings'
207
+ // packages and repositories are sent once each, in `values`
208
+ // (common/managed/finding-values.js).
209
+ function encodeReports(reports: readonly TeamReport[], links: readonly string[][], spent: number, folded = false): Buffer {
194
210
  let outputBytes = spent + 14
195
211
  const parts = [Buffer.from('{"reports":[')]
212
+ const values = findingValueIndex()
196
213
  for (const [index, report] of reports.entries()) {
197
- const bytes = Buffer.from(JSON.stringify(report))
214
+ const bytes = Buffer.from(JSON.stringify({ ...report, data: values.index(report.data) }))
198
215
  outputBytes += bytes.length + 1
199
216
  if (outputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
200
217
  if (index) parts.push(Buffer.from(','))
201
218
  parts.push(bytes)
202
219
  }
203
- const suffix = Buffer.from(links.length > 0 ? `],"links":${JSON.stringify(links)}}` : ']}')
220
+ const tail = JSON.stringify({ ...links.length > 0 ? { links } : {}, ...folded ? { folded } : {}, ...values.values.length > 0 ? { values: values.values } : {} })
221
+ const suffix = Buffer.from(tail === '{}' ? ']}' : `],${tail.slice(1)}`)
204
222
  if (outputBytes + suffix.length > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
205
223
  parts.push(suffix)
206
224
  return Buffer.concat(parts)
@@ -227,8 +245,9 @@ export async function loadTeamReportsResponse(db: ManagedDb, store: BlobStore, s
227
245
  }
228
246
  export async function loadTeamReports(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<TeamReport[]> {
229
247
  // Object consumers get their own copy, including when sharing a cold load.
230
- const body = await loadTeamReportsResponse(db, store, snapshot)
231
- return (JSON.parse(body.toString('utf8')) as { reports: TeamReport[] }).reports
248
+ const body = JSON.parse((await loadTeamReportsResponse(db, store, snapshot)).toString('utf8')) as { reports: TeamReport[]; values?: unknown[] }
249
+ for (const report of body.reports) expandFindingValues(report.data, body.values)
250
+ return body.reports
232
251
  }
233
252
  async function teamVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Visibility> {
234
253
  let visible = caches.get(db)?.get(workspaceKey(snapshot))