@preventive/triage 1.0.0-alpha.43 → 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.
- package/common/bundle-compare-diff.js +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-sources.js +20 -1
- 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/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 +91 -10
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +11 -0
- package/common/workspace-app.js +19 -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-5DHL5NG3.js +2 -0
- package/out/chunk-6HNXNIQY.js +52 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-AFLZH7WG.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HMAJDVXN.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 -0
- package/out/chunk-NPW52BM2.js +17 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-NV7ES4EW.js +1 -0
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PTGDYLNS.js +36 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-Q6MVYGYV.js +3 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-QRD6GIG5.js +229 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WI7Z5A2T.js +6 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/chunk-XVBEOGOS.js +3 -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 +31 -31
- 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 +55 -6
- package/risk/actions/findings.js +156 -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 +165 -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 +68 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +13 -8
- package/risk/npm/compare.js +34 -10
- package/risk/npm/dependencies.js +11 -0
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- 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/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +62 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/db-methods.ts +29 -10
- package/server-managed/db-neon.ts +30 -2
- package/server-managed/db-schema.ts +9 -2
- package/server-managed/db.ts +7 -0
- 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 +65 -15
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +23 -9
- package/server-managed/github-errors.ts +2 -0
- 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-users.ts +30 -0
- package/server-managed/http.ts +1219 -81
- package/server-managed/index.ts +6 -2
- package/server-managed/locked-packages.ts +48 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +173 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +51 -4
- package/server-managed/npm-loads.ts +20 -13
- package/server-managed/npm-packages.ts +24 -64
- package/server-managed/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +280 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/repository-actions.ts +209 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +54 -13
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +135 -0
- package/server-managed/repository-history.ts +445 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +432 -0
- package/server-managed/repository-risk-read.ts +165 -0
- package/server-managed/repository-risk.ts +116 -106
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +9 -5
- package/server-managed/sql.ts +1 -1
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/team-app.ts +54 -14
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +11 -0
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4F6JSF57.js +0 -3
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-7RU5EDRO.js +0 -226
- package/out/chunk-A7M4GV7M.js +0 -1
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- 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-LAAB5SZT.js +0 -2
- package/out/chunk-NWZ4PVPN.js +0 -1
- package/out/chunk-OSIMGFUF.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-RSHMVYNS.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UXNUAIIC.js +0 -33
- 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-VQJCXGIY.js +0 -7
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-XOCAPDCM.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
- package/out/chunk-ZMQQCGF6.js +0 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// What an open pull request's reviews say, as the code viewer's Pull requests
|
|
2
|
+
// tab and a pull request's popup show it (repository-issues.ts): who
|
|
3
|
+
// reviewed it and how, each by their latest review and the commit it was on,
|
|
4
|
+
// its author's own aside (the replies GitHub makes reviews of in a thread);
|
|
5
|
+
// whose review is requested, and how many more than those read; its review
|
|
6
|
+
// threads open and resolved, the latest read (open ones are, mostly, those
|
|
7
|
+
// lately opened), the gravest open one by the P0–P3 badge Codex marks a
|
|
8
|
+
// finding with; and
|
|
9
|
+
// Codex's own, which it keeps in a comment of its own (its summary, edited in
|
|
10
|
+
// place) rather than in reviews, reviewing only where it finds something:
|
|
11
|
+
// the commit its code review and its security review were last on, either
|
|
12
|
+
// one still running, asked for again since, or failed since. A review an
|
|
13
|
+
// agentic workflow (gh-aw) posted, as GitHub Actions' bot, names its
|
|
14
|
+
// workflow by the marker gh-aw leaves (`<!-- gh-aw-workflow-id: NAME -->`), in
|
|
15
|
+
// the popup, which reads reviews' bodies.
|
|
16
|
+
import { githubAgent } from '../common/managed/github-agents.ts'
|
|
17
|
+
import { ACTOR, type GraphAccount, actorOf } from './github-accounts.ts'
|
|
18
|
+
|
|
19
|
+
const REVIEWERS = 20
|
|
20
|
+
const REQUESTS = 10
|
|
21
|
+
// The threads the Pull requests tab counts, and those a popup lists.
|
|
22
|
+
export const TILE_THREADS = 50
|
|
23
|
+
export const ITEM_THREADS = 100
|
|
24
|
+
// The comments read for Codex's: its summary among the first, posted as the
|
|
25
|
+
// pull request opens, or among the last, where it was first asked later;
|
|
26
|
+
// asking it again, and its failures, among the last.
|
|
27
|
+
const FIRST_COMMENTS = 5
|
|
28
|
+
const LAST_COMMENTS = 20
|
|
29
|
+
const MAX_TITLE = 200
|
|
30
|
+
const MAX_PATH = 1000
|
|
31
|
+
const COMMIT = /^[a-f\d]{40}$/u
|
|
32
|
+
|
|
33
|
+
// The fields a pull request's reviews are read by, its threads' places and
|
|
34
|
+
// links with them for its popup (`detail`). A team requested is no reviewer
|
|
35
|
+
// read, but one of those requested GitHub names no one for: reading a team
|
|
36
|
+
// needs its organization's Members permission, and a query reading one
|
|
37
|
+
// without it fails whole (as one naming an EnterpriseTeam, which GitHub's
|
|
38
|
+
// docs name and its API knows none of, does).
|
|
39
|
+
export const reviewFields = (threads: number, detail: boolean) => `headRefOid
|
|
40
|
+
latestReviews(first: ${REVIEWERS}) { totalCount nodes { author { ${ACTOR} } state submittedAt commit { oid }${detail ? ' body' : ''} } }
|
|
41
|
+
reviewRequests(first: ${REQUESTS}) { totalCount nodes { requestedReviewer { __typename ... on User { login databaseId } ... on Bot { login databaseId }
|
|
42
|
+
... on Mannequin { login databaseId } } } }
|
|
43
|
+
reviewThreads(last: ${threads}) { totalCount nodes { isResolved isOutdated${detail ? ' path line originalLine' : ''}
|
|
44
|
+
comments(first: 1) { nodes { author { ${ACTOR} } body${detail ? ' url' : ''} } } } }
|
|
45
|
+
firstComments: comments(first: ${FIRST_COMMENTS}) { nodes { author { ${ACTOR} } body updatedAt } }
|
|
46
|
+
lastComments: comments(last: ${LAST_COMMENTS}) { nodes { author { ${ACTOR} } body createdAt updatedAt } }`
|
|
47
|
+
|
|
48
|
+
// The fields read for reviews that GitHub may refuse alone, as it refuses an
|
|
49
|
+
// App a pull request's checks: their pull request then shows no reviews.
|
|
50
|
+
const REVIEW_PATHS = new Set(['latestReviews', 'reviewRequests', 'reviewThreads', 'firstComments', 'lastComments'])
|
|
51
|
+
export const reviewsRefused = (error: { type?: unknown; path?: unknown } | null | undefined) => error?.type === 'FORBIDDEN' && Array.isArray(error.path)
|
|
52
|
+
&& error.path.some(part => REVIEW_PATHS.has(part as string))
|
|
53
|
+
|
|
54
|
+
type Comment = { author?: GraphAccount; body?: unknown; updatedAt?: unknown; createdAt?: unknown } | null
|
|
55
|
+
type Thread = { isResolved?: unknown; isOutdated?: unknown; path?: unknown; line?: unknown; originalLine?: unknown
|
|
56
|
+
comments?: { nodes?: (Comment & { url?: unknown })[] } | null } | null
|
|
57
|
+
export type ReviewNode = {
|
|
58
|
+
headRefOid?: unknown
|
|
59
|
+
latestReviews?: { totalCount?: unknown; nodes?: ({ author?: GraphAccount; state?: unknown; submittedAt?: unknown; commit?: { oid?: unknown } | null; body?: unknown } | null)[] } | null
|
|
60
|
+
reviewRequests?: { totalCount?: unknown; nodes?: ({ requestedReviewer?: { __typename?: unknown; login?: unknown; databaseId?: unknown } | null } | null)[] } | null
|
|
61
|
+
reviewThreads?: { totalCount?: unknown; nodes?: Thread[] } | null
|
|
62
|
+
firstComments?: { nodes?: Comment[] } | null
|
|
63
|
+
lastComments?: { nodes?: Comment[] } | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const STATES: Record<string, string> = { APPROVED: 'approved', CHANGES_REQUESTED: 'changes', COMMENTED: 'commented', DISMISSED: 'dismissed' }
|
|
67
|
+
const CODEX_STATES: Record<string, string> = { completed: 'done', running: 'running', queued: 'running', pending: 'running', in_progress: 'running', failed: 'failed', error: 'failed' }
|
|
68
|
+
const SEVERITY = /!\[(P[0-3]) Badge\]/u
|
|
69
|
+
const SUMMARY = /<!--\s*codex-review-state:v1\s+(\{[^]*?\})\s*-->/u
|
|
70
|
+
// Asking it to review again reruns its code review alone, never its security review.
|
|
71
|
+
const ASKED = /^@codex\s+review\b/iu
|
|
72
|
+
const FAILED = /^Codex Review: Something went wrong/u
|
|
73
|
+
const WORKFLOW = /<!--\s*gh-aw-workflow-id:\s*([\w.-]{1,100})\s*-->/u
|
|
74
|
+
|
|
75
|
+
const timeOf = (value: unknown) => {
|
|
76
|
+
const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
|
|
77
|
+
return Number.isSafeInteger(at) ? at : null
|
|
78
|
+
}
|
|
79
|
+
const commitOf = (value: unknown) => typeof value === 'string' && COMMIT.test(value.toLowerCase()) ? value.toLowerCase() : null
|
|
80
|
+
const textOf = (value: unknown) => typeof value === 'string' ? value : ''
|
|
81
|
+
const isCodex = (account: GraphAccount) => {
|
|
82
|
+
const { login, id } = actorOf(account)
|
|
83
|
+
return login !== null && githubAgent(login, id) === 'codex'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A finding's gravity, by its badge, `p0` the gravest; null for none.
|
|
87
|
+
const severityOf = (body: string) => SEVERITY.exec(body.slice(0, 500))?.[1]?.toLowerCase() ?? null
|
|
88
|
+
// A thread's title: its first comment's first line with words, as written,
|
|
89
|
+
// but for Codex's badge and emphasis.
|
|
90
|
+
function titleOf(body: string) {
|
|
91
|
+
for (const line of body.split('\n', 6)) {
|
|
92
|
+
const words = line.replaceAll(/!\[[^\]]*\]\([^)]*\)|<\/?sub>|\*\*|__/gu, '').trim()
|
|
93
|
+
if (words) return words.slice(0, MAX_TITLE)
|
|
94
|
+
}
|
|
95
|
+
return ''
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Codex's review of one kind, as its summary names it: the commit it was
|
|
99
|
+
// last on and how it stands.
|
|
100
|
+
function codexReview(value: unknown) {
|
|
101
|
+
const review = value && typeof value === 'object' ? value as { commit_sha?: unknown; status?: unknown } : null
|
|
102
|
+
if (!review) return null
|
|
103
|
+
return { commit: commitOf(review.commit_sha), status: CODEX_STATES[String(review.status)] ?? null }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// What Codex's summary says of its reviews, else null.
|
|
107
|
+
function summaryState(body: string): { code_review?: unknown; security_review?: unknown } | null {
|
|
108
|
+
try {
|
|
109
|
+
const value: unknown = JSON.parse(SUMMARY.exec(body)?.[1] ?? 'null')
|
|
110
|
+
return value && typeof value === 'object' ? value : null
|
|
111
|
+
} catch { return null }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Codex's reviews, from its summary, else none: its account, as the summary
|
|
115
|
+
// names it; whether it was asked again since the summary last changed
|
|
116
|
+
// (`@codex review`), and whether it failed since it was last asked.
|
|
117
|
+
function codexOf(node: ReviewNode) {
|
|
118
|
+
const summary = [...node.firstComments?.nodes ?? [], ...node.lastComments?.nodes ?? []]
|
|
119
|
+
.find(comment => comment && isCodex(comment.author) && SUMMARY.test(textOf(comment.body)))
|
|
120
|
+
if (!summary) return null
|
|
121
|
+
const state = summaryState(textOf(summary.body))
|
|
122
|
+
if (!state) return null
|
|
123
|
+
const since = timeOf(summary.updatedAt) ?? 0
|
|
124
|
+
const later = (node.lastComments?.nodes ?? []).filter(comment => comment && (timeOf(comment.createdAt) ?? 0) > since) as NonNullable<Comment>[]
|
|
125
|
+
const at = (comment: NonNullable<Comment> | undefined) => comment ? timeOf(comment.createdAt) ?? 0 : null
|
|
126
|
+
const asked = at(later.findLast(comment => !isCodex(comment.author) && ASKED.test(textOf(comment.body).trim())))
|
|
127
|
+
const failed = at(later.findLast(comment => isCodex(comment.author) && FAILED.test(textOf(comment.body).trim())))
|
|
128
|
+
const { login, id } = actorOf(summary.author)
|
|
129
|
+
return { login, id, code: codexReview(state.code_review), security: codexReview(state.security_review),
|
|
130
|
+
requested: asked !== null && !(failed !== null && failed > asked), failed: failed !== null && (asked === null || failed > asked) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type Reviewer = { login: string | null; id: number | null; workflow?: string; state: string | null; requested: boolean; commit: string | null; at: number | null }
|
|
134
|
+
|
|
135
|
+
// The reviewers, each once: by their latest review, its state and commit;
|
|
136
|
+
// whose is requested (again, for one who reviewed), a user, a bot (Copilot's
|
|
137
|
+
// code review, asked for, is its bot's) or an imported account's mannequin.
|
|
138
|
+
// Codex's reviews are its summary's where it has one (`codex`). How many
|
|
139
|
+
// requested GitHub named no one for, a team among them, as `unnamed`,
|
|
140
|
+
// counted with those past the ones read.
|
|
141
|
+
function reviewersOf(node: ReviewNode, author: string | null, codex: boolean) {
|
|
142
|
+
const own = author?.toLowerCase()
|
|
143
|
+
const reviewers: Reviewer[] = (node.latestReviews?.nodes ?? []).flatMap(review => {
|
|
144
|
+
const { login, id } = actorOf(review?.author)
|
|
145
|
+
const state = STATES[String(review?.state)]
|
|
146
|
+
if (!state || (login !== null && login.toLowerCase() === own) || (codex && isCodex(review?.author))) return []
|
|
147
|
+
const workflow = WORKFLOW.exec(textOf(review?.body))?.[1]
|
|
148
|
+
return [{ login, id, ...workflow ? { workflow } : {}, state, requested: false, commit: commitOf(review?.commit?.oid), at: timeOf(review?.submittedAt) }]
|
|
149
|
+
})
|
|
150
|
+
let unnamed = 0
|
|
151
|
+
for (const request of node.reviewRequests?.nodes ?? []) {
|
|
152
|
+
const reviewer = request?.requestedReviewer
|
|
153
|
+
const { login, id } = actorOf(reviewer)
|
|
154
|
+
if (login === null) {
|
|
155
|
+
unnamed++
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
// By their account where both name it, as GitHub may name one account
|
|
159
|
+
// otherwise on its reviews than on its requests (Copilot's code review's
|
|
160
|
+
// bot); else by their login.
|
|
161
|
+
const reviewed = reviewers.find(item => id !== null && item.id !== null ? item.id === id : item.login?.toLowerCase() === login.toLowerCase())
|
|
162
|
+
if (reviewed) reviewed.requested = true
|
|
163
|
+
else reviewers.push({ login, id, state: null, requested: true, commit: null, at: null })
|
|
164
|
+
}
|
|
165
|
+
return { reviewers, unnamed }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Its review threads, the latest read: how many are open and resolved,
|
|
169
|
+
// whether more than those read are, the gravest open one; and, for its popup (`detail`), each
|
|
170
|
+
// open one: its gravity, title, file and line, whether its code changed
|
|
171
|
+
// since, who opened it and where it is on GitHub.
|
|
172
|
+
function threadsOf(node: ReviewNode, detail: boolean) {
|
|
173
|
+
const threads = (node.reviewThreads?.nodes ?? []).filter(Boolean) as NonNullable<Thread>[]
|
|
174
|
+
const open = threads.filter(thread => thread.isResolved !== true)
|
|
175
|
+
const severities = open.map(thread => severityOf(textOf(thread.comments?.nodes?.[0]?.body))).filter(Boolean) as string[]
|
|
176
|
+
const total = node.reviewThreads?.totalCount
|
|
177
|
+
const counted = { open: open.length, resolved: threads.length - open.length,
|
|
178
|
+
more: Number.isSafeInteger(total) && (total as number) > threads.length, severity: severities.toSorted()[0] ?? null }
|
|
179
|
+
if (!detail) return counted
|
|
180
|
+
return { ...counted, list: open.map(thread => {
|
|
181
|
+
const first = thread.comments?.nodes?.[0]
|
|
182
|
+
const body = textOf(first?.body)
|
|
183
|
+
const opener = actorOf(first?.author)
|
|
184
|
+
const line = Number.isSafeInteger(thread.line) ? thread.line : Number.isSafeInteger(thread.originalLine) ? thread.originalLine : null
|
|
185
|
+
const url = typeof first?.url === 'string' && first.url.startsWith('https://github.com/') ? first.url : null
|
|
186
|
+
return { severity: severityOf(body), title: titleOf(body), path: typeof thread.path === 'string' ? thread.path.slice(0, MAX_PATH) : null, line,
|
|
187
|
+
outdated: thread.isOutdated === true, author: opener.login, authorId: opener.id, url }
|
|
188
|
+
}) }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A pull request's reviews (`author` its author's login), for its tile or,
|
|
192
|
+
// with its threads listed, its popup (`detail`); null where none was read, or
|
|
193
|
+
// GitHub refused its reviews, requests or threads (reviewsRefused), rather
|
|
194
|
+
// than none.
|
|
195
|
+
export function pullReviews(node: ReviewNode, author: string | null, detail = false) {
|
|
196
|
+
if (node.headRefOid === undefined || node.latestReviews === null || node.reviewRequests === null || node.reviewThreads === null) return null
|
|
197
|
+
const codex = codexOf(node)
|
|
198
|
+
// Those past the reviews and the requests read, as many as GitHub counts,
|
|
199
|
+
// apart: a review unread may be on the latest commit, a request never is.
|
|
200
|
+
const past = (connection: { totalCount?: unknown; nodes?: unknown[] } | null | undefined) => Number.isSafeInteger(connection?.totalCount)
|
|
201
|
+
? Math.max(0, (connection!.totalCount as number) - (connection!.nodes?.length ?? 0)) : 0
|
|
202
|
+
const { reviewers, unnamed } = reviewersOf(node, author, codex !== null)
|
|
203
|
+
return { head: commitOf(node.headRefOid), reviewers, moreReviews: past(node.latestReviews), moreRequests: past(node.reviewRequests) + unnamed,
|
|
204
|
+
threads: threadsOf(node, detail), codex }
|
|
205
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The commits that release a repository, for its Pulse (repository-pulse.ts
|
|
2
|
+
// commitStats): one a tag names that changes no code, only package.json
|
|
3
|
+
// files (at least one, in any directory), the lockfiles beside them
|
|
4
|
+
// (RELEASE_LOCKFILES) and changelogs written in Markdown (RELEASE_CHANGELOG,
|
|
5
|
+
// a changeset's file among them), as `npm version` and a release's tooling
|
|
6
|
+
// leave one. What each commit changes is read as Replay reads it, kept by
|
|
7
|
+
// the commit (repository-changes.ts repositoryChanges).
|
|
8
|
+
import { type ChangesStore, type CommitChanges, repositoryChanges } from './repository-changes.ts'
|
|
9
|
+
import { githubGraphql } from './repository-history.ts'
|
|
10
|
+
|
|
11
|
+
// The tags read, a page a query, the latest commits' first, as many pages as
|
|
12
|
+
// TAG_PAGES; of the commits they name, as many as RELEASES_READ have their
|
|
13
|
+
// files read.
|
|
14
|
+
const TAG_PAGE = 100
|
|
15
|
+
const TAG_PAGES = 5
|
|
16
|
+
const RELEASES_READ = 200
|
|
17
|
+
// An annotated tag's target, its own target in turn, through as many as
|
|
18
|
+
// TAG_DEPTH annotated tags.
|
|
19
|
+
const TAG_DEPTH = 3
|
|
20
|
+
const SHA = /^[a-f\d]{40}$/u
|
|
21
|
+
const RELEASE_LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'])
|
|
22
|
+
const RELEASE_CHANGELOG = /^(?:changelog|changes|history|news|releases?)(?:[._-][^/]*)?\.(?:md|markdown)$/iu
|
|
23
|
+
const CHANGESET = /(?:^|\/)\.changeset\/[^/]+\.md$/iu
|
|
24
|
+
|
|
25
|
+
const tagTarget = (depth: number): string => `target { __typename oid ... on Commit { committedDate }${depth > 0 ? ` ... on Tag { ${tagTarget(depth - 1)} }` : ''} }`
|
|
26
|
+
const TAGS_QUERY = `query($owner: String!, $name: String!, $after: String) {
|
|
27
|
+
repository(owner: $owner, name: $name) {
|
|
28
|
+
refs(refPrefix: "refs/tags/", first: ${TAG_PAGE}, after: $after, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {
|
|
29
|
+
pageInfo { hasNextPage endCursor }
|
|
30
|
+
nodes { ${tagTarget(TAG_DEPTH)} }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}`
|
|
34
|
+
type TagTarget = { __typename?: unknown; oid?: unknown; committedDate?: unknown; target?: TagTarget | null } | null | undefined
|
|
35
|
+
type TagsPage = { repository?: { refs?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: ({ target?: TagTarget } | null)[] } | null } | null }
|
|
36
|
+
|
|
37
|
+
// The commit a tag names, through its annotated tags, and when it was
|
|
38
|
+
// committed; null for one naming no commit (a tree, a blob).
|
|
39
|
+
function taggedCommit(target: TagTarget, depth = TAG_DEPTH): { oid: string; at: number } | null {
|
|
40
|
+
if (target?.__typename === 'Tag' && depth > 0) return taggedCommit(target.target, depth - 1)
|
|
41
|
+
const at = typeof target?.committedDate === 'string' ? Date.parse(target.committedDate) : Number.NaN
|
|
42
|
+
return target?.__typename === 'Commit' && typeof target.oid === 'string' && SHA.test(target.oid) && Number.isSafeInteger(at) ? { oid: target.oid, at } : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The commits the repository's tags name committed since `since`, the
|
|
46
|
+
// latest first, as many pages of them as TAG_PAGES.
|
|
47
|
+
export async function taggedCommits(fullName: string, token: string, since: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<Set<string>> {
|
|
48
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
49
|
+
const tagged = new Set<string>()
|
|
50
|
+
let after: string | null = null
|
|
51
|
+
for (let pages = 0; pages < TAG_PAGES; pages++) {
|
|
52
|
+
const page = await githubGraphql(TAGS_QUERY, { owner, name, after }, token, fetchImpl) as TagsPage | null
|
|
53
|
+
const refs = page?.repository?.refs
|
|
54
|
+
const commits = (refs?.nodes ?? []).flatMap(node => taggedCommit(node?.target) ?? [])
|
|
55
|
+
for (const commit of commits) if (commit.at >= since) tagged.add(commit.oid)
|
|
56
|
+
if (refs?.pageInfo?.hasNextPage !== true || typeof refs.pageInfo.endCursor !== 'string' || commits.some(commit => commit.at < since)) break
|
|
57
|
+
after = refs.pageInfo.endCursor
|
|
58
|
+
}
|
|
59
|
+
return tagged
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Whether a path is one a release changes: a package.json, a lockfile beside
|
|
63
|
+
// one, or a changelog in Markdown.
|
|
64
|
+
const releaseFile = (path: string) => {
|
|
65
|
+
const base = path.slice(path.lastIndexOf('/') + 1)
|
|
66
|
+
return base === 'package.json' || RELEASE_LOCKFILES.has(base) || RELEASE_CHANGELOG.test(base) || CHANGESET.test(path)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Whether what a commit changes is a release's: a package.json at least, and
|
|
70
|
+
// nothing a release changes none of, a rename's both paths alike; one
|
|
71
|
+
// GitHub listed only some files of, not.
|
|
72
|
+
export function releaseChanges({ files, more }: CommitChanges): boolean {
|
|
73
|
+
const paths = files.flatMap(([path, status, from]) => status === 'R' ? [path, from] : [path])
|
|
74
|
+
return !more && paths.some(path => path.slice(path.lastIndexOf('/') + 1) === 'package.json') && paths.every(releaseFile)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The commits since `since` that release the repository: those its tags
|
|
78
|
+
// name whose changes are a release's, each read as Replay reads it (`kept`),
|
|
79
|
+
// as many as RELEASES_READ, sorted.
|
|
80
|
+
export async function releaseCommits(fullName: string, token: string, since: number, kept: ChangesStore, fetchImpl: typeof fetch = globalThis.fetch): Promise<string[]> {
|
|
81
|
+
const tagged = [...await taggedCommits(fullName, token, since, fetchImpl)].slice(0, RELEASES_READ)
|
|
82
|
+
const changes = await repositoryChanges(fullName, token, tagged, kept, fetchImpl)
|
|
83
|
+
return tagged.filter(sha => { const read = changes.get(sha); return read ? releaseChanges(read) : false }).toSorted()
|
|
84
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// A repository's GitHub Actions in a risk scan (repository-risk.ts): its
|
|
2
|
+
// workflows and its own actions scanned (risk scanWorkflow), and the
|
|
3
|
+
// references they make resolved (risk resolveAction). Of a public
|
|
4
|
+
// repository, a reference resolved and what GitHub answers are kept for
|
|
5
|
+
// every scan, as many repositories use the same few actions.
|
|
6
|
+
import { type ActionsSource, type ResolvedAction, type ScanOptions, type WorkflowScan, dockerfileFindings, keptSource, ownFilesOf, resolveAction,
|
|
7
|
+
scanWorkflow, usesOfKey } from '../risk/index.js'
|
|
8
|
+
import type { OpenedRepository } from './repository-risk-read.ts'
|
|
9
|
+
import type { Lru } from './lru.ts'
|
|
10
|
+
import type { KeptStore } from './npm-kept.ts'
|
|
11
|
+
import { type Queue, queue } from './queue.ts'
|
|
12
|
+
import { RISK_KEPT_MS, type RiskFailure, failureOf } from './repository-risk-assess.ts'
|
|
13
|
+
|
|
14
|
+
export const WORKFLOWS_DIR = '.github/workflows'
|
|
15
|
+
// How many files of a repository's Actions are read, and how large.
|
|
16
|
+
const MAX_FILES = 200
|
|
17
|
+
const MAX_FILE_CHARS = 1024 * 1024
|
|
18
|
+
// How many references a scan resolves, how deep it follows what they name,
|
|
19
|
+
// and how many at once.
|
|
20
|
+
const MAX_ACTIONS = 1000
|
|
21
|
+
const MAX_DEPTH = 4
|
|
22
|
+
const RESOLVERS = 4
|
|
23
|
+
|
|
24
|
+
// A file of a repository's Actions: its text, or why it couldn't be had.
|
|
25
|
+
export type ActionsFile = { path: string; kind: 'workflow' | 'action' | 'dockerfile' } & ({ text: string } | RiskFailure)
|
|
26
|
+
export type WorkflowsRead = { files: ActionsFile[] } | RiskFailure
|
|
27
|
+
|
|
28
|
+
// What a scan tells of them: the workflows' paths, each file's scan, and
|
|
29
|
+
// each reference resolved, or why not.
|
|
30
|
+
export type WorkflowsLine = { type: 'workflows'; repo: number } & ({ paths: string[] } | RiskFailure)
|
|
31
|
+
export type WorkflowLine = { type: 'workflow'; repo: number; path: string; kind: ActionsFile['kind'] } & (WorkflowScan | RiskFailure)
|
|
32
|
+
export type ActionLine = { type: 'action' } & (ResolvedAction | { key: string } & RiskFailure)
|
|
33
|
+
|
|
34
|
+
// A reference to resolve, by the owner of the workflow naming it.
|
|
35
|
+
export interface ActionRef { key: string; owner: string | null; depth: number }
|
|
36
|
+
|
|
37
|
+
// What reading a repository's Actions asks of it.
|
|
38
|
+
export type ActionsReading = Required<Pick<OpenedRepository, 'workflows' | 'file'>>
|
|
39
|
+
|
|
40
|
+
// A file's text, or why not: one GitHub hasn't, or larger than read.
|
|
41
|
+
async function readFile(reading: ActionsReading, path: string): Promise<{ text: string } | RiskFailure> {
|
|
42
|
+
try {
|
|
43
|
+
const text = await reading.file(path)
|
|
44
|
+
return text.length > MAX_FILE_CHARS ? { error: 'workflow-too-large' } : { text }
|
|
45
|
+
} catch (error) { return failureOf(error) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Whether a read holds what any read of its commit would: no file that
|
|
49
|
+
// couldn't be had for a while, as GitHub's limits are.
|
|
50
|
+
export const lastingRead = (read: WorkflowsRead) => !('error' in read) && read.files.every(file => 'text' in file || file.error === 'workflow-too-large' || file.error === 'github-not-found')
|
|
51
|
+
|
|
52
|
+
// An action's directory, by its metadata's path.
|
|
53
|
+
const actionDir = (path: string) => path.replace(/\/?action\.ya?ml$/u, '')
|
|
54
|
+
const inRepository = (path: string) => !path.split('/').includes('..')
|
|
55
|
+
const notFound = (read: { text: string } | RiskFailure) => 'error' in read && read.error === 'github-not-found'
|
|
56
|
+
|
|
57
|
+
// A repository's workflows, the actions of its own they use in turn (`./`
|
|
58
|
+
// as the workspace has it, the repository checked out first) and those's
|
|
59
|
+
// Dockerfiles, MAX_FILES at most; why not, where they couldn't be listed.
|
|
60
|
+
export async function readActions(reading: ActionsReading): Promise<WorkflowsRead> {
|
|
61
|
+
let listed
|
|
62
|
+
try { listed = await reading.workflows() } catch (error) { return failureOf(error, 'listing its workflows') }
|
|
63
|
+
const files: ActionsFile[] = await Promise.all(listed.slice(0, MAX_FILES).map(async path => ({ path, kind: 'workflow' as const, ...await readFile(reading, path) })))
|
|
64
|
+
const seen = new Set<string>()
|
|
65
|
+
for (let at = 0; at < files.length; at++) {
|
|
66
|
+
const file = files[at]!
|
|
67
|
+
if (!('text' in file) || file.kind === 'dockerfile') continue
|
|
68
|
+
const { actions, dockerfile } = ownFilesOf(file.kind, file.text)
|
|
69
|
+
const path = dockerfile && [actionDir(file.path), dockerfile].filter(Boolean).join('/')
|
|
70
|
+
if (path && inRepository(path) && files.length < MAX_FILES) files.push({ path, kind: 'dockerfile', ...await readFile(reading, path) })
|
|
71
|
+
for (const dir of actions) {
|
|
72
|
+
if (files.length >= MAX_FILES || seen.has(dir) || !inRepository(dir) || !seen.add(dir)) continue
|
|
73
|
+
const named = dir === '' ? 'action' : `${dir}/action`, yml = await readFile(reading, `${named}.yml`)
|
|
74
|
+
const yaml = notFound(yml) ? await readFile(reading, `${named}.yaml`) : null
|
|
75
|
+
files.push(yaml && !notFound(yaml) ? { path: `${named}.yaml`, kind: 'action', ...yaml } : { path: `${named}.yml`, kind: 'action', ...yml })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { files }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A lockfile's packages, by name and by `name@version`, and those only
|
|
82
|
+
// devDependencies need.
|
|
83
|
+
export function lockedNames(path: string, packages: readonly { name: string; version: string | null; dev?: true }[]) {
|
|
84
|
+
const dev = new Map<string, boolean>()
|
|
85
|
+
for (const { name, version, dev: only } of packages) for (const key of version ? [name, `${name}@${version}`] : [name]) dev.set(key, (dev.get(key) ?? true) && only === true)
|
|
86
|
+
return { path, names: new Set(dev.keys()), dev: new Set([...dev].flatMap(([name, only]) => only ? [name] : [])) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The lines a repository's Actions read are told by, and the references
|
|
90
|
+
// they make to resolve. `lockfiles`, its lockfiles' packages' names by path.
|
|
91
|
+
export function actionsLines(repo: number, owner: string, read: WorkflowsRead, lockfiles: NonNullable<ScanOptions['lockfiles']>) {
|
|
92
|
+
if ('error' in read) return { lines: [{ type: 'workflows', repo, ...read } as WorkflowsLine], refs: [] as ActionRef[] }
|
|
93
|
+
const lines: (WorkflowsLine | WorkflowLine)[] = [{ type: 'workflows', repo, paths: read.files.filter(file => file.kind === 'workflow').map(file => file.path) }]
|
|
94
|
+
const refs = new Map<string, ActionRef>()
|
|
95
|
+
const actions = new Map(read.files.flatMap(file => file.kind === 'action' && 'text' in file ? [[actionDir(file.path), file.text] as const] : []))
|
|
96
|
+
for (const file of read.files) {
|
|
97
|
+
const scan = 'error' in file ? file : file.kind === 'dockerfile' ? { uses: [], findings: dockerfileFindings(file.text) } : scanWorkflow(file.kind, file.text, { owner, lockfiles, actions })
|
|
98
|
+
lines.push({ type: 'workflow', repo, path: file.path, kind: file.kind, ...scan })
|
|
99
|
+
if ('uses' in scan) for (const uses of scan.uses) if (uses.kind === 'action' || uses.kind === 'workflow') refs.set(uses.key!, { key: uses.key!, owner, depth: 0 })
|
|
100
|
+
}
|
|
101
|
+
return { lines, refs: [...refs.values()] }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type ActionAnswers = Lru<{ at: number; answer: unknown }>
|
|
105
|
+
// An answer's weight: an entry of a list, a kilobyte of a file.
|
|
106
|
+
export const answerWeight = ({ answer }: { at: number; answer: unknown }) => Array.isArray(answer) ? answer.length + 1
|
|
107
|
+
: typeof answer === 'string' ? Math.ceil(answer.length / 1024) + 1 : ((answer as { branches?: unknown[] } | null)?.branches?.length ?? 0) + 1
|
|
108
|
+
|
|
109
|
+
const isCommit = (text: string) => /^[\da-f]{40}$/u.test(text)
|
|
110
|
+
|
|
111
|
+
// `source`, what it answers of a public repository kept for every scan, the
|
|
112
|
+
// same whoever asks: of a commit while kept (but none, as one can be pushed
|
|
113
|
+
// later), the rest for RISK_KEPT_MS. A private one's, or one missing, never.
|
|
114
|
+
export function sharedSource(source: ActionsSource, answers: ActionAnswers): ActionsSource {
|
|
115
|
+
const keyOf = (what: string, name: string, ...more: string[]) => [what, name.toLowerCase(), ...more].join('\0')
|
|
116
|
+
const kept = (key: string, lasting = false) => {
|
|
117
|
+
const at = answers.get(key)
|
|
118
|
+
return at && ((lasting && at.answer !== null) || Date.now() - at.at < RISK_KEPT_MS) ? at : undefined
|
|
119
|
+
}
|
|
120
|
+
async function shared<T>(name: string, key: string, lasting: boolean, ask: () => Promise<T>): Promise<T> {
|
|
121
|
+
if (!kept(keyOf('repo', name))) return ask()
|
|
122
|
+
const at = kept(key, lasting)
|
|
123
|
+
if (at) return at.answer as T
|
|
124
|
+
const answer = await ask()
|
|
125
|
+
answers.set(key, { at: Date.now(), answer })
|
|
126
|
+
return answer
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
async repo(name) {
|
|
130
|
+
const at = kept(keyOf('repo', name))
|
|
131
|
+
if (at) return at.answer as Awaited<ReturnType<ActionsSource['repo']>>
|
|
132
|
+
const info = await source.repo(name)
|
|
133
|
+
if (info?.private === false) for (const each of [name, info.fullName]) answers.set(keyOf('repo', each), { at: Date.now(), answer: info })
|
|
134
|
+
return info
|
|
135
|
+
},
|
|
136
|
+
tags: name => shared(name, keyOf('tags', name), false, () => source.tags(name)),
|
|
137
|
+
tag: (name, tag) => shared(name, keyOf('tag', name, tag), false, () => source.tag(name, tag)),
|
|
138
|
+
branch: (name, branch) => shared(name, keyOf('branch', name, branch), false, () => source.branch(name, branch)),
|
|
139
|
+
branches: name => shared(name, keyOf('branches', name), false, () => source.branches(name)),
|
|
140
|
+
contains: (name, base, sha) => shared(name, keyOf('contains', name, base, sha), isCommit(base), () => source.contains(name, base, sha)),
|
|
141
|
+
file: (name, path, commit) => shared(name, keyOf('file', name, path, commit), isCommit(commit), () => source.file(name, path, commit)),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ActionsResolving {
|
|
146
|
+
source: ActionsSource
|
|
147
|
+
signal: AbortSignal
|
|
148
|
+
tell(line: ActionLine): void
|
|
149
|
+
memory: Lru<{ at: number; line: ActionLine }>
|
|
150
|
+
answers: ActionAnswers
|
|
151
|
+
store?: KeptStore<ActionLine> | undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A reference's line: kept, else resolved, and kept where it is a public
|
|
155
|
+
// repository's; why not, where it couldn't be.
|
|
156
|
+
async function resolved(resolving: ActionsResolving, source: ActionsSource, { key, owner }: ActionRef): Promise<ActionLine | null> {
|
|
157
|
+
const kept = resolving.memory.get(key) ?? await resolving.store?.read(key).then(row => row && { at: row.at, line: row.value })
|
|
158
|
+
if (kept && Date.now() - kept.at < RISK_KEPT_MS) { resolving.memory.set(key, kept); return kept.line }
|
|
159
|
+
const uses = usesOfKey(key)
|
|
160
|
+
if (!uses) return { type: 'action', key, error: 'invalid-uses' }
|
|
161
|
+
let found: ResolvedAction
|
|
162
|
+
try { found = await resolveAction(uses, source, { owner }) } catch (error) { return resolving.signal.aborted ? null : { type: 'action', key, ...failureOf(error) } }
|
|
163
|
+
const line: ActionLine = { type: 'action', ...found }
|
|
164
|
+
if (found.repository?.private === false) {
|
|
165
|
+
const at = Date.now()
|
|
166
|
+
resolving.memory.set(key, { at, line })
|
|
167
|
+
await resolving.store?.write(key, line, at)
|
|
168
|
+
}
|
|
169
|
+
return line
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Whether a reference is named by a workflow of its repository's owner.
|
|
173
|
+
const ownOwners = ({ key, owner }: ActionRef) => key.split('/')[0] === owner?.toLowerCase()
|
|
174
|
+
|
|
175
|
+
// Resolves the references added, and those they name in turn, each once a
|
|
176
|
+
// scan, RESOLVERS at once, each told as it is; once more where another
|
|
177
|
+
// owner's workflow names one its own owner's did, as a repository missing is
|
|
178
|
+
// red then, or one is met nearer, its own followed as far as they then go. `close` settles once all are told, or the scan stops.
|
|
179
|
+
export function resolvingActions(resolving: ActionsResolving) {
|
|
180
|
+
const source = keptSource(sharedSource(resolving.source, resolving.answers))
|
|
181
|
+
const pending: Queue<ActionRef> = queue(resolving.signal)
|
|
182
|
+
const latest = new Map<string, ActionRef>()
|
|
183
|
+
let closed = false, waiting = 0
|
|
184
|
+
const settle = () => { if (closed && waiting === 0) pending.close() }
|
|
185
|
+
const add = (refs: ActionRef[]) => {
|
|
186
|
+
for (const ref of refs) {
|
|
187
|
+
const first = latest.get(ref.key)
|
|
188
|
+
if (ref.depth > MAX_DEPTH || (first ? (!ownOwners(first) || ownOwners(ref)) && first.depth <= ref.depth : latest.size >= MAX_ACTIONS)) continue
|
|
189
|
+
const kept = first ? { ...ref, owner: ownOwners(first) ? ref.owner : first.owner, depth: Math.min(ref.depth, first.depth) } : ref
|
|
190
|
+
latest.set(ref.key, kept)
|
|
191
|
+
waiting++
|
|
192
|
+
pending.add(kept)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const work = async () => {
|
|
196
|
+
for (let ref = await pending.take(); ref !== undefined; ref = await pending.take()) {
|
|
197
|
+
try {
|
|
198
|
+
const line = await resolved(resolving, source, ref)
|
|
199
|
+
if (line && latest.get(ref.key) === ref) {
|
|
200
|
+
resolving.tell(line)
|
|
201
|
+
const owner = 'repository' in line ? (line.repository?.fullName ?? line.repo).split('/')[0]! : ref.owner
|
|
202
|
+
if ('uses' in line) add(line.uses.map(key => ({ key, owner, depth: ref.depth + 1 })))
|
|
203
|
+
}
|
|
204
|
+
} finally { waiting--; settle() }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const workers = Array.from({ length: RESOLVERS }, work)
|
|
208
|
+
return { add, async close() { closed = true; settle(); await Promise.all(workers) } }
|
|
209
|
+
}
|