@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +25 -2
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/line-diff.js +5 -4
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +88 -7
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +3 -0
- package/common/workspace-app.js +6 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4UFAMJAX.js +1 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7XRR4OFU.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-BOIXMUU5.js +3 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-CC4OM6TZ.js +3 -0
- package/out/chunk-G24A42V6.js +2 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -1
- package/out/chunk-JKGQ3O7O.js +6 -0
- package/out/chunk-LE5ZWLG4.js +235 -0
- package/out/chunk-MSION3G4.js +36 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-S6GIXWQW.js +52 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-V3KWNNMY.js +1 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +30 -34
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +53 -6
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +71 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +1 -1
- package/risk/npm/compare.js +34 -10
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/bundle-cache.ts +7 -1
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +72 -0
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +66 -19
- package/server-managed/db-neon.ts +89 -21
- package/server-managed/db-schema.ts +13 -2
- package/server-managed/db.ts +9 -1
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +78 -18
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +33 -13
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +1251 -97
- package/server-managed/index.ts +13 -8
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/locked-packages.ts +37 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +178 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +83 -6
- package/server-managed/npm-loads.ts +114 -37
- package/server-managed/npm-packages.ts +110 -64
- package/server-managed/npm-risk.ts +4 -1
- package/server-managed/pretty-print.ts +67 -23
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +300 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +16 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +42 -9
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +166 -37
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +157 -0
- package/server-managed/repository-history.ts +461 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +439 -0
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +133 -118
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +27 -9
- package/server-managed/sql.ts +6 -3
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-app.ts +19 -3
- package/server-managed/team-reports.ts +24 -12
- package/server-managed/upstream-cache.ts +12 -0
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +25 -5
- package/out/chunk-2ABLKCOY.js +0 -46
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-5LSXOLUS.js +0 -1
- package/out/chunk-5RCM2J63.js +0 -1
- package/out/chunk-7B6HB4D7.js +0 -1
- package/out/chunk-AJ4GTATZ.js +0 -3
- package/out/chunk-CH4UN6WK.js +0 -225
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-EDKLBQQN.js +0 -17
- package/out/chunk-F47DXCEV.js +0 -33
- package/out/chunk-FK3DZEKL.js +0 -1
- package/out/chunk-G6AJWENO.js +0 -1
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-GZWM7UQM.js +0 -2
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LPCTJYNA.js +0 -7
- package/out/chunk-O4RJY5TP.js +0 -2
- package/out/chunk-OD4LPN3H.js +0 -1
- package/out/chunk-QWDWTBM7.js +0 -1
- package/out/chunk-QYWKC557.js +0 -1
- package/out/chunk-R75XOKUP.js +0 -1
- package/out/chunk-SWTLADW5.js +0 -1
- package/out/chunk-T7OKH4VW.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UTR2LVME.js +0 -2
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-ZESLQCTF.js +0 -6
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// A connected repository's dependency review, as an admin sets it, and the
|
|
2
|
+
// reviews that pass its pull requests' dependencies (pull-dependency-reviews.ts).
|
|
3
|
+
// Enabled, each package version its open pull requests' lockfiles lock needs
|
|
4
|
+
// as many published reviews as `required`; those its lockfiles locked at
|
|
5
|
+
// `since`, a commit (null for none), none; of the lockfiles it holds to
|
|
6
|
+
// (`lockfiles`, lockfile-scope.ts), every one by default. It is in effect only where a team
|
|
7
|
+
// that reviews dependencies has the repository, which is where reviews that
|
|
8
|
+
// count are published, and not revoked: each reviewer's latest of a version
|
|
9
|
+
// tells where they stand on it, a reject holding it unreviewed whatever
|
|
10
|
+
// others passed it till they accept it again; and a reviewer standing on
|
|
11
|
+
// accepting it passes it by a review of theirs that is full (every file
|
|
12
|
+
// checked; a partial review counts for nothing yet) and accepting (Accept,
|
|
13
|
+
// or Accept with nits).
|
|
14
|
+
import type { ManagedSql } from './sql.ts'
|
|
15
|
+
import { type LockfileScope, scopeText, storedScope } from './lockfile-scope.ts'
|
|
16
|
+
import { reviewsIn } from './dependency-reviews.ts'
|
|
17
|
+
import { type ReviewVerdict, summarizeReview } from '../common/dependency-reviews.js'
|
|
18
|
+
|
|
19
|
+
export const REPOSITORY_DEPENDENCY_REVIEW_COLUMNS = [['dependency_review', 'INTEGER NOT NULL DEFAULT 0'],
|
|
20
|
+
['dependency_reviews_required', 'INTEGER NOT NULL DEFAULT 1'], ['dependency_review_since', 'TEXT'], ['dependency_review_lockfiles', 'TEXT']] as const
|
|
21
|
+
export const MAX_REQUIRED_REVIEWS = 10
|
|
22
|
+
export const SINCE_COMMIT = /^[a-f\d]{40}$/u
|
|
23
|
+
|
|
24
|
+
export interface RepoDependencyReview { enabled: boolean; required: number; since: string | null; lockfiles: LockfileScope }
|
|
25
|
+
// A review published of the version it is of: its package, version and
|
|
26
|
+
// npm's integrity of it, the patch it is of (none here, the app reviewing no
|
|
27
|
+
// patch yet), the version it was compared with (none for a whole package's),
|
|
28
|
+
// who wrote it (`reviewer`, their id, else their login then), its verdict,
|
|
29
|
+
// whether it checked every file (`full`), and when it was published.
|
|
30
|
+
// One made in a pull request's comments (`comment`) counts for it alone;
|
|
31
|
+
// one published here has its `id`.
|
|
32
|
+
export interface PublishedReview {
|
|
33
|
+
id?: string; package: string; version: string; integrity: string; patchHash?: string; basePackage: string | null; baseVersion: string | null
|
|
34
|
+
reviewer: string; status: ReviewVerdict; full: boolean; time: number; comment?: true
|
|
35
|
+
}
|
|
36
|
+
// A repository's review in effect for a reader: its settings, whether they
|
|
37
|
+
// publish reviews to one of the teams that count (`canReview`), and the
|
|
38
|
+
// published reviews of the packages asked.
|
|
39
|
+
export interface RepoReviewState extends Omit<RepoDependencyReview, 'enabled'> { canReview: boolean; reviews: PublishedReview[] }
|
|
40
|
+
|
|
41
|
+
// A package version whose published reviews are asked.
|
|
42
|
+
export interface ReviewedVersionKey { name: string; version: string }
|
|
43
|
+
|
|
44
|
+
export interface RepositoryDependencyReviewStore {
|
|
45
|
+
listRepoDependencyReviews(): Promise<Map<number, RepoDependencyReview>>
|
|
46
|
+
// One repository's, none where it isn't connected.
|
|
47
|
+
repoDependencyReview(repoId: number): Promise<RepoDependencyReview | null>
|
|
48
|
+
// The teams whose published reviews count for each repository (COUNTING_TEAM),
|
|
49
|
+
// by name.
|
|
50
|
+
listRepoReviewTeams(): Promise<Map<number, { id: string; name: string }[]>>
|
|
51
|
+
// Sets a repository's review, its commit and lockfiles too but where
|
|
52
|
+
// `since` or `lockfiles` is undefined, which keeps it; the setting then, or
|
|
53
|
+
// null for no repository.
|
|
54
|
+
setRepoDependencyReview(repoId: number, setting: Omit<RepoDependencyReview, 'since' | 'lockfiles'> & { since?: string | null; lockfiles?: LockfileScope }): Promise<RepoDependencyReview | null>
|
|
55
|
+
// Its review in effect for `user`, as `setting` tells it where read
|
|
56
|
+
// already, with the published reviews of `versions`.
|
|
57
|
+
repoDependencyReviewState(repoId: number, user: string, versions: ReviewedVersionKey[], setting?: RepoDependencyReview | null): Promise<RepoReviewState | null>
|
|
58
|
+
// Which of `users` publish reviews to a team counting for repository
|
|
59
|
+
// `repoId`, whose reviews its pull requests' comments make count.
|
|
60
|
+
listRepoReviewers(repoId: number, users: string[]): Promise<Set<string>>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type SettingRow = { repoId: number; enabled: number; required: number; since: string | null; lockfiles: string | null }
|
|
64
|
+
const setting = (row: SettingRow): RepoDependencyReview => ({ enabled: row.enabled === 1, required: row.required, since: row.since, lockfiles: storedScope(row.lockfiles) })
|
|
65
|
+
// Teams whose published reviews count for repository `?`: that review
|
|
66
|
+
// dependencies and have it, not hidden.
|
|
67
|
+
const COUNTING_TEAM = `t.hidden = 0 AND t.dependency_review = 1 AND EXISTS (
|
|
68
|
+
SELECT 1 FROM managed_team_repo tr WHERE tr.team_id = t.id AND tr.repo_id = ?)`
|
|
69
|
+
const VERSIONS_AT_ONCE = 250
|
|
70
|
+
|
|
71
|
+
// The published and unrevoked reviews of `versions` in teams counting for
|
|
72
|
+
// repository `repoId`, each with whether it is full, a couple of hundred
|
|
73
|
+
// versions a query: those of their packages' and versions' found, the
|
|
74
|
+
// versions asked alone kept, their files' reviews read of those alone.
|
|
75
|
+
async function publishedReviews(db: ManagedSql, repoId: number, versions: ReviewedVersionKey[]): Promise<PublishedReview[]> {
|
|
76
|
+
const found: PublishedReview[] = []
|
|
77
|
+
for (let at = 0; at < versions.length; at += VERSIONS_AT_ONCE) {
|
|
78
|
+
const asked = versions.slice(at, at + VERSIONS_AT_ONCE)
|
|
79
|
+
const keys = new Set(asked.map(({ name, version }) => `${name}@${version}`))
|
|
80
|
+
const [names, numbers] = [[...new Set(asked.map(({ name }) => name))], [...new Set(asked.map(({ version }) => version))]]
|
|
81
|
+
const rows = (await db.prepare(`SELECT r.id, r.user_id AS userId, r.user_login AS userLogin, r.package, r.version, r.integrity,
|
|
82
|
+
NULLIF(r.base_package, '') AS basePackage, NULLIF(r.base_version, '') AS baseVersion, r.files, r.status, r.published_at AS publishedAt
|
|
83
|
+
FROM managed_dependency_review r
|
|
84
|
+
WHERE r.ecosystem = 'npm' AND r.published_at IS NOT NULL AND r.revoked_at IS NULL AND r.status IS NOT NULL
|
|
85
|
+
AND r.package IN (${names.map(() => '?').join(',')}) AND r.version IN (${numbers.map(() => '?').join(',')})
|
|
86
|
+
AND EXISTS (SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id
|
|
87
|
+
WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, ...numbers, repoId) as
|
|
88
|
+
{ id: string; userId: string | null; userLogin: string; package: string; version: string; integrity: string
|
|
89
|
+
basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[])
|
|
90
|
+
.filter(row => keys.has(`${row.package}@${row.version}`))
|
|
91
|
+
if (rows.length === 0) continue
|
|
92
|
+
const entries = new Map<string, { file: string; status: ReviewVerdict | null }[]>()
|
|
93
|
+
for (const { reviewId, ...entry } of await db.prepare(`SELECT review_id AS reviewId, file, status FROM managed_dependency_file_review
|
|
94
|
+
WHERE review_id IN (${rows.map(() => '?').join(',')})`).all(...rows.map(row => row.id)) as { reviewId: string; file: string; status: ReviewVerdict | null }[]) {
|
|
95
|
+
const of = entries.get(reviewId)
|
|
96
|
+
if (of) of.push(entry)
|
|
97
|
+
else entries.set(reviewId, [entry])
|
|
98
|
+
}
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
const snapshot = row.files === null ? null : JSON.parse(row.files) as string[] | { paths: string[]; nonTextFiles: string[] }
|
|
101
|
+
// A snapshot of paths alone, before non-text files were told, can't be full.
|
|
102
|
+
const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
|
|
103
|
+
const nonText = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
|
|
104
|
+
const full = summarizeReview(entries.get(row.id) ?? [], files, nonText).coverage === 'full'
|
|
105
|
+
found.push({ id: row.id, package: row.package, version: row.version, integrity: row.integrity, basePackage: row.basePackage, baseVersion: row.baseVersion,
|
|
106
|
+
reviewer: row.userId ?? `login:${row.userLogin.toLowerCase()}`, status: row.status, full, time: Number(row.publishedAt) })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return found
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDependencyReviewStore {
|
|
113
|
+
const columns = `repo_id AS repoId, dependency_review AS enabled, dependency_reviews_required AS required, dependency_review_since AS since,
|
|
114
|
+
dependency_review_lockfiles AS lockfiles`
|
|
115
|
+
const readSetting = async (repoId: number) => {
|
|
116
|
+
const row = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow | undefined
|
|
117
|
+
return row ? setting(row) : null
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
async listRepoDependencyReviews() {
|
|
121
|
+
const rows = await db.prepare(`SELECT ${columns} FROM managed_selected_repo`).all() as SettingRow[]
|
|
122
|
+
return new Map(rows.map(row => [row.repoId, setting(row)]))
|
|
123
|
+
},
|
|
124
|
+
repoDependencyReview: readSetting,
|
|
125
|
+
async listRepoReviewTeams() {
|
|
126
|
+
const rows = await db.prepare(`SELECT DISTINCT tr.repo_id AS repoId, t.id AS id, t.name AS name FROM managed_team_repo tr
|
|
127
|
+
JOIN managed_team t ON t.id = tr.team_id WHERE t.hidden = 0 AND t.dependency_review = 1 ORDER BY t.name, t.id`).all() as { repoId: number; id: string; name: string }[]
|
|
128
|
+
const teams = new Map<number, { id: string; name: string }[]>()
|
|
129
|
+
for (const { repoId, id, name } of rows) teams.set(repoId, [...teams.get(repoId) ?? [], { id, name }])
|
|
130
|
+
return teams
|
|
131
|
+
},
|
|
132
|
+
async setRepoDependencyReview(repoId, { enabled, required, since, lockfiles }) {
|
|
133
|
+
const set: [string, unknown][] = [['dependency_review', enabled ? 1 : 0], ['dependency_reviews_required', required],
|
|
134
|
+
...since === undefined ? [] : [['dependency_review_since', since] as [string, unknown]],
|
|
135
|
+
...lockfiles === undefined ? [] : [['dependency_review_lockfiles', scopeText(lockfiles)] as [string, unknown]]]
|
|
136
|
+
const { changes } = await db.prepare(`UPDATE managed_selected_repo SET ${set.map(([column]) => `${column} = ?`).join(', ')} WHERE repo_id = ?`)
|
|
137
|
+
.run(...set.map(([, value]) => value), repoId)
|
|
138
|
+
if (Number(changes) === 0) return null
|
|
139
|
+
return setting(await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow)
|
|
140
|
+
},
|
|
141
|
+
async listRepoReviewers(repoId, users) {
|
|
142
|
+
if (users.length === 0) return new Set()
|
|
143
|
+
const rows = await db.prepare(`SELECT DISTINCT u.id FROM managed_user u JOIN managed_team t ON t.hidden = 0
|
|
144
|
+
WHERE u.id IN (${users.map(() => '?').join(',')}) AND ${reviewsIn(true)} AND ${COUNTING_TEAM}`).all(...users, repoId) as { id: string }[]
|
|
145
|
+
return new Set(rows.map(row => row.id))
|
|
146
|
+
},
|
|
147
|
+
async repoDependencyReviewState(repoId, user, versions, read) {
|
|
148
|
+
const now = read === undefined ? await readSetting(repoId) : read
|
|
149
|
+
if (!now?.enabled) return null
|
|
150
|
+
if (!await db.prepare(`SELECT 1 FROM managed_team t WHERE ${COUNTING_TEAM}`).get(repoId)) return null
|
|
151
|
+
const canReview = await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ?
|
|
152
|
+
WHERE ${reviewsIn(true)} AND ${COUNTING_TEAM}`).get(user, repoId) != null
|
|
153
|
+
const asked = [...new Map(versions.map(version => [`${version.name}@${version.version}`, version])).values()]
|
|
154
|
+
return { required: now.required, since: now.since, lockfiles: now.lockfiles, canReview, reviews: await publishedReviews(db, repoId, asked) }
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,461 @@
|
|
|
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 }, true) 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
|
+
// The pages kept of `first`; where none is, those of `then`, in one read
|
|
176
|
+
// (`first` whether they are `first`'s). Without it, both are read together.
|
|
177
|
+
readFirst?(first: string[], then: string[]): Promise<{ first: boolean; pages: Map<string, HistoryCommit[]> }>
|
|
178
|
+
write(top: string, commits: HistoryCommit[]): Promise<void>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// What a commit is read by in GraphQL.
|
|
182
|
+
const COMMIT_FRAGMENT = `fragment historyCommit on Commit { oid message committedDate additions deletions author { name email date user { login databaseId } }
|
|
183
|
+
authors(first: ${MAX_COAUTHORS + 1}) { nodes { name email user { login databaseId } } } parents(first: ${MAX_PARENTS}) { nodes { oid } } }`
|
|
184
|
+
// A query's variables `$<prefix><n>` of `type`, as many as `count`.
|
|
185
|
+
const variablesOf = (prefix: string, count: number, type: string) => Array.from({ length: count }, (_, at) => `$${prefix}${at}: ${type}`).join(', ')
|
|
186
|
+
|
|
187
|
+
// The latest commits of each tip, by its id, `b<n>` the `n`th's.
|
|
188
|
+
const historiesQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
|
|
189
|
+
repository(owner: $owner, name: $name) {
|
|
190
|
+
${Array.from({ length: count }, (_, at) => `b${at}: object(oid: $o${at}) { ... on Commit { history(first: ${ALL_DEPTH}) { pageInfo { hasNextPage } nodes { ...historyCommit } } } }`).join('\n ')}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
${COMMIT_FRAGMENT}`
|
|
194
|
+
|
|
195
|
+
type GraphCommit = { oid?: unknown; message?: unknown; committedDate?: unknown; additions?: unknown; deletions?: unknown
|
|
196
|
+
author?: ({ name?: unknown; date?: unknown } & GraphActor) | null; authors?: { nodes?: (({ name?: unknown } & GraphActor) | null)[] } | null
|
|
197
|
+
parents?: { nodes?: { oid?: unknown }[] } }
|
|
198
|
+
type GraphTarget = { __typename?: unknown; oid?: unknown; committedDate?: unknown; target?: GraphTarget | null }
|
|
199
|
+
type GraphRef = { name?: unknown; target?: GraphTarget | null }
|
|
200
|
+
type HeadsPage = { repository?: { defaultBranchRef?: GraphRef | null
|
|
201
|
+
branches?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: GraphRef[] }; tags?: { nodes?: GraphRef[] } } | null }
|
|
202
|
+
type Histories = { repository?: Record<string, { history?: { pageInfo?: { hasNextPage?: unknown }; nodes?: GraphCommit[] } } | null> | null }
|
|
203
|
+
|
|
204
|
+
// The repository's branches, as many as MAX_HEAD_PAGES of them list, each
|
|
205
|
+
// with its tip's time, its default branch and latest tags (an annotated tag
|
|
206
|
+
// by the commit it tags), and whether any branch is left out.
|
|
207
|
+
async function repositoryHeads(owner: string, name: string, token: string, fetchImpl: typeof fetch) {
|
|
208
|
+
const heads: (HistoryRef & { at: number | null })[] = []
|
|
209
|
+
let first: HeadsPage['repository'] = null
|
|
210
|
+
let after: string | null = null
|
|
211
|
+
for (let page = 0; page < MAX_HEAD_PAGES; page++) {
|
|
212
|
+
const data = await githubGraphql(HEADS_QUERY, { owner, name, after, tags: ALL_TAGS, first: page === 0 }, token, fetchImpl) as HeadsPage
|
|
213
|
+
const repository = data?.repository
|
|
214
|
+
if (!repository) throw new GithubApiError(404, 'github-not-found')
|
|
215
|
+
first ??= repository
|
|
216
|
+
for (const ref of repository.branches?.nodes ?? []) {
|
|
217
|
+
const sha = ref?.target?.oid
|
|
218
|
+
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) })
|
|
219
|
+
}
|
|
220
|
+
const next = repository.branches?.pageInfo
|
|
221
|
+
if (next?.hasNextPage !== true || typeof next.endCursor !== 'string') return { heads, repository: first, cut: false }
|
|
222
|
+
after = next.endCursor
|
|
223
|
+
}
|
|
224
|
+
return { heads, repository: first, cut: true }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// The first page of the history from each of `tops`, by its top: with a
|
|
228
|
+
// token, in one GraphQL query; without, a REST list each.
|
|
229
|
+
async function readPages(fullName: string, token: string | null, tops: string[], fetchImpl: typeof fetch) {
|
|
230
|
+
if (!token) {
|
|
231
|
+
return new Map(await Promise.all(tops.map(async top => [top, (await repositoryHistory(fullName, null, top, '', 1, fetchImpl)).commits] as const)))
|
|
232
|
+
}
|
|
233
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
234
|
+
const data = await githubGraphql(historiesQuery(tops.length), { owner, name, ...Object.fromEntries(tops.map((sha, at) => [`o${at}`, sha])) }, token, fetchImpl) as Histories
|
|
235
|
+
return new Map(tops.map((top, at) => [top, (data?.repository?.[`b${at}`]?.history?.nodes ?? []).flatMap(node => graphCommit(node) ?? [])]))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Its authors past the first, the git author, GitHub lists from its
|
|
239
|
+
// `Co-authored-by:` trailers.
|
|
240
|
+
const graphCommit = (node: GraphCommit | null | undefined) => historyCommit(node?.oid, (node?.parents?.nodes ?? []).map(parent => parent?.oid),
|
|
241
|
+
node?.message, { name: node?.author?.name, email: node?.author?.email, account: commitAccount(node?.author) }, node?.committedDate,
|
|
242
|
+
{ additions: node?.additions, deletions: node?.deletions, authoredAt: node?.author?.date },
|
|
243
|
+
Array.isArray(node?.authors?.nodes) ? node.authors.nodes.slice(1).map(actor => ({ name: actor?.name, email: actor?.email, account: commitAccount(actor) })) : null)
|
|
244
|
+
|
|
245
|
+
// The history from each of `starts`, a page of each, kept (`pages`), or read
|
|
246
|
+
// and then kept by its top; glued on to it, the pages kept that start at a
|
|
247
|
+
// parent of a commit listed not listed, each adding commits none listed has,
|
|
248
|
+
// and, where any of those has none kept and readable, at a commit listed, the
|
|
249
|
+
// oldest first (where each goes on furthest; a page at a commit listed lists
|
|
250
|
+
// mostly commits listed already), GLUE_LOOKUP of each looked up at a time, in
|
|
251
|
+
// one read where none of those parents' is kept, else two, and
|
|
252
|
+
// those that start where those go on, till HISTORY_GLUED are listed. The
|
|
253
|
+
// pages from `starts` are listed whole, however many that is (all branches'
|
|
254
|
+
// first, as many as ALL_BRANCHES of them): the cap is gluing's. Each commit
|
|
255
|
+
// once, whether any goes on past those listed (`more`), and how many pages
|
|
256
|
+
// were read of GitHub (`read`).
|
|
257
|
+
export async function historyFrom(fullName: string, token: string | null, starts: string[], pages: HistoryPages, fetchImpl: typeof fetch = globalThis.fetch) {
|
|
258
|
+
const kept = await pages.read(starts)
|
|
259
|
+
const unread = starts.filter(top => !kept.has(top))
|
|
260
|
+
const read = unread.length > 0 ? await readPages(fullName, token, unread, fetchImpl) : new Map<string, HistoryCommit[]>()
|
|
261
|
+
// Kept only as GitHub listed it from its top: none for a commit it named nothing of.
|
|
262
|
+
await Promise.all([...read].flatMap(([top, commits]) => commits[0]?.sha === top ? [pages.write(top, commits)] : []))
|
|
263
|
+
const listed = new Map<string, HistoryCommit>()
|
|
264
|
+
const add = (commits: HistoryCommit[]) => { for (const commit of commits) if (!listed.has(commit.sha)) listed.set(commit.sha, commit) }
|
|
265
|
+
for (const top of starts) add(kept.get(top) ?? read.get(top) ?? [])
|
|
266
|
+
const looked = new Set(starts)
|
|
267
|
+
for (let round = 0; round < MAX_GLUE_ROUNDS && listed.size < HISTORY_GLUED; round++) {
|
|
268
|
+
const parents = [...new Set([...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent) && !looked.has(parent))))].slice(0, GLUE_LOOKUP)
|
|
269
|
+
const within = [...listed.keys()].toReversed().filter(sha => !looked.has(sha)).slice(0, GLUE_LOOKUP)
|
|
270
|
+
if (parents.length === 0 && within.length === 0) break
|
|
271
|
+
// Without readFirst, GLUE_LOOKUP of both at once, the parents first.
|
|
272
|
+
const asked = pages.readFirst ? null : [...parents, ...within].slice(0, GLUE_LOOKUP)
|
|
273
|
+
let { first, pages: glued } = asked ? { first: false, pages: await pages.read(asked) } : await pages.readFirst!(parents, within)
|
|
274
|
+
// A parent with no page kept, or none readable (one GitHub's REST API
|
|
275
|
+
// listed, read with GraphQL), its line going on only by a page at a
|
|
276
|
+
// commit listed: those read too, apart, the others' lines notwithstanding.
|
|
277
|
+
if (first && glued.size < parents.length && within.length > 0) {
|
|
278
|
+
glued = new Map([...glued, ...await pages.read(within)])
|
|
279
|
+
first = false
|
|
280
|
+
}
|
|
281
|
+
// Those at commits listed, unread where a parent's answered, are looked up again.
|
|
282
|
+
for (const sha of asked ?? (first ? parents : [...parents, ...within])) looked.add(sha)
|
|
283
|
+
for (const commit of [...glued.values()].flat()) {
|
|
284
|
+
if (listed.size >= HISTORY_GLUED) break
|
|
285
|
+
if (!listed.has(commit.sha)) listed.set(commit.sha, commit)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const commits = [...listed.values()]
|
|
289
|
+
return { commits, more: commits.some(commit => commit.parents.some(parent => !listed.has(parent))), read: unread.length }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export type AllHeads = { heads: (HistoryRef & { at: number | null })[]; defaultBranch: string | null; tags: HistoryRef[]; cut: boolean }
|
|
293
|
+
|
|
294
|
+
// `--all`'s branches: the repository's, as many as MAX_HEAD_PAGES of them
|
|
295
|
+
// list, each with its tip's time, its default branch among them wherever its
|
|
296
|
+
// name sorts; its latest tags, each by the commit it tags; and whether
|
|
297
|
+
// branches past those listed are left out (`cut`).
|
|
298
|
+
export async function repositoryAllHeads(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<AllHeads> {
|
|
299
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
300
|
+
const { heads, repository, cut } = await repositoryHeads(owner, name, token, fetchImpl)
|
|
301
|
+
const defaultBranch = textOf(repository?.defaultBranchRef?.name, MAX_NAME)
|
|
302
|
+
// The default branch, where its name sorts past the heads read.
|
|
303
|
+
const defaultTip = repository?.defaultBranchRef?.target?.oid
|
|
304
|
+
if (defaultBranch && typeof defaultTip === 'string' && SHA.test(defaultTip) && !heads.some(head => head.name === defaultBranch)) {
|
|
305
|
+
heads.push({ name: defaultBranch, sha: defaultTip, at: timeOf(repository?.defaultBranchRef?.target?.committedDate) })
|
|
306
|
+
}
|
|
307
|
+
// A tag by the commit it tags, through the annotated tags between; one
|
|
308
|
+
// ending at no commit names none.
|
|
309
|
+
const tags = (repository?.tags?.nodes ?? []).flatMap(ref => {
|
|
310
|
+
let target = ref?.target
|
|
311
|
+
while (target?.__typename === 'Tag') target = target.target
|
|
312
|
+
const sha = target?.__typename === 'Commit' ? target.oid : null
|
|
313
|
+
return typeof ref?.name === 'string' && typeof sha === 'string' && SHA.test(sha) ? [{ name: ref.name.slice(0, MAX_NAME), sha }] : []
|
|
314
|
+
})
|
|
315
|
+
return { heads, defaultBranch, tags, cut }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// `--all`: the latest commits of the most recently committed to of `heads`
|
|
319
|
+
// (repositoryAllHeads), the default branch's among them, each commit once
|
|
320
|
+
// (historyFrom); the branches naming them and the latest tags; the branches
|
|
321
|
+
// not read, newest first, whose tips none of those is (`unread`), where
|
|
322
|
+
// reading on goes; whether branches past those listed are left out, never to
|
|
323
|
+
// be read (`cut`); and whether any is left out (`more`).
|
|
324
|
+
export async function repositoryAllHistory(fullName: string, token: string, { heads, defaultBranch, tags, cut }: AllHeads, pages: HistoryPages,
|
|
325
|
+
fetchImpl: typeof fetch = globalThis.fetch) {
|
|
326
|
+
const newest = heads.toSorted((a, b) => Number(b.name === defaultBranch) - Number(a.name === defaultBranch) || (b.at ?? 0) - (a.at ?? 0))
|
|
327
|
+
const shown = newest.slice(0, ALL_BRANCHES)
|
|
328
|
+
const read = await historyFrom(fullName, token, [...new Set(shown.map(head => head.sha))], pages, fetchImpl)
|
|
329
|
+
const listed = new Set(read.commits.map(commit => commit.sha))
|
|
330
|
+
const more = cut || heads.length > shown.length || read.more
|
|
331
|
+
// Every branch naming a commit listed, those not read among them.
|
|
332
|
+
const branches = heads.flatMap(({ name: branch, sha }) => listed.has(sha) ? [{ name: branch, sha }] : [])
|
|
333
|
+
const unread = newest.slice(ALL_BRANCHES).filter(head => !listed.has(head.sha))
|
|
334
|
+
return { commits: read.commits, branches, tags, defaultBranch, unread, cut, more }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// A pull request squashed into a commit of the default branch's history
|
|
338
|
+
// (`merge`): its number, the commit its head was at when merged (`head`,
|
|
339
|
+
// GitHub's own of it, which a branch reused or pushed over since never
|
|
340
|
+
// moves), and its own commits, the latest MAX_SQUASHED of them, `more`
|
|
341
|
+
// where it had more. Drawn as the merge it was, its commits on a lane of
|
|
342
|
+
// their own (repository-history.js).
|
|
343
|
+
export interface SquashedPull { merge: string; number: number; head: string; commits: HistoryCommit[]; more: boolean }
|
|
344
|
+
// The pull requests squashed into commits, kept by each commit (a merged pull
|
|
345
|
+
// request's commits never change); null for a commit none was.
|
|
346
|
+
export interface Squashes {
|
|
347
|
+
read(shas: string[]): Promise<Map<string, SquashedPull | null>>
|
|
348
|
+
write(sha: string, pull: SquashedPull | null): Promise<void>
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const MAX_SQUASHED = 100
|
|
352
|
+
// Looked up of an answer's commits not yet kept, at most, the newest first;
|
|
353
|
+
// in queries of as many commits, or pull requests, as many at once.
|
|
354
|
+
export const MAX_SQUASH_LOOKUPS = 500
|
|
355
|
+
const SQUASH_COMMITS = 100
|
|
356
|
+
const SQUASH_PULLS = 20
|
|
357
|
+
const SQUASH_PARALLEL = 4
|
|
358
|
+
|
|
359
|
+
// The merged pull request that brought each commit in, `c<n>` the `n`th's.
|
|
360
|
+
const mergedPullsQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
|
|
361
|
+
repository(owner: $owner, name: $name) {
|
|
362
|
+
${Array.from({ length: count }, (_, at) => `c${at}: object(oid: $o${at}) { ... on Commit { associatedPullRequests(first: 1) { nodes { number mergeCommit { oid } } } } }`).join('\n ')}
|
|
363
|
+
}
|
|
364
|
+
}`
|
|
365
|
+
// Each pull request's head when merged and its latest commits, `p<n>` the `n`th's.
|
|
366
|
+
const squashedQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('n', count, 'Int!')}) {
|
|
367
|
+
repository(owner: $owner, name: $name) {
|
|
368
|
+
${Array.from({ length: count }, (_, at) => `p${at}: pullRequest(number: $n${at}) { headRefOid commits(last: ${MAX_SQUASHED}) { totalCount nodes { commit { ...historyCommit } } } }`).join('\n ')}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
${COMMIT_FRAGMENT}`
|
|
372
|
+
|
|
373
|
+
type MergedPulls = { repository?: Record<string, { associatedPullRequests?: { nodes?: ({ number?: unknown; mergeCommit?: { oid?: unknown } | null } | null)[] } } | null> | null }
|
|
374
|
+
type SquashedPage = { repository?: Record<string, { headRefOid?: unknown; commits?: { totalCount?: unknown; nodes?: ({ commit?: GraphCommit | null } | null)[] } } | null> | null }
|
|
375
|
+
|
|
376
|
+
const chunked = <T>(items: T[], size: number) => Array.from({ length: Math.ceil(items.length / size) }, (_, at) => items.slice(at * size, (at + 1) * size))
|
|
377
|
+
// `run` for each of `items`, as many at once as `parallel`.
|
|
378
|
+
async function eachOf<T>(items: T[], parallel: number, run: (item: T) => Promise<void>) {
|
|
379
|
+
let next = 0
|
|
380
|
+
await Promise.all(Array.from({ length: Math.min(parallel, items.length) }, async () => { while (next < items.length) await run(items[next++]!) }))
|
|
381
|
+
}
|
|
382
|
+
// A pull request or commit GitHub has none of, as one deleted: its field null.
|
|
383
|
+
const notFound = (error: GraphqlError) => error?.type === 'NOT_FOUND'
|
|
384
|
+
|
|
385
|
+
// The pull requests squashed into `commits`, a merged pull request's merge
|
|
386
|
+
// commit of one parent, not one rebased (its commits copied onto the branch,
|
|
387
|
+
// told by their subjects), kept by each commit (`squashes`), those not yet
|
|
388
|
+
// kept looked up with GraphQL, as many as MAX_SQUASH_LOOKUPS an answer.
|
|
389
|
+
export async function squashedPulls(fullName: string, token: string, commits: HistoryCommit[], squashes: Squashes,
|
|
390
|
+
fetchImpl: typeof fetch = globalThis.fetch): Promise<SquashedPull[]> {
|
|
391
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
392
|
+
const single = commits.filter(commit => commit.parents.length === 1)
|
|
393
|
+
const listed = new Map(commits.map(commit => [commit.sha, commit]))
|
|
394
|
+
const kept = await squashes.read(single.map(commit => commit.sha))
|
|
395
|
+
const unknown = single.filter(commit => !kept.has(commit.sha)).slice(0, MAX_SQUASH_LOOKUPS)
|
|
396
|
+
const merged: { commit: HistoryCommit; number: number }[] = []
|
|
397
|
+
const found = new Map<string, SquashedPull | null>()
|
|
398
|
+
await eachOf(chunked(unknown, SQUASH_COMMITS), SQUASH_PARALLEL, async batch => {
|
|
399
|
+
const data = await githubGraphql(mergedPullsQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map((commit, at) => [`o${at}`, commit.sha])) },
|
|
400
|
+
token, fetchImpl, notFound) as MergedPulls
|
|
401
|
+
for (const [at, commit] of batch.entries()) {
|
|
402
|
+
const node = data?.repository?.[`c${at}`]
|
|
403
|
+
if (!node) continue
|
|
404
|
+
const pull = node.associatedPullRequests?.nodes?.[0]
|
|
405
|
+
const number = pull?.mergeCommit?.oid === commit.sha && Number.isSafeInteger(pull.number) && (pull.number as number) > 0 ? pull.number as number : null
|
|
406
|
+
if (number === null) { found.set(commit.sha, null); await squashes.write(commit.sha, null) }
|
|
407
|
+
else merged.push({ commit, number })
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
await eachOf(chunked(merged, SQUASH_PULLS), SQUASH_PARALLEL, async batch => {
|
|
411
|
+
const data = await githubGraphql(squashedQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map(({ number }, at) => [`n${at}`, number])) },
|
|
412
|
+
token, fetchImpl, notFound) as SquashedPage
|
|
413
|
+
for (const [at, { commit, number }] of batch.entries()) {
|
|
414
|
+
const node = data?.repository?.[`p${at}`]
|
|
415
|
+
if (!node) continue
|
|
416
|
+
const own = (node.commits?.nodes ?? []).flatMap(item => graphCommit(item?.commit) ?? [])
|
|
417
|
+
const head = typeof node.headRefOid === 'string' && SHA.test(node.headRefOid) ? node.headRefOid : own.at(-1)?.sha
|
|
418
|
+
// Fast-forwarded, its last commit the merge's own; or rebased, its
|
|
419
|
+
// commits, more than one, copied onto the branch: the merge and those
|
|
420
|
+
// below it on their first parents, by their subjects, its commits'
|
|
421
|
+
// (one squashed is told by its commit's title too, by default, and
|
|
422
|
+
// drawn as it was either way).
|
|
423
|
+
const below = [commit]
|
|
424
|
+
for (let next = listed.get(commit.parents[0] ?? ''); next && below.length < own.length; next = listed.get(next.parents[0] ?? '')) below.push(next)
|
|
425
|
+
const copies = own.toReversed().slice(0, below.length).every((item, step) => below[step]!.subject === item.subject)
|
|
426
|
+
// Its commits' copies as far as those listed go, but not all of them
|
|
427
|
+
// listed: told, and kept, once they are.
|
|
428
|
+
if (own.length > 1 && copies && below.length < own.length) continue
|
|
429
|
+
const rebased = own.length > 1 && copies
|
|
430
|
+
const squashed = head !== undefined && head !== commit.sha && own.length > 0 && !own.some(item => item.sha === commit.sha) && !rebased
|
|
431
|
+
const pull = squashed ? { merge: commit.sha, number, head, commits: own, more: Number(node.commits?.totalCount) > own.length } : null
|
|
432
|
+
found.set(commit.sha, pull)
|
|
433
|
+
await squashes.write(commit.sha, pull)
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
return single.flatMap(commit => kept.get(commit.sha) ?? found.get(commit.sha) ?? [])
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The open pull requests most recently updated, each by the commit its head
|
|
440
|
+
// is at, to name commits by.
|
|
441
|
+
const OPEN_PULLS = 100
|
|
442
|
+
const OPEN_PULLS_QUERY = `query($owner: String!, $name: String!) {
|
|
443
|
+
repository(owner: $owner, name: $name) {
|
|
444
|
+
pullRequests(states: OPEN, first: ${OPEN_PULLS}, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { number headRefOid } }
|
|
445
|
+
}
|
|
446
|
+
}`
|
|
447
|
+
export async function openPullHeads(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch) {
|
|
448
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
449
|
+
const data = await githubGraphql(OPEN_PULLS_QUERY, { owner, name }, token, fetchImpl) as
|
|
450
|
+
{ repository?: { pullRequests?: { nodes?: ({ number?: unknown; headRefOid?: unknown } | null)[] } } | null }
|
|
451
|
+
return { pulls: (data?.repository?.pullRequests?.nodes ?? []).flatMap(node => Number.isSafeInteger(node?.number) && (node!.number as number) > 0
|
|
452
|
+
&& typeof node!.headRefOid === 'string' && SHA.test(node!.headRefOid) ? [{ number: node!.number as number, sha: node!.headRefOid }] : []) }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Where `--all` reads on from, as a request names them (`from`): as many
|
|
456
|
+
// commit ids, each once, as branches are read at first; null for none, or
|
|
457
|
+
// for one that isn't a commit id.
|
|
458
|
+
export function historyStarts(from: string): string[] | null {
|
|
459
|
+
const starts = [...new Set(from.split(','))]
|
|
460
|
+
return starts.length <= ALL_BRANCHES && starts.every(sha => SHA.test(sha)) ? starts : null
|
|
461
|
+
}
|