@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.45

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 (183) hide show
  1. package/common/bundle-sources.js +20 -1
  2. package/common/default-scan-models.ts +1 -1
  3. package/common/dependency-reviews.d.ts +0 -5
  4. package/common/dependency-reviews.js +0 -17
  5. package/common/managed/agent-tasks.ts +292 -0
  6. package/common/managed/github-agents.ts +92 -0
  7. package/common/managed/github-logins.ts +49 -0
  8. package/common/managed/roles.ts +7 -0
  9. package/common/managed/routes.js +88 -7
  10. package/common/managed/team-app.d.ts +5 -2
  11. package/common/managed/team-app.js +2 -2
  12. package/common/server-info.ts +3 -0
  13. package/common/workspace-app.js +6 -3
  14. package/out/brotli-fallback.js +3 -3
  15. package/out/chunk-2XUEZHIP.js +18 -18
  16. package/out/chunk-2ZDZWLFY.js +1 -0
  17. package/out/chunk-3PS4KWD2.js +10 -0
  18. package/out/chunk-5DHL5NG3.js +2 -0
  19. package/out/chunk-6HNXNIQY.js +52 -0
  20. package/out/chunk-7C7DWL7I.js +1 -0
  21. package/out/chunk-AFLZH7WG.js +1 -0
  22. package/out/chunk-BC2UCLFJ.js +1 -0
  23. package/out/chunk-C4Z6ZBXV.js +6 -6
  24. package/out/chunk-GDPCLSOS.js +2 -0
  25. package/out/chunk-GO7FT4OD.js +1 -0
  26. package/out/chunk-HMAJDVXN.js +1 -0
  27. package/out/chunk-I2MDGSQX.js +3 -0
  28. package/out/chunk-IYPY47MC.js +1 -0
  29. package/out/chunk-IZ7POWI3.js +18 -0
  30. package/out/chunk-JCIIBLKR.js +1 -1
  31. package/out/chunk-NPW52BM2.js +17 -0
  32. package/out/chunk-NRPC74AM.js +1 -1
  33. package/out/chunk-NV7ES4EW.js +1 -0
  34. package/out/chunk-OKRLLE5I.js +17 -0
  35. package/out/chunk-OOJM4CTU.js +1 -1
  36. package/out/chunk-PNHBZGDD.js +1 -1
  37. package/out/chunk-PTGDYLNS.js +36 -0
  38. package/out/chunk-PXK7EU4V.js +1 -0
  39. package/out/chunk-Q6MVYGYV.js +3 -0
  40. package/out/chunk-QB4CBOX2.js +1 -0
  41. package/out/chunk-QRD6GIG5.js +229 -0
  42. package/out/chunk-R3NZFXVR.js +1 -0
  43. package/out/chunk-R5O6FAW4.js +1 -1
  44. package/out/chunk-SXV2T5C4.js +80 -0
  45. package/out/chunk-TUII34K2.js +1 -1
  46. package/out/chunk-VFWMQ3LG.js +2 -0
  47. package/out/chunk-VH4XMZIW.js +2 -0
  48. package/out/chunk-WFYLMF6Z.js +10 -0
  49. package/out/chunk-WI7Z5A2T.js +6 -0
  50. package/out/chunk-WQ74ZBZF.js +7 -0
  51. package/out/chunk-XVBEOGOS.js +3 -0
  52. package/out/client-managed-import.js +3 -3
  53. package/out/client-managed.js +31 -35
  54. package/out/client-sync.js +11 -11
  55. package/out/compare.js +35 -0
  56. package/out/dialogs.js +51 -0
  57. package/out/github-issue-authorized.html +1 -1
  58. package/out/github-issue-authorized.js +1 -1
  59. package/out/graph.js +17 -17
  60. package/out/index.html +30 -34
  61. package/out/prism.js +14 -14
  62. package/out/scan.js +2 -0
  63. package/out/terminal.js +294 -293
  64. package/out/view.css +1 -1
  65. package/out/view.js +16 -107
  66. package/package.json +51 -6
  67. package/risk/actions/findings.js +156 -0
  68. package/risk/actions/resolve.js +219 -0
  69. package/risk/actions/scripts/commands.js +182 -0
  70. package/risk/actions/scripts/grade.js +113 -0
  71. package/risk/actions/scripts/packages.js +123 -0
  72. package/risk/actions/scripts/shell.js +165 -0
  73. package/risk/actions/uses.js +76 -0
  74. package/risk/actions/workflow.js +110 -0
  75. package/risk/files/hosts.js +6 -6
  76. package/risk/files/readability.js +23 -17
  77. package/risk/files/runs-code.js +18 -6
  78. package/risk/files/script.js +7 -3
  79. package/risk/files/stamps.js +8 -7
  80. package/risk/index.d.ts +68 -1
  81. package/risk/index.js +6 -2
  82. package/risk/npm/assess.js +1 -1
  83. package/risk/npm/compare.js +34 -10
  84. package/risk/npm/files.js +413 -2
  85. package/risk/npm/tarball.js +56 -73
  86. package/server-managed/agent-merges.ts +198 -0
  87. package/server-managed/app-agents.ts +30 -0
  88. package/server-managed/avatar-store.ts +1 -0
  89. package/server-managed/commit-coauthors.ts +83 -0
  90. package/server-managed/commit-lockfiles.ts +62 -0
  91. package/server-managed/db-methods.ts +29 -10
  92. package/server-managed/db-neon.ts +30 -2
  93. package/server-managed/db-schema.ts +9 -2
  94. package/server-managed/db.ts +7 -0
  95. package/server-managed/dependabot-access.ts +60 -0
  96. package/server-managed/dependabot-alerts.ts +185 -0
  97. package/server-managed/dependency-review-comments.ts +97 -0
  98. package/server-managed/dependency-reviews.ts +65 -15
  99. package/server-managed/github-accounts.ts +33 -0
  100. package/server-managed/github-actions.ts +71 -0
  101. package/server-managed/github-app.ts +23 -9
  102. package/server-managed/github-errors.ts +2 -0
  103. package/server-managed/github-link-oauth.ts +69 -0
  104. package/server-managed/github-links.ts +204 -0
  105. package/server-managed/github-oauth.ts +5 -2
  106. package/server-managed/github-users.ts +30 -0
  107. package/server-managed/http.ts +1122 -52
  108. package/server-managed/index.ts +6 -2
  109. package/server-managed/locked-packages.ts +37 -16
  110. package/server-managed/lockfile-scope.ts +49 -0
  111. package/server-managed/npm-delta-worker.js +23 -0
  112. package/server-managed/npm-delta.ts +173 -0
  113. package/server-managed/npm-insights.ts +20 -11
  114. package/server-managed/npm-kept.ts +51 -4
  115. package/server-managed/npm-loads.ts +20 -13
  116. package/server-managed/npm-packages.ts +24 -64
  117. package/server-managed/pull-comment-reviews.ts +161 -0
  118. package/server-managed/pull-dependencies.ts +280 -0
  119. package/server-managed/pull-dependency-reviews.ts +318 -0
  120. package/server-managed/pull-reviews.ts +205 -0
  121. package/server-managed/release-commits.ts +84 -0
  122. package/server-managed/repository-actions.ts +209 -0
  123. package/server-managed/repository-agents.ts +220 -0
  124. package/server-managed/repository-browser.ts +54 -13
  125. package/server-managed/repository-changes.ts +284 -0
  126. package/server-managed/repository-code.ts +52 -0
  127. package/server-managed/repository-dependency-reviews.ts +135 -0
  128. package/server-managed/repository-history.ts +445 -0
  129. package/server-managed/repository-issues.ts +181 -0
  130. package/server-managed/repository-pulse.ts +432 -0
  131. package/server-managed/repository-risk-read.ts +165 -0
  132. package/server-managed/repository-risk.ts +113 -105
  133. package/server-managed/reviewed-dependencies.ts +149 -0
  134. package/server-managed/session.ts +9 -5
  135. package/server-managed/sql.ts +1 -1
  136. package/server-managed/storage-stores.ts +5 -0
  137. package/server-managed/team-app.ts +19 -3
  138. package/server-managed/workspace-shares.ts +4 -1
  139. package/server.ts +11 -0
  140. package/out/chunk-2ABLKCOY.js +0 -46
  141. package/out/chunk-2KBF5TZ7.js +0 -2
  142. package/out/chunk-35VE6CDH.js +0 -2
  143. package/out/chunk-3BZ7JERB.js +0 -1
  144. package/out/chunk-4XCDFGDP.js +0 -10
  145. package/out/chunk-5LSXOLUS.js +0 -1
  146. package/out/chunk-5RCM2J63.js +0 -1
  147. package/out/chunk-7B6HB4D7.js +0 -1
  148. package/out/chunk-AJ4GTATZ.js +0 -3
  149. package/out/chunk-CH4UN6WK.js +0 -225
  150. package/out/chunk-CMQA4DM6.js +0 -1
  151. package/out/chunk-CVVJAXVY.js +0 -2
  152. package/out/chunk-DIJJNTRH.js +0 -10
  153. package/out/chunk-EDKLBQQN.js +0 -17
  154. package/out/chunk-F47DXCEV.js +0 -33
  155. package/out/chunk-FK3DZEKL.js +0 -1
  156. package/out/chunk-G6AJWENO.js +0 -1
  157. package/out/chunk-GJ4IUCNS.js +0 -1
  158. package/out/chunk-GNXOKIGP.js +0 -80
  159. package/out/chunk-GZ4MS3TE.js +0 -13
  160. package/out/chunk-GZWM7UQM.js +0 -2
  161. package/out/chunk-IU3CCINR.js +0 -3
  162. package/out/chunk-K3X5EJBQ.js +0 -2
  163. package/out/chunk-KEX2ATEW.js +0 -2
  164. package/out/chunk-KYCNJVB7.js +0 -1
  165. package/out/chunk-LPCTJYNA.js +0 -7
  166. package/out/chunk-O4RJY5TP.js +0 -2
  167. package/out/chunk-OD4LPN3H.js +0 -1
  168. package/out/chunk-QWDWTBM7.js +0 -1
  169. package/out/chunk-QYWKC557.js +0 -1
  170. package/out/chunk-R75XOKUP.js +0 -1
  171. package/out/chunk-SWTLADW5.js +0 -1
  172. package/out/chunk-T7OKH4VW.js +0 -1
  173. package/out/chunk-TZ5KB2Y5.js +0 -10
  174. package/out/chunk-UNTRGE26.js +0 -2
  175. package/out/chunk-UTR2LVME.js +0 -2
  176. package/out/chunk-UYAXVRVL.js +0 -1
  177. package/out/chunk-V3ME6FI5.js +0 -2
  178. package/out/chunk-V5O7M2YT.js +0 -1
  179. package/out/chunk-VIHGO77Y.js +0 -1
  180. package/out/chunk-W4J3MGLB.js +0 -1
  181. package/out/chunk-WGG7KQUH.js +0 -1
  182. package/out/chunk-XYFXXB2G.js +0 -10
  183. package/out/chunk-ZESLQCTF.js +0 -6
@@ -0,0 +1,445 @@
1
+ // The commit history the code viewer's History tab draws
2
+ // (GET /api/admin/repositories/history), as `git log --oneline --decorate
3
+ // --graph` shows it: each commit's id, parents, subject, author and time, and
4
+ // the branches and tags pointing at commits. From a revision, a page at a
5
+ // time, of the whole repository or of the commits touching a directory, as
6
+ // GitHub's REST API lists them, for any reader; or, as `--all`, across the
7
+ // repository's most recently committed to branches at once, then on from where
8
+ // those stop, in GraphQL queries, which need a token. A whole repository's is
9
+ // read a page from a commit at a time, each page kept by the commit it starts
10
+ // at (historyFrom): a commit's history never changes, and where a commit read
11
+ // starts a page kept, that page glues on, and those after it.
12
+ import { type Coauthor, MAX_COAUTHORS, type Maker, coauthorsOf } from './commit-coauthors.ts'
13
+ import { GithubApiError, githubJson } from './github-app.ts'
14
+ import { type GraphActor, accountOf, commitAccount } from './github-accounts.ts'
15
+
16
+ export { ACTOR, type GraphAccount, type GraphActor, type NamedAccount, accountOf, actorOf, commitAccount } from './github-accounts.ts'
17
+
18
+ export const HISTORY_PAGE = 100
19
+ // The deepest page read, as far as Older commits goes.
20
+ export const MAX_HISTORY_PAGE = 100
21
+ // `--all`: the branches most recently committed to, of as many as fifty
22
+ // pages of them list, the latest commits of each (as many as a GraphQL connection
23
+ // gives), and the latest tags.
24
+ export const ALL_BRANCHES = 25
25
+ const MAX_HEAD_PAGES = 50
26
+ const ALL_DEPTH = 100
27
+ const ALL_TAGS = 100
28
+ const REF_TIPS = 100
29
+ const MAX_PARENTS = 8
30
+ const MAX_SUBJECT = 1000
31
+ const MAX_NAME = 256
32
+ const SHA = /^[a-f\d]{40}$/u
33
+
34
+ // A commit, its author by name and by their account's login and numeric id
35
+ // (`authorId`, absent from pages kept before it was read), its co-authors
36
+ // (coauthorsOf, absent from pages kept before they were read), with the lines
37
+ // it adds and removes and when it was authored in its author's own time zone
38
+ // (`authorZone`, minutes east of UTC), where GitHub's GraphQL API tells them
39
+ // (null where its REST API lists it).
40
+ export interface HistoryCommit { sha: string; parents: string[]; subject: string; authorName: string | null; authorLogin: string | null; authorId?: number | null
41
+ coauthors?: Coauthor[]; committedAt: number | null; additions?: number | null; deletions?: number | null; authoredAt?: number | null; authorZone?: number | null }
42
+ export interface HistoryRef { name: string; sha: string }
43
+
44
+ const textOf = (value: unknown, max: number) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null
45
+ const timeOf = (value: unknown) => {
46
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
47
+ return Number.isSafeInteger(at) ? at : null
48
+ }
49
+
50
+ const lineCount = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null
51
+ // The minutes east of UTC an ISO 8601 time is given in: `Z` none.
52
+ const zoneOf = (value: unknown) => {
53
+ const zone = typeof value === 'string' ? /(?:Z|([+-])(\d{2}):?(\d{2}))$/u.exec(value) : null
54
+ return zone ? zone[1] ? (zone[1] === '-' ? -1 : 1) * (Number(zone[2]) * 60 + Number(zone[3])) : 0 : null
55
+ }
56
+
57
+ // A commit as the Commits tab reads it, its subject the message's first
58
+ // line, as `--oneline` shows, its author by name and account, and its
59
+ // co-authors (coauthorsOf, `listed` as GitHub's GraphQL API lists them); null
60
+ // for one GitHub names no commit id for. With `stats`, the GraphQL API's, its
61
+ // lines and its author's time.
62
+ function historyCommit(sha: unknown, parents: unknown[], message: unknown, author: Maker, committedAt: unknown,
63
+ stats: { additions?: unknown; deletions?: unknown; authoredAt?: unknown } | null = null, listed: Maker[] | null = null): HistoryCommit | null {
64
+ if (typeof sha !== 'string' || !SHA.test(sha)) return null
65
+ const lines = stats ? { additions: lineCount(stats.additions), deletions: lineCount(stats.deletions), authoredAt: timeOf(stats.authoredAt),
66
+ authorZone: zoneOf(stats.authoredAt) } : { additions: null, deletions: null, authoredAt: null, authorZone: null }
67
+ return {
68
+ sha,
69
+ parents: parents.filter((parent): parent is string => typeof parent === 'string' && SHA.test(parent)).slice(0, MAX_PARENTS),
70
+ subject: typeof message === 'string' ? message.replaceAll('\r\n', '\n').split('\n', 1)[0]!.trim().slice(0, MAX_SUBJECT) : '',
71
+ authorName: textOf(author.name, MAX_NAME),
72
+ authorLogin: author.account.login,
73
+ authorId: author.account.id,
74
+ coauthors: coauthorsOf(message, author, listed),
75
+ committedAt: timeOf(committedAt),
76
+ ...lines,
77
+ }
78
+ }
79
+
80
+ type RestCommit = { sha?: unknown; parents?: { sha?: unknown }[]; author?: { login?: unknown; id?: unknown } | null
81
+ commit?: { message?: unknown; author?: { name?: unknown; email?: unknown } | null; committer?: { date?: unknown } | null } }
82
+ const restCommit = (item: RestCommit) => historyCommit(item?.sha, Array.isArray(item?.parents) ? item.parents.map(parent => parent?.sha) : [],
83
+ item?.commit?.message, { name: item?.commit?.author?.name, email: item?.commit?.author?.email, account: accountOf(item?.author) }, item?.commit?.committer?.date)
84
+
85
+ const repoApi = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
86
+
87
+ // A page of the commits `commit` reaches, newest first as GitHub lists them,
88
+ // those touching `path` alone where it names a directory; `more` where a
89
+ // full page leaves some for the next, one that may be read.
90
+ export async function repositoryHistory(fullName: string, token: string | null, commit: string, path: string, page: number, fetchImpl: typeof fetch = globalThis.fetch) {
91
+ const params = new URLSearchParams({ sha: commit, per_page: String(HISTORY_PAGE), page: String(page), ...path ? { path } : {} })
92
+ const data = await githubJson(`${repoApi(fullName)}/commits?${params}`, token, fetchImpl)
93
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
94
+ return { commits: data.flatMap(item => restCommit(item as RestCommit) ?? []), more: data.length >= HISTORY_PAGE && page < MAX_HISTORY_PAGE }
95
+ }
96
+
97
+ // The commits the repository's branches and tags point at, as GitHub's
98
+ // first page of each lists them, to name commits by.
99
+ export async function repositoryRefTips(fullName: string, token: string | null, fetchImpl: typeof fetch = globalThis.fetch) {
100
+ const tips = (list: unknown): HistoryRef[] => Array.isArray(list) ? list.flatMap((item: { name?: unknown; commit?: { sha?: unknown } }) =>
101
+ typeof item?.name === 'string' && item.name && typeof item.commit?.sha === 'string' && SHA.test(item.commit.sha)
102
+ ? [{ name: item.name.slice(0, MAX_NAME), sha: item.commit.sha }] : []) : []
103
+ const [branches, tags] = await Promise.all(['branches', 'tags'].map(kind => githubJson(`${repoApi(fullName)}/${kind}?per_page=${REF_TIPS}`, token, fetchImpl)))
104
+ return { branches: tips(branches), tags: tips(tags) }
105
+ }
106
+
107
+ export type GraphqlError = { type?: unknown; path?: unknown; message?: unknown }
108
+
109
+ const NAMED_ERRORS = new Set(['RATE_LIMITED', 'NOT_FOUND', 'FORBIDDEN'])
110
+ // GitHub's own words for a query it stopped past its time (ten seconds):
111
+ // "Something went wrong while executing your query. This may be the result
112
+ // of a timeout, …", with no type.
113
+ const TIMED_OUT = /\btimeout\b/iu
114
+
115
+ // One GraphQL query with `token`, answered with its `data`: one GitHub answers
116
+ // with errors, but for those the caller reads past (`tolerated`, the fields
117
+ // they name then null), is rate limited, names what isn't there, is refused,
118
+ // as one a GitHub App lacks the permission for is, or a token the scope for
119
+ // (`INSUFFICIENT_SCOPES`, a sign-in's having none), took GitHub too long, or
120
+ // else is malformed, as one GitHub rejects unread is (a field or type its API
121
+ // doesn't know). GitHub's words for errors of none of those first three types
122
+ // are logged, those read past too.
123
+ export async function githubGraphql(query: string, variables: Record<string, unknown>, token: string, fetchImpl: typeof fetch,
124
+ tolerated: (error: GraphqlError) => boolean = () => false) {
125
+ const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }) as { data?: unknown; errors?: GraphqlError[] } | null
126
+ const all = Array.isArray(body?.errors) ? body.errors : []
127
+ const unnamed = all.filter(error => !NAMED_ERRORS.has(String(error?.type)))
128
+ if (unnamed.length > 0) {
129
+ console.warn('managed: GitHub answered a query with errors:', JSON.stringify(unnamed.slice(0, 3)
130
+ .map(error => ({ type: error?.type, message: String(error?.message).slice(0, 300), path: error?.path }))))
131
+ }
132
+ const errors = all.filter(error => !tolerated(error))
133
+ if (errors.length > 0) {
134
+ if (errors.some(error => error?.type === 'RATE_LIMITED')) throw new GithubApiError(429, 'github-rate-limited', 60)
135
+ if (errors.some(error => error?.type === 'NOT_FOUND')) throw new GithubApiError(404, 'github-not-found')
136
+ if (errors.some(error => error?.type === 'FORBIDDEN' || error?.type === 'INSUFFICIENT_SCOPES')) throw new GithubApiError(502, 'github-status-403')
137
+ if (errors.some(error => typeof error?.message === 'string' && TIMED_OUT.test(error.message))) throw new GithubApiError(502, 'github-timeout')
138
+ throw new GithubApiError(502, 'github-malformed')
139
+ }
140
+ return body?.data
141
+ }
142
+
143
+ // A tag's target, an annotated tag's own target in turn, through as many as
144
+ // MAX_TAG_DEPTH annotated tags, each object's type naming what it is.
145
+ const MAX_TAG_DEPTH = 4
146
+ const tagTarget = (tags: number): string => `target { __typename oid${tags > 0 ? ` ... on Tag { ${tagTarget(tags - 1)} }` : ''} }`
147
+
148
+ // The branches' tips, a page at a time, each with its commit's date (GitHub
149
+ // orders heads by name alone), and, on the first page, the default branch's
150
+ // tip, wherever its name sorts, and the latest tags.
151
+ const HEADS_QUERY = `query($owner: String!, $name: String!, $after: String, $tags: Int!, $first: Boolean!) {
152
+ repository(owner: $owner, name: $name) {
153
+ defaultBranchRef { name target { oid ... on Commit { committedDate } } }
154
+ branches: refs(refPrefix: "refs/heads/", first: 100, after: $after) {
155
+ pageInfo { hasNextPage endCursor }
156
+ nodes { name target { oid ... on Commit { committedDate } } }
157
+ }
158
+ tags: refs(refPrefix: "refs/tags/", first: $tags, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) @include(if: $first) {
159
+ nodes { name ${tagTarget(MAX_TAG_DEPTH)} }
160
+ }
161
+ }
162
+ }`
163
+
164
+ // As many commits as gluing kept pages on goes to, in as many rounds of
165
+ // looking them up, each looking up as many commits as tops.
166
+ export const HISTORY_GLUED = 2000
167
+ const MAX_GLUE_ROUNDS = 20
168
+ const GLUE_LOOKUP = 50
169
+
170
+ // The pages of a repository's history kept, each by the commit it starts at
171
+ // (its top): the first page of the history from that commit, which never
172
+ // changes.
173
+ export interface HistoryPages {
174
+ read(tops: string[]): Promise<Map<string, HistoryCommit[]>>
175
+ write(top: string, commits: HistoryCommit[]): Promise<void>
176
+ }
177
+
178
+ // What a commit is read by in GraphQL.
179
+ const COMMIT_FRAGMENT = `fragment historyCommit on Commit { oid message committedDate additions deletions author { name email date user { login databaseId } }
180
+ authors(first: ${MAX_COAUTHORS + 1}) { nodes { name email user { login databaseId } } } parents(first: ${MAX_PARENTS}) { nodes { oid } } }`
181
+ // A query's variables `$<prefix><n>` of `type`, as many as `count`.
182
+ const variablesOf = (prefix: string, count: number, type: string) => Array.from({ length: count }, (_, at) => `$${prefix}${at}: ${type}`).join(', ')
183
+
184
+ // The latest commits of each tip, by its id, `b<n>` the `n`th's.
185
+ const historiesQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
186
+ repository(owner: $owner, name: $name) {
187
+ ${Array.from({ length: count }, (_, at) => `b${at}: object(oid: $o${at}) { ... on Commit { history(first: ${ALL_DEPTH}) { pageInfo { hasNextPage } nodes { ...historyCommit } } } }`).join('\n ')}
188
+ }
189
+ }
190
+ ${COMMIT_FRAGMENT}`
191
+
192
+ type GraphCommit = { oid?: unknown; message?: unknown; committedDate?: unknown; additions?: unknown; deletions?: unknown
193
+ author?: ({ name?: unknown; date?: unknown } & GraphActor) | null; authors?: { nodes?: (({ name?: unknown } & GraphActor) | null)[] } | null
194
+ parents?: { nodes?: { oid?: unknown }[] } }
195
+ type GraphTarget = { __typename?: unknown; oid?: unknown; committedDate?: unknown; target?: GraphTarget | null }
196
+ type GraphRef = { name?: unknown; target?: GraphTarget | null }
197
+ type HeadsPage = { repository?: { defaultBranchRef?: GraphRef | null
198
+ branches?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: GraphRef[] }; tags?: { nodes?: GraphRef[] } } | null }
199
+ type Histories = { repository?: Record<string, { history?: { pageInfo?: { hasNextPage?: unknown }; nodes?: GraphCommit[] } } | null> | null }
200
+
201
+ // The repository's branches, as many as MAX_HEAD_PAGES of them list, each
202
+ // with its tip's time, its default branch and latest tags (an annotated tag
203
+ // by the commit it tags), and whether any branch is left out.
204
+ async function repositoryHeads(owner: string, name: string, token: string, fetchImpl: typeof fetch) {
205
+ const heads: (HistoryRef & { at: number | null })[] = []
206
+ let first: HeadsPage['repository'] = null
207
+ let after: string | null = null
208
+ for (let page = 0; page < MAX_HEAD_PAGES; page++) {
209
+ const data = await githubGraphql(HEADS_QUERY, { owner, name, after, tags: ALL_TAGS, first: page === 0 }, token, fetchImpl) as HeadsPage
210
+ const repository = data?.repository
211
+ if (!repository) throw new GithubApiError(404, 'github-not-found')
212
+ first ??= repository
213
+ for (const ref of repository.branches?.nodes ?? []) {
214
+ const sha = ref?.target?.oid
215
+ if (typeof ref?.name === 'string' && ref.name && typeof sha === 'string' && SHA.test(sha)) heads.push({ name: ref.name.slice(0, MAX_NAME), sha, at: timeOf(ref.target?.committedDate) })
216
+ }
217
+ const next = repository.branches?.pageInfo
218
+ if (next?.hasNextPage !== true || typeof next.endCursor !== 'string') return { heads, repository: first, cut: false }
219
+ after = next.endCursor
220
+ }
221
+ return { heads, repository: first, cut: true }
222
+ }
223
+
224
+ // The first page of the history from each of `tops`, by its top: with a
225
+ // token, in one GraphQL query; without, a REST list each.
226
+ async function readPages(fullName: string, token: string | null, tops: string[], fetchImpl: typeof fetch) {
227
+ if (!token) {
228
+ return new Map(await Promise.all(tops.map(async top => [top, (await repositoryHistory(fullName, null, top, '', 1, fetchImpl)).commits] as const)))
229
+ }
230
+ const [owner, name] = fullName.split('/') as [string, string]
231
+ const data = await githubGraphql(historiesQuery(tops.length), { owner, name, ...Object.fromEntries(tops.map((sha, at) => [`o${at}`, sha])) }, token, fetchImpl) as Histories
232
+ return new Map(tops.map((top, at) => [top, (data?.repository?.[`b${at}`]?.history?.nodes ?? []).flatMap(node => graphCommit(node) ?? [])]))
233
+ }
234
+
235
+ // Its authors past the first, the git author, GitHub lists from its
236
+ // `Co-authored-by:` trailers.
237
+ const graphCommit = (node: GraphCommit | null | undefined) => historyCommit(node?.oid, (node?.parents?.nodes ?? []).map(parent => parent?.oid),
238
+ node?.message, { name: node?.author?.name, email: node?.author?.email, account: commitAccount(node?.author) }, node?.committedDate,
239
+ { additions: node?.additions, deletions: node?.deletions, authoredAt: node?.author?.date },
240
+ Array.isArray(node?.authors?.nodes) ? node.authors.nodes.slice(1).map(actor => ({ name: actor?.name, email: actor?.email, account: commitAccount(actor) })) : null)
241
+
242
+ // The history from each of `starts`, a page of each, kept (`pages`), or read
243
+ // and then kept by its top; glued on to it, the pages kept that start at a
244
+ // parent of a commit listed not listed, or at a commit listed, the oldest
245
+ // first (where each goes on furthest), GLUE_LOOKUP looked up at a time, and
246
+ // those that start where those go on, till HISTORY_GLUED are listed. The
247
+ // pages from `starts` are listed whole, however many that is (all branches'
248
+ // first, as many as ALL_BRANCHES of them): the cap is gluing's. Each commit
249
+ // once, whether any goes on past those listed (`more`), and how many pages
250
+ // were read of GitHub (`read`).
251
+ export async function historyFrom(fullName: string, token: string | null, starts: string[], pages: HistoryPages, fetchImpl: typeof fetch = globalThis.fetch) {
252
+ const kept = await pages.read(starts)
253
+ const unread = starts.filter(top => !kept.has(top))
254
+ const read = unread.length > 0 ? await readPages(fullName, token, unread, fetchImpl) : new Map<string, HistoryCommit[]>()
255
+ // Kept only as GitHub listed it from its top: none for a commit it named nothing of.
256
+ await Promise.all([...read].flatMap(([top, commits]) => commits[0]?.sha === top ? [pages.write(top, commits)] : []))
257
+ const listed = new Map<string, HistoryCommit>()
258
+ const add = (commits: HistoryCommit[]) => { for (const commit of commits) if (!listed.has(commit.sha)) listed.set(commit.sha, commit) }
259
+ for (const top of starts) add(kept.get(top) ?? read.get(top) ?? [])
260
+ const looked = new Set(starts)
261
+ for (let round = 0; round < MAX_GLUE_ROUNDS && listed.size < HISTORY_GLUED; round++) {
262
+ const parents = [...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent)))
263
+ const tops = [...new Set([...parents, ...[...listed.keys()].toReversed()])].filter(sha => !looked.has(sha)).slice(0, GLUE_LOOKUP)
264
+ if (tops.length === 0) break
265
+ for (const sha of tops) looked.add(sha)
266
+ const glued = await pages.read(tops)
267
+ for (const commit of [...glued.values()].flat()) {
268
+ if (listed.size >= HISTORY_GLUED) break
269
+ if (!listed.has(commit.sha)) listed.set(commit.sha, commit)
270
+ }
271
+ }
272
+ const commits = [...listed.values()]
273
+ return { commits, more: commits.some(commit => commit.parents.some(parent => !listed.has(parent))), read: unread.length }
274
+ }
275
+
276
+ export type AllHeads = { heads: (HistoryRef & { at: number | null })[]; defaultBranch: string | null; tags: HistoryRef[]; cut: boolean }
277
+
278
+ // `--all`'s branches: the repository's, as many as MAX_HEAD_PAGES of them
279
+ // list, each with its tip's time, its default branch among them wherever its
280
+ // name sorts; its latest tags, each by the commit it tags; and whether
281
+ // branches past those listed are left out (`cut`).
282
+ export async function repositoryAllHeads(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<AllHeads> {
283
+ const [owner, name] = fullName.split('/') as [string, string]
284
+ const { heads, repository, cut } = await repositoryHeads(owner, name, token, fetchImpl)
285
+ const defaultBranch = textOf(repository?.defaultBranchRef?.name, MAX_NAME)
286
+ // The default branch, where its name sorts past the heads read.
287
+ const defaultTip = repository?.defaultBranchRef?.target?.oid
288
+ if (defaultBranch && typeof defaultTip === 'string' && SHA.test(defaultTip) && !heads.some(head => head.name === defaultBranch)) {
289
+ heads.push({ name: defaultBranch, sha: defaultTip, at: timeOf(repository?.defaultBranchRef?.target?.committedDate) })
290
+ }
291
+ // A tag by the commit it tags, through the annotated tags between; one
292
+ // ending at no commit names none.
293
+ const tags = (repository?.tags?.nodes ?? []).flatMap(ref => {
294
+ let target = ref?.target
295
+ while (target?.__typename === 'Tag') target = target.target
296
+ const sha = target?.__typename === 'Commit' ? target.oid : null
297
+ return typeof ref?.name === 'string' && typeof sha === 'string' && SHA.test(sha) ? [{ name: ref.name.slice(0, MAX_NAME), sha }] : []
298
+ })
299
+ return { heads, defaultBranch, tags, cut }
300
+ }
301
+
302
+ // `--all`: the latest commits of the most recently committed to of `heads`
303
+ // (repositoryAllHeads), the default branch's among them, each commit once
304
+ // (historyFrom); the branches naming them and the latest tags; the branches
305
+ // not read, newest first, whose tips none of those is (`unread`), where
306
+ // reading on goes; whether branches past those listed are left out, never to
307
+ // be read (`cut`); and whether any is left out (`more`).
308
+ export async function repositoryAllHistory(fullName: string, token: string, { heads, defaultBranch, tags, cut }: AllHeads, pages: HistoryPages,
309
+ fetchImpl: typeof fetch = globalThis.fetch) {
310
+ const newest = heads.toSorted((a, b) => Number(b.name === defaultBranch) - Number(a.name === defaultBranch) || (b.at ?? 0) - (a.at ?? 0))
311
+ const shown = newest.slice(0, ALL_BRANCHES)
312
+ const read = await historyFrom(fullName, token, [...new Set(shown.map(head => head.sha))], pages, fetchImpl)
313
+ const listed = new Set(read.commits.map(commit => commit.sha))
314
+ const more = cut || heads.length > shown.length || read.more
315
+ // Every branch naming a commit listed, those not read among them.
316
+ const branches = heads.flatMap(({ name: branch, sha }) => listed.has(sha) ? [{ name: branch, sha }] : [])
317
+ const unread = newest.slice(ALL_BRANCHES).filter(head => !listed.has(head.sha))
318
+ return { commits: read.commits, branches, tags, defaultBranch, unread, cut, more }
319
+ }
320
+
321
+ // A pull request squashed into a commit of the default branch's history
322
+ // (`merge`): its number, the commit its head was at when merged (`head`,
323
+ // GitHub's own of it, which a branch reused or pushed over since never
324
+ // moves), and its own commits, the latest MAX_SQUASHED of them, `more`
325
+ // where it had more. Drawn as the merge it was, its commits on a lane of
326
+ // their own (repository-history.js).
327
+ export interface SquashedPull { merge: string; number: number; head: string; commits: HistoryCommit[]; more: boolean }
328
+ // The pull requests squashed into commits, kept by each commit (a merged pull
329
+ // request's commits never change); null for a commit none was.
330
+ export interface Squashes {
331
+ read(shas: string[]): Promise<Map<string, SquashedPull | null>>
332
+ write(sha: string, pull: SquashedPull | null): Promise<void>
333
+ }
334
+
335
+ const MAX_SQUASHED = 100
336
+ // Looked up of an answer's commits not yet kept, at most, the newest first;
337
+ // in queries of as many commits, or pull requests, as many at once.
338
+ export const MAX_SQUASH_LOOKUPS = 500
339
+ const SQUASH_COMMITS = 100
340
+ const SQUASH_PULLS = 20
341
+ const SQUASH_PARALLEL = 4
342
+
343
+ // The merged pull request that brought each commit in, `c<n>` the `n`th's.
344
+ const mergedPullsQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
345
+ repository(owner: $owner, name: $name) {
346
+ ${Array.from({ length: count }, (_, at) => `c${at}: object(oid: $o${at}) { ... on Commit { associatedPullRequests(first: 1) { nodes { number mergeCommit { oid } } } } }`).join('\n ')}
347
+ }
348
+ }`
349
+ // Each pull request's head when merged and its latest commits, `p<n>` the `n`th's.
350
+ const squashedQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('n', count, 'Int!')}) {
351
+ repository(owner: $owner, name: $name) {
352
+ ${Array.from({ length: count }, (_, at) => `p${at}: pullRequest(number: $n${at}) { headRefOid commits(last: ${MAX_SQUASHED}) { totalCount nodes { commit { ...historyCommit } } } }`).join('\n ')}
353
+ }
354
+ }
355
+ ${COMMIT_FRAGMENT}`
356
+
357
+ type MergedPulls = { repository?: Record<string, { associatedPullRequests?: { nodes?: ({ number?: unknown; mergeCommit?: { oid?: unknown } | null } | null)[] } } | null> | null }
358
+ type SquashedPage = { repository?: Record<string, { headRefOid?: unknown; commits?: { totalCount?: unknown; nodes?: ({ commit?: GraphCommit | null } | null)[] } } | null> | null }
359
+
360
+ const chunked = <T>(items: T[], size: number) => Array.from({ length: Math.ceil(items.length / size) }, (_, at) => items.slice(at * size, (at + 1) * size))
361
+ // `run` for each of `items`, as many at once as `parallel`.
362
+ async function eachOf<T>(items: T[], parallel: number, run: (item: T) => Promise<void>) {
363
+ let next = 0
364
+ await Promise.all(Array.from({ length: Math.min(parallel, items.length) }, async () => { while (next < items.length) await run(items[next++]!) }))
365
+ }
366
+ // A pull request or commit GitHub has none of, as one deleted: its field null.
367
+ const notFound = (error: GraphqlError) => error?.type === 'NOT_FOUND'
368
+
369
+ // The pull requests squashed into `commits`, a merged pull request's merge
370
+ // commit of one parent, not one rebased (its commits copied onto the branch,
371
+ // told by their subjects), kept by each commit (`squashes`), those not yet
372
+ // kept looked up with GraphQL, as many as MAX_SQUASH_LOOKUPS an answer.
373
+ export async function squashedPulls(fullName: string, token: string, commits: HistoryCommit[], squashes: Squashes,
374
+ fetchImpl: typeof fetch = globalThis.fetch): Promise<SquashedPull[]> {
375
+ const [owner, name] = fullName.split('/') as [string, string]
376
+ const single = commits.filter(commit => commit.parents.length === 1)
377
+ const listed = new Map(commits.map(commit => [commit.sha, commit]))
378
+ const kept = await squashes.read(single.map(commit => commit.sha))
379
+ const unknown = single.filter(commit => !kept.has(commit.sha)).slice(0, MAX_SQUASH_LOOKUPS)
380
+ const merged: { commit: HistoryCommit; number: number }[] = []
381
+ const found = new Map<string, SquashedPull | null>()
382
+ await eachOf(chunked(unknown, SQUASH_COMMITS), SQUASH_PARALLEL, async batch => {
383
+ const data = await githubGraphql(mergedPullsQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map((commit, at) => [`o${at}`, commit.sha])) },
384
+ token, fetchImpl, notFound) as MergedPulls
385
+ for (const [at, commit] of batch.entries()) {
386
+ const node = data?.repository?.[`c${at}`]
387
+ if (!node) continue
388
+ const pull = node.associatedPullRequests?.nodes?.[0]
389
+ const number = pull?.mergeCommit?.oid === commit.sha && Number.isSafeInteger(pull.number) && (pull.number as number) > 0 ? pull.number as number : null
390
+ if (number === null) { found.set(commit.sha, null); await squashes.write(commit.sha, null) }
391
+ else merged.push({ commit, number })
392
+ }
393
+ })
394
+ await eachOf(chunked(merged, SQUASH_PULLS), SQUASH_PARALLEL, async batch => {
395
+ const data = await githubGraphql(squashedQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map(({ number }, at) => [`n${at}`, number])) },
396
+ token, fetchImpl, notFound) as SquashedPage
397
+ for (const [at, { commit, number }] of batch.entries()) {
398
+ const node = data?.repository?.[`p${at}`]
399
+ if (!node) continue
400
+ const own = (node.commits?.nodes ?? []).flatMap(item => graphCommit(item?.commit) ?? [])
401
+ const head = typeof node.headRefOid === 'string' && SHA.test(node.headRefOid) ? node.headRefOid : own.at(-1)?.sha
402
+ // Fast-forwarded, its last commit the merge's own; or rebased, its
403
+ // commits, more than one, copied onto the branch: the merge and those
404
+ // below it on their first parents, by their subjects, its commits'
405
+ // (one squashed is told by its commit's title too, by default, and
406
+ // drawn as it was either way).
407
+ const below = [commit]
408
+ for (let next = listed.get(commit.parents[0] ?? ''); next && below.length < own.length; next = listed.get(next.parents[0] ?? '')) below.push(next)
409
+ const copies = own.toReversed().slice(0, below.length).every((item, step) => below[step]!.subject === item.subject)
410
+ // Its commits' copies as far as those listed go, but not all of them
411
+ // listed: told, and kept, once they are.
412
+ if (own.length > 1 && copies && below.length < own.length) continue
413
+ const rebased = own.length > 1 && copies
414
+ const squashed = head !== undefined && head !== commit.sha && own.length > 0 && !own.some(item => item.sha === commit.sha) && !rebased
415
+ const pull = squashed ? { merge: commit.sha, number, head, commits: own, more: Number(node.commits?.totalCount) > own.length } : null
416
+ found.set(commit.sha, pull)
417
+ await squashes.write(commit.sha, pull)
418
+ }
419
+ })
420
+ return single.flatMap(commit => kept.get(commit.sha) ?? found.get(commit.sha) ?? [])
421
+ }
422
+
423
+ // The open pull requests most recently updated, each by the commit its head
424
+ // is at, to name commits by.
425
+ const OPEN_PULLS = 100
426
+ const OPEN_PULLS_QUERY = `query($owner: String!, $name: String!) {
427
+ repository(owner: $owner, name: $name) {
428
+ pullRequests(states: OPEN, first: ${OPEN_PULLS}, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { number headRefOid } }
429
+ }
430
+ }`
431
+ export async function openPullHeads(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch) {
432
+ const [owner, name] = fullName.split('/') as [string, string]
433
+ const data = await githubGraphql(OPEN_PULLS_QUERY, { owner, name }, token, fetchImpl) as
434
+ { repository?: { pullRequests?: { nodes?: ({ number?: unknown; headRefOid?: unknown } | null)[] } } | null }
435
+ return { pulls: (data?.repository?.pullRequests?.nodes ?? []).flatMap(node => Number.isSafeInteger(node?.number) && (node!.number as number) > 0
436
+ && typeof node!.headRefOid === 'string' && SHA.test(node!.headRefOid) ? [{ number: node!.number as number, sha: node!.headRefOid }] : []) }
437
+ }
438
+
439
+ // Where `--all` reads on from, as a request names them (`from`): as many
440
+ // commit ids, each once, as branches are read at first; null for none, or
441
+ // for one that isn't a commit id.
442
+ export function historyStarts(from: string): string[] | null {
443
+ const starts = [...new Set(from.split(','))]
444
+ return starts.length <= ALL_BRANCHES && starts.every(sha => SHA.test(sha)) ? starts : null
445
+ }
@@ -0,0 +1,181 @@
1
+ // The issues and pull requests the code viewer's Issues and Pull requests
2
+ // tabs show (GET /api/admin/repositories/issues): those open, or those
3
+ // closed (a pull request's merged among them), the most recently updated
4
+ // first, a page at a time, each as one tile has room for, with how many are
5
+ // open and closed. A pull request's tile names its branches, its size, its
6
+ // review and its checks, which GraphQL alone gives, so it needs a token; an
7
+ // open one's, and its popup's, whether it changes a lockfile whose new
8
+ // packages are told (pull-dependencies.ts touchesLockfile), and its reviews
9
+ // (pull-reviews.ts), each reviewer's. A nonpublic repository's are read with
10
+ // its GitHub App's installation, which reads pull requests only with their
11
+ // own permission (README).
12
+ import { GithubApiError } from './github-app.ts'
13
+ import { touchesLockfile } from './pull-dependencies.ts'
14
+ import { ACTOR, type GraphAccount, type GraphqlError, actorOf, githubGraphql } from './repository-history.ts'
15
+ import { ITEM_THREADS, type ReviewNode, TILE_THREADS, pullReviews, reviewFields, reviewsRefused } from './pull-reviews.ts'
16
+
17
+ export const ISSUES_PAGE = 50
18
+ // What a kept page or item was read as: kept ones read before differ.
19
+ export const ISSUES_READ_VERSION = 3
20
+ const MAX_LABELS = 20
21
+ const MAX_TITLE = 1000
22
+ const MAX_NAME = 256
23
+ const COLOR = /^[a-f\d]{6}$/iu
24
+ // A page's cursor as GitHub gives it, to read on from: opaque, base64 or
25
+ // base64url as it is now (`…rOrK_u7Q==`), passed back to GitHub alone, so any
26
+ // printable one is, but none with spaces or control characters.
27
+ export const ISSUES_CURSOR = /^[!-~]{1,256}$/u
28
+
29
+ export type IssuesKind = 'issues' | 'pulls'
30
+ export type IssuesState = 'open' | 'closed'
31
+
32
+ const shared = `number title createdAt updatedAt closedAt author { ${ACTOR} } comments { totalCount } labels(first: ${MAX_LABELS}) { totalCount nodes { name color } }`
33
+ const fields = {
34
+ issues: `${shared} state stateReason`,
35
+ pulls: `${shared} state isDraft mergedAt reviewDecision additions deletions headRefName baseRefName
36
+ commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`,
37
+ }
38
+ // The files a pull request changes, as many as LISTED_FILES, as an open one's
39
+ // tile and its popup tell whether it changes a lockfile by them.
40
+ const LISTED_FILES = 100
41
+ const files = `files(first: ${LISTED_FILES}) { totalCount nodes { path } }`
42
+ const connection = { issues: 'issues', pulls: 'pullRequests' }
43
+ const states = { issues: { open: '[OPEN]', closed: '[CLOSED]' }, pulls: { open: '[OPEN]', closed: '[CLOSED, MERGED]' } }
44
+
45
+ // A page of `kind`, those in `state`, after the page `after` ends, and how
46
+ // many of each state there are; open pull requests with their reviews.
47
+ const pageQuery = (kind: IssuesKind, state: IssuesState) => `query($owner: String!, $name: String!, $after: String) {
48
+ repository(owner: $owner, name: $name) {
49
+ open: ${connection[kind]}(states: ${states[kind].open}) { totalCount }
50
+ closed: ${connection[kind]}(states: ${states[kind].closed}) { totalCount }
51
+ page: ${connection[kind]}(states: ${states[kind][state]}, first: ${ISSUES_PAGE}, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
52
+ pageInfo { hasNextPage endCursor }
53
+ nodes { ${fields[kind]}${kind === 'pulls' && state === 'open' ? ` ${files} ${reviewFields(TILE_THREADS, false)}` : ''} }
54
+ }
55
+ }
56
+ }`
57
+
58
+ type Node = ReviewNode & { number?: unknown; title?: unknown; createdAt?: unknown; updatedAt?: unknown; closedAt?: unknown; mergedAt?: unknown; author?: GraphAccount
59
+ comments?: { totalCount?: unknown }; labels?: { totalCount?: unknown; nodes?: { name?: unknown; color?: unknown }[] }; state?: unknown; stateReason?: unknown
60
+ isDraft?: unknown; reviewDecision?: unknown; additions?: unknown; deletions?: unknown; headRefName?: unknown; baseRefName?: unknown
61
+ commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: unknown } | null } | null }[] }
62
+ files?: { totalCount?: unknown; nodes?: ({ path?: unknown } | null)[] } | null }
63
+ type Page = { repository?: { open?: { totalCount?: unknown }; closed?: { totalCount?: unknown }
64
+ page?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (Node | null)[] } } | null }
65
+
66
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
67
+ const timeOf = (value: unknown) => {
68
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
69
+ return Number.isSafeInteger(at) ? at : null
70
+ }
71
+ const nameOf = (value: unknown, max = MAX_NAME) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null
72
+ const REVIEWS: Record<string, string> = { APPROVED: 'approved', CHANGES_REQUESTED: 'changes', REVIEW_REQUIRED: 'required' }
73
+ const CHECKS: Record<string, string> = { SUCCESS: 'success', FAILURE: 'failure', ERROR: 'failure', PENDING: 'pending', EXPECTED: 'pending' }
74
+ const REASONS: Record<string, string> = { COMPLETED: 'completed', NOT_PLANNED: 'not-planned', DUPLICATE: 'duplicate' }
75
+
76
+ // An issue or pull request as a tile shows it; null for one GitHub names no
77
+ // number for.
78
+ function item(kind: IssuesKind, node: Node | null) {
79
+ if (!node || !Number.isSafeInteger(node.number) || (node.number as number) <= 0) return null
80
+ const author = actorOf(node.author)
81
+ const labels = (node.labels?.nodes ?? []).flatMap(label => {
82
+ const name = nameOf(label?.name)
83
+ return name ? [{ name, color: typeof label?.color === 'string' && COLOR.test(label.color) ? label.color.toLowerCase() : null }] : []
84
+ })
85
+ const common = { number: node.number as number, title: nameOf(node.title, MAX_TITLE) ?? '', author: author.login, authorId: author.id, createdAt: timeOf(node.createdAt), updatedAt: timeOf(node.updatedAt),
86
+ closedAt: timeOf(node.closedAt), comments: count(node.comments?.totalCount), labels, moreLabels: Math.max(0, count(node.labels?.totalCount) - labels.length) }
87
+ if (kind === 'issues') {
88
+ return { ...common, state: node.state === 'OPEN' ? 'open' : 'closed', reason: REASONS[String(node.stateReason)] ?? null }
89
+ }
90
+ const checks = node.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state
91
+ const listed = node.files?.nodes
92
+ const paths = Array.isArray(listed) ? listed.flatMap(file => typeof file?.path === 'string' ? [file.path] : []) : null
93
+ return { ...common, state: node.state === 'MERGED' ? 'merged' : node.state === 'OPEN' ? 'open' : 'closed', draft: node.isDraft === true, mergedAt: timeOf(node.mergedAt),
94
+ review: REVIEWS[String(node.reviewDecision)] ?? null, checks: CHECKS[String(checks)] ?? null, additions: count(node.additions), deletions: count(node.deletions),
95
+ head: nameOf(node.headRefName), base: nameOf(node.baseRefName),
96
+ ...paths ? { touchesLockfile: touchesLockfile(paths, count(node.files?.totalCount)) } : {} }
97
+ }
98
+
99
+ // A pull request's checks, which a GitHub App reads only with the Checks and
100
+ // Commit statuses permissions, refused: its tile then shows none; or its
101
+ // files, whose lockfiles' new packages it then tells none of. Any other
102
+ // field refused, the list is, as one without Pull requests: read is.
103
+ const checksRefused = (error: GraphqlError) => error?.type === 'FORBIDDEN' && Array.isArray(error.path)
104
+ && (error.path.includes('statusCheckRollup') || error.path.includes('files'))
105
+ const partRefused = (error: GraphqlError) => checksRefused(error) || reviewsRefused(error)
106
+
107
+ // One issue or pull request, as its popup shows it (GET
108
+ // /api/admin/repositories/item): as its tile does, with its description, its
109
+ // Markdown as written (never GitHub's HTML of it); an issue's pull requests closing
110
+ // it, a pull request's issues it closes, its commits and files changed, and
111
+ // who merged it. Its state, of a different type each, named apart. A
112
+ // description longer than MAX_BODY is shortened (`bodyShortened`), and the
113
+ // linked past the first LINKED counted (`moreLinked`), the rest on GitHub.
114
+ const MAX_BODY = 20_000
115
+ const LINKED = 10
116
+ const ITEM_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
117
+ repository(owner: $owner, name: $name) {
118
+ item: issueOrPullRequest(number: $number) {
119
+ __typename
120
+ ... on Issue { ${shared} issueState: state stateReason body
121
+ closedByPullRequestsReferences(first: ${LINKED}, includeClosedPrs: true) { totalCount nodes { number title pullState: state isDraft } } }
122
+ ... on PullRequest { ${shared} pullState: state isDraft mergedAt reviewDecision additions deletions headRefName baseRefName body changedFiles ${files}
123
+ commits(last: 1) { totalCount nodes { commit { statusCheckRollup { state } } } } mergedBy { ${ACTOR} }
124
+ closingIssuesReferences(first: ${LINKED}) { totalCount nodes { number title issueState: state stateReason } }
125
+ ${reviewFields(ITEM_THREADS, true)} }
126
+ }
127
+ }
128
+ }`
129
+ type Linked = { number?: unknown; title?: unknown; issueState?: unknown; pullState?: unknown; stateReason?: unknown; isDraft?: unknown } | null
130
+ type ItemNode = Node & { __typename?: unknown; issueState?: unknown; pullState?: unknown; body?: unknown; changedFiles?: unknown
131
+ commits?: Node['commits'] & { totalCount?: unknown }; mergedBy?: GraphAccount
132
+ closedByPullRequestsReferences?: Links; closingIssuesReferences?: Links }
133
+ type Links = { totalCount?: unknown; nodes?: Linked[] } | null
134
+
135
+ // The issues or pull requests an item names, each by its number, title and
136
+ // state, as `kind`'s tile names one.
137
+ const linked = (kind: IssuesKind, nodes: Linked[] | undefined) => (nodes ?? []).flatMap(node => {
138
+ const tile = item(kind, node ? { number: node.number, title: node.title, state: node.issueState ?? node.pullState, stateReason: node.stateReason, isDraft: node.isDraft } : null)
139
+ return tile ? [{ number: tile.number, title: tile.title, state: tile.state, ...'reason' in tile ? { reason: tile.reason } : { draft: tile.draft } }] : []
140
+ })
141
+
142
+ // The issue or pull request numbered `number`, its `kind` with it.
143
+ export async function repositoryItem(fullName: string, token: string, number: number, fetchImpl: typeof fetch = globalThis.fetch) {
144
+ const [owner, name] = fullName.split('/') as [string, string]
145
+ const data = await githubGraphql(ITEM_QUERY, { owner, name, number }, token, fetchImpl, partRefused) as { repository?: { item?: ItemNode | null } | null }
146
+ const node = data?.repository?.item
147
+ const kind: IssuesKind | null = node?.__typename === 'Issue' ? 'issues' : node?.__typename === 'PullRequest' ? 'pulls' : null
148
+ const tile = kind && node ? item(kind, { ...node, state: kind === 'issues' ? node.issueState : node.pullState }) : null
149
+ if (!kind || !node || !tile) throw new GithubApiError(404, 'github-not-found')
150
+ // As written, but for blank lines before it and spaces after: a first
151
+ // line's indent, an indented code block's, kept.
152
+ const written = typeof node.body === 'string' ? node.body.replace(/^(?:[ \t]*\r?\n)+/u, '').trimEnd() : ''
153
+ const links = kind === 'issues' ? node.closedByPullRequestsReferences : node.closingIssuesReferences
154
+ const named = linked(kind === 'issues' ? 'pulls' : 'issues', links?.nodes)
155
+ const described = { body: written.slice(0, MAX_BODY), bodyShortened: written.length > MAX_BODY, moreLinked: Math.max(0, count(links?.totalCount) - named.length) }
156
+ const merger = actorOf(node.mergedBy)
157
+ return kind === 'issues' ? { kind, ...tile, ...described, closedBy: named }
158
+ : { kind, ...tile, ...described, closes: named, commits: count(node.commits?.totalCount), files: count(node.changedFiles),
159
+ mergedBy: merger.login, mergedById: merger.id, reviews: pullReviews(node, tile.author, true) }
160
+ }
161
+
162
+ // A page of the repository's issues or pull requests in `state`, after the
163
+ // page `after` ended (null for the first): its `items`, how many are open and
164
+ // closed (`counts`), and, where more follow, the cursor reading them (`after`).
165
+ export async function repositoryIssues(fullName: string, token: string, kind: IssuesKind, state: IssuesState, after: string | null, fetchImpl: typeof fetch = globalThis.fetch) {
166
+ const [owner, name] = fullName.split('/') as [string, string]
167
+ const data = await githubGraphql(pageQuery(kind, state), { owner, name, after }, token, fetchImpl, partRefused) as Page
168
+ const repository = data?.repository
169
+ if (!repository) throw new GithubApiError(404, 'github-not-found')
170
+ const info = repository.page?.pageInfo
171
+ const next = info?.hasNextPage === true && typeof info.endCursor === 'string' && ISSUES_CURSOR.test(info.endCursor) ? info.endCursor : null
172
+ return {
173
+ items: (repository.page?.nodes ?? []).flatMap(node => {
174
+ const tile = item(kind, node)
175
+ if (!tile) return []
176
+ return kind === 'pulls' && state === 'open' && node ? [{ ...tile, reviews: pullReviews(node, tile.author) }] : [tile]
177
+ }),
178
+ counts: { open: count(repository.open?.totalCount), closed: count(repository.closed?.totalCount) },
179
+ more: next !== null, after: next,
180
+ }
181
+ }