@stacksjs/github 0.70.55 → 0.70.57
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/package.json +6 -5
- package/src/bots.ts +0 -34
- package/src/client.ts +0 -64
- package/src/dashboard.ts +0 -180
- package/src/failure-detector.ts +0 -194
- package/src/index.ts +0 -36
- package/src/repos.ts +0 -47
- package/src/run-history.ts +0 -194
- package/src/runner-pressure-detector.ts +0 -174
- package/src/runners.ts +0 -43
- package/src/runs.ts +0 -124
- package/src/types.ts +0 -71
- /package/dist/{src/bots.d.ts → bots.d.ts} +0 -0
- /package/dist/{src/client.d.ts → client.d.ts} +0 -0
- /package/dist/{src/dashboard.d.ts → dashboard.d.ts} +0 -0
- /package/dist/{src/failure-detector.d.ts → failure-detector.d.ts} +0 -0
- /package/dist/{src/index.d.ts → index.d.ts} +0 -0
- /package/dist/{src/repos.d.ts → repos.d.ts} +0 -0
- /package/dist/{src/run-history.d.ts → run-history.d.ts} +0 -0
- /package/dist/{src/runner-pressure-detector.d.ts → runner-pressure-detector.d.ts} +0 -0
- /package/dist/{src/runners.d.ts → runners.d.ts} +0 -0
- /package/dist/{src/runs.d.ts → runs.d.ts} +0 -0
- /package/dist/{src/types.d.ts → types.d.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/github",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.70.
|
|
4
|
+
"version": "0.70.57",
|
|
5
5
|
"description": "GitHub API client used by Stacks framework features (dashboard CI surface, notifications, runner alerts).",
|
|
6
6
|
"author": "Chris Breuer",
|
|
7
7
|
"contributors": [
|
|
@@ -28,16 +28,17 @@
|
|
|
28
28
|
],
|
|
29
29
|
"exports": {
|
|
30
30
|
".": {
|
|
31
|
-
"
|
|
32
|
-
"
|
|
31
|
+
"development": "./src/index.ts",
|
|
32
|
+
"bun": "./dist/index.js",
|
|
33
|
+
"import": "./dist/index.js",
|
|
34
|
+
"default": "./dist/index.js"
|
|
33
35
|
}
|
|
34
36
|
},
|
|
35
37
|
"module": "dist/index.js",
|
|
36
38
|
"types": "dist/index.d.ts",
|
|
37
39
|
"files": [
|
|
38
40
|
"README.md",
|
|
39
|
-
"dist"
|
|
40
|
-
"src"
|
|
41
|
+
"dist"
|
|
41
42
|
],
|
|
42
43
|
"scripts": {
|
|
43
44
|
"build": "bun build.ts",
|
package/src/bots.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { ghFetch, GITHUB_API } from './client'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Count open PRs authored by a GitHub App across every repo in an org.
|
|
5
|
-
* Returns a map keyed by `owner/repo` so callers can attribute counts back
|
|
6
|
-
* to the right repo card. Used to surface Renovate / GitHub Actions bot
|
|
7
|
-
* traffic in the dashboard.
|
|
8
|
-
*/
|
|
9
|
-
export async function fetchBotPRCounts(org: string, authorSlug: string): Promise<Map<string, number>> {
|
|
10
|
-
const counts = new Map<string, number>()
|
|
11
|
-
let page = 1
|
|
12
|
-
|
|
13
|
-
while (true) {
|
|
14
|
-
const q = `is:pr is:open org:${org} author:app/${authorSlug}`
|
|
15
|
-
const res = await ghFetch(`${GITHUB_API}/search/issues?q=${encodeURIComponent(q)}&per_page=100&page=${page}`)
|
|
16
|
-
if (!res.ok)
|
|
17
|
-
break
|
|
18
|
-
|
|
19
|
-
const data = await res.json() as { items: Array<{ repository_url: string }>, total_count: number }
|
|
20
|
-
if (!data.items || data.items.length === 0)
|
|
21
|
-
break
|
|
22
|
-
|
|
23
|
-
for (const item of data.items) {
|
|
24
|
-
const fullName = item.repository_url.replace(`${GITHUB_API}/repos/`, '')
|
|
25
|
-
counts.set(fullName, (counts.get(fullName) ?? 0) + 1)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (data.items.length < 100)
|
|
29
|
-
break
|
|
30
|
-
page++
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return counts
|
|
34
|
-
}
|
package/src/client.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
export const GITHUB_API = 'https://api.github.com'
|
|
2
|
-
|
|
3
|
-
function getToken(): string {
|
|
4
|
-
const token = process.env.GITHUB_TOKEN
|
|
5
|
-
if (!token)
|
|
6
|
-
throw new Error('GITHUB_TOKEN environment variable is required')
|
|
7
|
-
return token
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function ghHeaders(): Record<string, string> {
|
|
11
|
-
return {
|
|
12
|
-
'Authorization': `Bearer ${getToken()}`,
|
|
13
|
-
'Accept': 'application/vnd.github+json',
|
|
14
|
-
'X-GitHub-Api-Version': '2022-11-28',
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* `fetch` against the GitHub API that retries on secondary rate limits.
|
|
20
|
-
*
|
|
21
|
-
* GitHub signals back-off via either a `Retry-After` header (preferred) or
|
|
22
|
-
* an `x-ratelimit-reset` epoch second. The retry budget is bounded so a
|
|
23
|
-
* permanently rate-limited token doesn't hang callers indefinitely.
|
|
24
|
-
*/
|
|
25
|
-
export async function ghFetch(url: string, attempt = 0): Promise<Response> {
|
|
26
|
-
const res = await fetch(url, { headers: ghHeaders() })
|
|
27
|
-
if (res.ok || attempt >= 3)
|
|
28
|
-
return res
|
|
29
|
-
|
|
30
|
-
const isRateLimit
|
|
31
|
-
= res.status === 429
|
|
32
|
-
|| (res.status === 403 && (res.headers.get('x-ratelimit-remaining') === '0' || res.headers.get('retry-after')))
|
|
33
|
-
if (!isRateLimit)
|
|
34
|
-
return res
|
|
35
|
-
|
|
36
|
-
const retryAfterHeader = res.headers.get('retry-after')
|
|
37
|
-
const resetHeader = res.headers.get('x-ratelimit-reset')
|
|
38
|
-
let waitMs = 1000 * 2 ** attempt
|
|
39
|
-
if (retryAfterHeader)
|
|
40
|
-
waitMs = Number(retryAfterHeader) * 1000
|
|
41
|
-
else if (resetHeader)
|
|
42
|
-
waitMs = Math.max(0, Number(resetHeader) * 1000 - Date.now()) + 500
|
|
43
|
-
await new Promise(r => setTimeout(r, Math.min(waitMs, 30_000)))
|
|
44
|
-
return ghFetch(url, attempt + 1)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Run `fn` over `items` with at most `limit` concurrent invocations.
|
|
49
|
-
* Result indices match input indices.
|
|
50
|
-
*/
|
|
51
|
-
export async function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
52
|
-
const results: R[] = Array.from({ length: items.length })
|
|
53
|
-
let next = 0
|
|
54
|
-
async function worker(): Promise<void> {
|
|
55
|
-
while (true) {
|
|
56
|
-
const i = next++
|
|
57
|
-
if (i >= items.length)
|
|
58
|
-
return
|
|
59
|
-
results[i] = await fn(items[i])
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()))
|
|
63
|
-
return results
|
|
64
|
-
}
|
package/src/dashboard.ts
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import type { DashboardData, DashboardOptions, RepoStatus } from './types'
|
|
2
|
-
import { fetchBotPRCounts } from './bots'
|
|
3
|
-
import { mapWithConcurrency } from './client'
|
|
4
|
-
import { fetchAllRepos } from './repos'
|
|
5
|
-
import { fetchRepoActiveRuns } from './runners'
|
|
6
|
-
import { fetchRepoStatus } from './runs'
|
|
7
|
-
|
|
8
|
-
const DEFAULT_TTL_MS = 30 * 1000
|
|
9
|
-
const DEFAULT_CACHE_PATH = '.cache/dashboard.json'
|
|
10
|
-
const DEFAULT_RUNNER_CAP = 20
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* In-memory cache scoped per cache-path. Multiple callers hitting the same
|
|
14
|
-
* disk-backed cache file share state; callers using distinct paths (e.g.
|
|
15
|
-
* test isolation) stay independent.
|
|
16
|
-
*/
|
|
17
|
-
interface CacheEntry {
|
|
18
|
-
data: DashboardData | null
|
|
19
|
-
savedAt: number
|
|
20
|
-
inflight: Promise<DashboardData> | null
|
|
21
|
-
diskLoaded: boolean
|
|
22
|
-
}
|
|
23
|
-
const caches = new Map<string, CacheEntry>()
|
|
24
|
-
|
|
25
|
-
function entryFor(path: string): CacheEntry {
|
|
26
|
-
let e = caches.get(path)
|
|
27
|
-
if (!e) {
|
|
28
|
-
e = { data: null, savedAt: 0, inflight: null, diskLoaded: false }
|
|
29
|
-
caches.set(path, e)
|
|
30
|
-
}
|
|
31
|
-
return e
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function loadCacheFromDisk(entry: CacheEntry, path: string): Promise<void> {
|
|
35
|
-
if (entry.diskLoaded)
|
|
36
|
-
return
|
|
37
|
-
entry.diskLoaded = true
|
|
38
|
-
try {
|
|
39
|
-
const file = Bun.file(path)
|
|
40
|
-
if (!(await file.exists()))
|
|
41
|
-
return
|
|
42
|
-
const stored = await file.json() as { data: DashboardData, savedAt: number }
|
|
43
|
-
entry.data = stored.data
|
|
44
|
-
entry.savedAt = stored.savedAt
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
// corrupt or missing cache file is harmless — we just rebuild
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function saveCacheToDisk(path: string, data: DashboardData, savedAt: number): Promise<void> {
|
|
52
|
-
try {
|
|
53
|
-
await Bun.write(path, JSON.stringify({ data, savedAt }))
|
|
54
|
-
}
|
|
55
|
-
catch (err) {
|
|
56
|
-
console.warn('[github/dashboard] cache write failed:', err)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Build a fresh snapshot — fans out across orgs / repos / bot authors with
|
|
62
|
-
* bounded concurrency, then collates into the `DashboardData` shape the
|
|
63
|
-
* dashboard UI consumes.
|
|
64
|
-
*/
|
|
65
|
-
async function buildDashboardData(opts: DashboardOptions): Promise<DashboardData> {
|
|
66
|
-
const orgs = opts.orgs
|
|
67
|
-
const runnerCaps = opts.runnerCaps ?? {}
|
|
68
|
-
const defaultRunnerCap = opts.defaultRunnerCap ?? DEFAULT_RUNNER_CAP
|
|
69
|
-
|
|
70
|
-
const repos = await fetchAllRepos(orgs, opts.ignoreRepos)
|
|
71
|
-
const statuses = await mapWithConcurrency(repos, 8, r => fetchRepoStatus(r.owner, r.name, r.default_branch))
|
|
72
|
-
|
|
73
|
-
const prCountMaps = await Promise.all(
|
|
74
|
-
orgs.flatMap(org => [
|
|
75
|
-
fetchBotPRCounts(org, 'renovate').then(m => ({ type: 'renovate' as const, map: m })),
|
|
76
|
-
fetchBotPRCounts(org, 'github-actions').then(m => ({ type: 'actions' as const, map: m })),
|
|
77
|
-
]),
|
|
78
|
-
)
|
|
79
|
-
const renovateCounts = new Map<string, number>()
|
|
80
|
-
const actionsCounts = new Map<string, number>()
|
|
81
|
-
for (const { type, map } of prCountMaps) {
|
|
82
|
-
const target = type === 'renovate' ? renovateCounts : actionsCounts
|
|
83
|
-
for (const [k, v] of map) target.set(k, (target.get(k) ?? 0) + v)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
for (const s of statuses) {
|
|
87
|
-
const rCount = renovateCounts.get(s.fullName) ?? 0
|
|
88
|
-
const aCount = actionsCounts.get(s.fullName) ?? 0
|
|
89
|
-
s.renovatePRs = rCount
|
|
90
|
-
s.actionsPRs = aCount
|
|
91
|
-
if (rCount > 0)
|
|
92
|
-
s.renovatePRsUrl = `https://github.com/${s.fullName}/pulls?q=${encodeURIComponent('is:pr is:open author:app/renovate')}`
|
|
93
|
-
if (aCount > 0)
|
|
94
|
-
s.actionsPRsUrl = `https://github.com/${s.fullName}/pulls?q=${encodeURIComponent('is:pr is:open author:app/github-actions')}`
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const runnerCounts = await mapWithConcurrency(repos, 8, async r => ({
|
|
98
|
-
owner: r.owner,
|
|
99
|
-
...(await fetchRepoActiveRuns(r.owner, r.name)),
|
|
100
|
-
}))
|
|
101
|
-
const runners: Record<string, { running: number, queued: number, cap: number }> = {}
|
|
102
|
-
for (const org of orgs)
|
|
103
|
-
runners[org] = { running: 0, queued: 0, cap: runnerCaps[org] ?? defaultRunnerCap }
|
|
104
|
-
for (const c of runnerCounts) {
|
|
105
|
-
if (!runners[c.owner])
|
|
106
|
-
runners[c.owner] = { running: 0, queued: 0, cap: runnerCaps[c.owner] ?? defaultRunnerCap }
|
|
107
|
-
runners[c.owner].running += c.running
|
|
108
|
-
runners[c.owner].queued += c.queued
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// failures float to the top of the feed so the dashboard's eye-line lands
|
|
112
|
-
// on what's broken, not on the long tail of green.
|
|
113
|
-
const order: Record<string, number> = { failure: 0, error: 1, pending: 2, success: 3, no_runs: 4 }
|
|
114
|
-
statuses.sort((a: RepoStatus, b: RepoStatus) => (order[a.status] ?? 5) - (order[b.status] ?? 5))
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
repos: statuses,
|
|
118
|
-
fetchedAt: new Date().toISOString(),
|
|
119
|
-
total: statuses.length,
|
|
120
|
-
passing: statuses.filter(r => r.status === 'success').length,
|
|
121
|
-
failing: statuses.filter(r => r.status === 'failure' || r.status === 'error').length,
|
|
122
|
-
pending: statuses.filter(r => r.status === 'pending').length,
|
|
123
|
-
noRuns: statuses.filter(r => r.status === 'no_runs').length,
|
|
124
|
-
runners,
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Aggregated CI/runner snapshot across the configured orgs.
|
|
130
|
-
*
|
|
131
|
-
* Stale-while-revalidate: if a cached snapshot exists, it is returned
|
|
132
|
-
* immediately and a background refresh kicks off once the TTL has elapsed.
|
|
133
|
-
* First-ever call (no in-memory and no on-disk cache) waits for the build
|
|
134
|
-
* so the dashboard renders against real data instead of `null`.
|
|
135
|
-
*/
|
|
136
|
-
export async function getDashboardData(opts: DashboardOptions): Promise<DashboardData> {
|
|
137
|
-
const ttl = opts.cacheTtlMs ?? DEFAULT_TTL_MS
|
|
138
|
-
const path = opts.cachePath ?? DEFAULT_CACHE_PATH
|
|
139
|
-
const entry = entryFor(path)
|
|
140
|
-
|
|
141
|
-
await loadCacheFromDisk(entry, path)
|
|
142
|
-
const now = Date.now()
|
|
143
|
-
|
|
144
|
-
if (entry.data) {
|
|
145
|
-
if (now - entry.savedAt >= ttl && !entry.inflight) {
|
|
146
|
-
entry.inflight = buildDashboardData(opts)
|
|
147
|
-
.then(async (data) => {
|
|
148
|
-
entry.data = data
|
|
149
|
-
entry.savedAt = Date.now()
|
|
150
|
-
await saveCacheToDisk(path, data, entry.savedAt)
|
|
151
|
-
return data
|
|
152
|
-
})
|
|
153
|
-
.finally(() => { entry.inflight = null })
|
|
154
|
-
entry.inflight.catch(err => console.warn('[github/dashboard] refresh failed:', err))
|
|
155
|
-
}
|
|
156
|
-
return entry.data
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (!entry.inflight) {
|
|
160
|
-
entry.inflight = buildDashboardData(opts)
|
|
161
|
-
.then(async (data) => {
|
|
162
|
-
entry.data = data
|
|
163
|
-
entry.savedAt = Date.now()
|
|
164
|
-
await saveCacheToDisk(path, data, entry.savedAt)
|
|
165
|
-
return data
|
|
166
|
-
})
|
|
167
|
-
.finally(() => { entry.inflight = null })
|
|
168
|
-
}
|
|
169
|
-
return entry.inflight
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Drop the in-memory cache for a given cache path. Disk cache is left
|
|
174
|
-
* alone — callers that want a true reset should `Bun.write` an empty file
|
|
175
|
-
* or delete it before re-fetching. Used by tests and by an eventual
|
|
176
|
-
* "Refresh now" UI action.
|
|
177
|
-
*/
|
|
178
|
-
export function clearDashboardCache(cachePath = DEFAULT_CACHE_PATH): void {
|
|
179
|
-
caches.delete(cachePath)
|
|
180
|
-
}
|
package/src/failure-detector.ts
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
import type { RepoStatus } from './types'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Pure failure-transition detector (stacksjs/stacks#1849).
|
|
5
|
-
*
|
|
6
|
-
* Given a fresh CI snapshot and the previous per-repo states the
|
|
7
|
-
* dashboard knows about, return the list of repos that *just*
|
|
8
|
-
* transitioned to a failed conclusion — the moment worth firing a
|
|
9
|
-
* notification on. Anything still failing, or newly passing, or
|
|
10
|
-
* still in flight, is silenced.
|
|
11
|
-
*
|
|
12
|
-
* Pure for a reason: easy to unit-test the transition matrix without
|
|
13
|
-
* a DB, a notify implementation, or the network. The persistence
|
|
14
|
-
* layer + notification fan-out live in defaults so framework
|
|
15
|
-
* packages stay infrastructure-free.
|
|
16
|
-
*
|
|
17
|
-
* @see PreviousRunState what the persistence layer hands in
|
|
18
|
-
* @see FailedTransition what we return — caller decides how to notify
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/** What a caller stored about a repo on its last snapshot pass. */
|
|
22
|
-
export interface PreviousRunState {
|
|
23
|
-
/** GH "fullName" — `owner/repo`. The composite key used in storage. */
|
|
24
|
-
repoFullName: string
|
|
25
|
-
/** Last conclusion the dashboard saw. `null` when the repo was in
|
|
26
|
-
* flight (status pending) or had no runs yet. */
|
|
27
|
-
lastConclusion: string | null
|
|
28
|
-
/** GH workflow run id of the last seen run. Used to silence noise
|
|
29
|
-
* when the same failed run shows up across multiple polls — we
|
|
30
|
-
* only want to fire on a NEW run that transitioned. */
|
|
31
|
-
lastRunId: number | null
|
|
32
|
-
/** Last notification dispatch timestamp (ISO). Drives the cooldown
|
|
33
|
-
* window for flap-storms. `null` means "never fired". */
|
|
34
|
-
lastNotifiedAt: string | null
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Per-transition payload the detector hands back to the caller. */
|
|
38
|
-
export interface FailedTransition {
|
|
39
|
-
repoFullName: string
|
|
40
|
-
/** `failure`, `error`, `timed_out` — kept verbatim from snapshot. */
|
|
41
|
-
conclusion: string
|
|
42
|
-
/** Run id that introduced the failure. Stored back as `lastRunId`. */
|
|
43
|
-
runId: number | null
|
|
44
|
-
/** Pass-through metadata the notification template usually wants. */
|
|
45
|
-
workflowName: string | null
|
|
46
|
-
commitSha: string | null
|
|
47
|
-
commitMessage: string | null
|
|
48
|
-
commitAuthor: string | null
|
|
49
|
-
runUrl: string | null
|
|
50
|
-
/** For comparison + de-duping: the previous conclusion the detector
|
|
51
|
-
* saw before deciding this counts as a transition. */
|
|
52
|
-
previousConclusion: string | null
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface DetectOptions {
|
|
56
|
-
/**
|
|
57
|
-
* Minimum delay between consecutive notifications for the same
|
|
58
|
-
* repo. Defaults to 5 minutes. A red → green → red flap inside this
|
|
59
|
-
* window only fires the first transition; the second is silenced.
|
|
60
|
-
*
|
|
61
|
-
* Set to 0 to disable cooldown entirely.
|
|
62
|
-
*/
|
|
63
|
-
cooldownMs?: number
|
|
64
|
-
/**
|
|
65
|
-
* Reference timestamp. Defaults to `Date.now()`. Injectable so the
|
|
66
|
-
* test suite can drive the clock and assert cooldown semantics
|
|
67
|
-
* deterministically.
|
|
68
|
-
*/
|
|
69
|
-
now?: number
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000
|
|
73
|
-
|
|
74
|
-
const FAILED_CONCLUSIONS = new Set<string>([
|
|
75
|
-
'failure',
|
|
76
|
-
'error',
|
|
77
|
-
'timed_out',
|
|
78
|
-
'startup_failure',
|
|
79
|
-
])
|
|
80
|
-
|
|
81
|
-
function isFailed(repo: { status: string, conclusion: string | null }): boolean {
|
|
82
|
-
// `RepoStatus.status` covers our internal bucket (success / failure /
|
|
83
|
-
// pending / error / no_runs). `failure` and `error` both count;
|
|
84
|
-
// `conclusion` adds GH's finer-grained terminology (timed_out,
|
|
85
|
-
// startup_failure) for the cases that surfaced as `error` upstream.
|
|
86
|
-
if (repo.status === 'failure' || repo.status === 'error')
|
|
87
|
-
return true
|
|
88
|
-
if (repo.conclusion && FAILED_CONCLUSIONS.has(repo.conclusion))
|
|
89
|
-
return true
|
|
90
|
-
return false
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function isInFlight(repo: { status: string }): boolean {
|
|
94
|
-
return repo.status === 'pending'
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Compute the failed-transition list.
|
|
99
|
-
*
|
|
100
|
-
* The "transition" is gated on:
|
|
101
|
-
*
|
|
102
|
-
* 1. The repo is currently in a failed state.
|
|
103
|
-
* 2. The repo is NOT in flight (pending). Pending → failure happens
|
|
104
|
-
* mid-run; firing on it would just produce noise once the run
|
|
105
|
-
* finishes and the same failure resurfaces with a final
|
|
106
|
-
* conclusion.
|
|
107
|
-
* 3. The previous state was *not* failed. (Sticky-red repos don't
|
|
108
|
-
* keep firing.)
|
|
109
|
-
* 4. The run id changed — if the same failed run id is still
|
|
110
|
-
* surfacing, this is a duplicate poll, not a new failure.
|
|
111
|
-
* 5. Cooldown: `lastNotifiedAt` is either null or older than
|
|
112
|
-
* `cooldownMs` ago.
|
|
113
|
-
*/
|
|
114
|
-
export function detectNewlyFailedRuns(
|
|
115
|
-
snapshot: { repos: RepoStatus[] },
|
|
116
|
-
previousStates: Map<string, PreviousRunState>,
|
|
117
|
-
options: DetectOptions = {},
|
|
118
|
-
): FailedTransition[] {
|
|
119
|
-
const cooldownMs = options.cooldownMs ?? DEFAULT_COOLDOWN_MS
|
|
120
|
-
const now = options.now ?? Date.now()
|
|
121
|
-
const transitions: FailedTransition[] = []
|
|
122
|
-
|
|
123
|
-
for (const repo of snapshot.repos) {
|
|
124
|
-
if (!isFailed(repo) || isInFlight(repo))
|
|
125
|
-
continue
|
|
126
|
-
|
|
127
|
-
const prev = previousStates.get(repo.fullName)
|
|
128
|
-
const prevWasFailed = prev?.lastConclusion
|
|
129
|
-
? FAILED_CONCLUSIONS.has(prev.lastConclusion) || prev.lastConclusion === 'failure' || prev.lastConclusion === 'error'
|
|
130
|
-
: false
|
|
131
|
-
if (prevWasFailed) {
|
|
132
|
-
// The repo is still red. Only count this as a transition if
|
|
133
|
-
// the run id has changed — i.e. a new failed run, not the same
|
|
134
|
-
// one surfacing again.
|
|
135
|
-
const currentRunId = parseRunIdFromUrl(repo.runUrl)
|
|
136
|
-
if (currentRunId !== null && prev?.lastRunId !== null && currentRunId === prev?.lastRunId)
|
|
137
|
-
continue
|
|
138
|
-
// Different run id while still failing — counts as a new
|
|
139
|
-
// transition (a fresh failed build after the previous one).
|
|
140
|
-
// BUT silence it if we already notified within the cooldown.
|
|
141
|
-
if (prev?.lastNotifiedAt && isWithinCooldown(prev.lastNotifiedAt, cooldownMs, now))
|
|
142
|
-
continue
|
|
143
|
-
transitions.push(toTransition(repo, prev?.lastConclusion ?? null))
|
|
144
|
-
continue
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Previously green / pending / no_runs / never-seen → now failed.
|
|
148
|
-
// The transition we care about. Still honour the cooldown — covers
|
|
149
|
-
// the "green → red → green → red" flap-storm case where the
|
|
150
|
-
// intermediate green resets `prevWasFailed` but the user still
|
|
151
|
-
// doesn't want to be paged twice in 30s.
|
|
152
|
-
if (prev?.lastNotifiedAt && isWithinCooldown(prev.lastNotifiedAt, cooldownMs, now))
|
|
153
|
-
continue
|
|
154
|
-
|
|
155
|
-
transitions.push(toTransition(repo, prev?.lastConclusion ?? null))
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return transitions
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function isWithinCooldown(lastNotifiedAt: string, cooldownMs: number, now: number): boolean {
|
|
162
|
-
if (cooldownMs === 0) return false
|
|
163
|
-
const ts = Date.parse(lastNotifiedAt)
|
|
164
|
-
if (Number.isNaN(ts)) return false
|
|
165
|
-
return (now - ts) < cooldownMs
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Extract the run id from a GH run URL like
|
|
170
|
-
* `https://github.com/owner/repo/actions/runs/12345678`. Returns null
|
|
171
|
-
* if the URL doesn't match — we don't blow up the detector on
|
|
172
|
-
* unfamiliar URL shapes; we just fall through to "treat as a new run".
|
|
173
|
-
*/
|
|
174
|
-
function parseRunIdFromUrl(runUrl: string | null): number | null {
|
|
175
|
-
if (!runUrl) return null
|
|
176
|
-
const match = runUrl.match(/\/actions\/runs\/(\d+)/)
|
|
177
|
-
if (!match) return null
|
|
178
|
-
const id = Number(match[1])
|
|
179
|
-
return Number.isFinite(id) ? id : null
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function toTransition(repo: RepoStatus, previousConclusion: string | null): FailedTransition {
|
|
183
|
-
return {
|
|
184
|
-
repoFullName: repo.fullName,
|
|
185
|
-
conclusion: repo.conclusion ?? repo.status,
|
|
186
|
-
runId: parseRunIdFromUrl(repo.runUrl),
|
|
187
|
-
workflowName: repo.workflowName,
|
|
188
|
-
commitSha: repo.commitSha,
|
|
189
|
-
commitMessage: repo.commitMessage,
|
|
190
|
-
commitAuthor: repo.commitAuthor,
|
|
191
|
-
runUrl: repo.runUrl,
|
|
192
|
-
previousConclusion,
|
|
193
|
-
}
|
|
194
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @stacksjs/github — thin GitHub API client used by Stacks framework
|
|
3
|
-
* features (dashboard CI surface, future runner alerts, kanban→PR links).
|
|
4
|
-
*
|
|
5
|
-
* Top-level entry: {@link getDashboardData} returns the aggregated snapshot
|
|
6
|
-
* the dashboard renders. Lower-level helpers (`fetchAllRepos`,
|
|
7
|
-
* `fetchRepoStatus`, …) are also exported so other surfaces can pull a
|
|
8
|
-
* single dimension without pulling the whole snapshot through.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export { fetchBotPRCounts } from './bots'
|
|
12
|
-
export { ghFetch, ghHeaders, GITHUB_API, mapWithConcurrency } from './client'
|
|
13
|
-
export { clearDashboardCache, getDashboardData } from './dashboard'
|
|
14
|
-
export { detectNewlyFailedRuns } from './failure-detector'
|
|
15
|
-
export type { DetectOptions, FailedTransition, PreviousRunState } from './failure-detector'
|
|
16
|
-
export { fetchAllRepos } from './repos'
|
|
17
|
-
export { fetchRepoActiveRuns } from './runners'
|
|
18
|
-
export { fetchRunJobs, fetchWorkflowRuns } from './run-history'
|
|
19
|
-
export type { WorkflowJob, WorkflowRun } from './run-history'
|
|
20
|
-
export { detectRunnerPressure } from './runner-pressure-detector'
|
|
21
|
-
export type {
|
|
22
|
-
DetectOptions as PressureDetectOptions,
|
|
23
|
-
PressureAction,
|
|
24
|
-
RunnerAlertState,
|
|
25
|
-
RunnerSample,
|
|
26
|
-
} from './runner-pressure-detector'
|
|
27
|
-
export { fetchFailedJobs, fetchRepoStatus } from './runs'
|
|
28
|
-
export type {
|
|
29
|
-
DashboardData,
|
|
30
|
-
DashboardOptions,
|
|
31
|
-
FailedJob,
|
|
32
|
-
OrgRunnerUsage,
|
|
33
|
-
Repo,
|
|
34
|
-
RepoStatus,
|
|
35
|
-
RepoStatusKind,
|
|
36
|
-
} from './types'
|
package/src/repos.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import type { Repo } from './types'
|
|
2
|
-
import { ghFetch, GITHUB_API } from './client'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Fetch all non-archived public repos across `orgs`, filtering out any names
|
|
6
|
-
* in `ignore` (defaults to `.github` which exists in every org and never has
|
|
7
|
-
* project CI on it).
|
|
8
|
-
*/
|
|
9
|
-
export async function fetchAllRepos(orgs: string[], ignore: string[] = ['.github']): Promise<Repo[]> {
|
|
10
|
-
const ignored = new Set(ignore)
|
|
11
|
-
const all: Repo[] = []
|
|
12
|
-
|
|
13
|
-
for (const org of orgs) {
|
|
14
|
-
let page = 1
|
|
15
|
-
while (true) {
|
|
16
|
-
const res = await ghFetch(`${GITHUB_API}/orgs/${org}/repos?per_page=100&page=${page}&type=public`)
|
|
17
|
-
if (!res.ok)
|
|
18
|
-
break
|
|
19
|
-
|
|
20
|
-
const repos = await res.json() as Array<{
|
|
21
|
-
name: string
|
|
22
|
-
owner: { login: string }
|
|
23
|
-
full_name: string
|
|
24
|
-
html_url: string
|
|
25
|
-
default_branch: string
|
|
26
|
-
archived: boolean
|
|
27
|
-
}>
|
|
28
|
-
if (repos.length === 0)
|
|
29
|
-
break
|
|
30
|
-
|
|
31
|
-
for (const repo of repos) {
|
|
32
|
-
all.push({
|
|
33
|
-
name: repo.name,
|
|
34
|
-
owner: repo.owner.login,
|
|
35
|
-
full_name: repo.full_name,
|
|
36
|
-
html_url: repo.html_url,
|
|
37
|
-
default_branch: repo.default_branch,
|
|
38
|
-
archived: repo.archived,
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
page++
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return all.filter(r => !r.archived && !ignored.has(r.name))
|
|
47
|
-
}
|
package/src/run-history.ts
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
import { ghFetch, GITHUB_API } from './client'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Recent-workflow-runs + per-run job detail for the dashboard CI
|
|
5
|
-
* drilldown (stacksjs/stacks#1848).
|
|
6
|
-
*
|
|
7
|
-
* Distinct from `runs.ts` which is the *aggregator* — it folds the
|
|
8
|
-
* latest run per repo into a `RepoStatus` for the at-a-glance card.
|
|
9
|
-
* Here we want history: the last N runs for a single repo, plus the
|
|
10
|
-
* per-job detail for a single run. Different shapes, different
|
|
11
|
-
* callers, easier to keep them in separate files than to overload
|
|
12
|
-
* one module.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export interface WorkflowRun {
|
|
16
|
-
id: number
|
|
17
|
-
/** GH's `status` field: queued / in_progress / completed. */
|
|
18
|
-
status: 'queued' | 'in_progress' | 'completed' | string
|
|
19
|
-
/** GH's `conclusion` field: success / failure / cancelled / null
|
|
20
|
-
* (null while in flight). */
|
|
21
|
-
conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | 'timed_out' | 'action_required' | 'neutral' | 'startup_failure' | null
|
|
22
|
-
/** Workflow display name. */
|
|
23
|
-
name: string
|
|
24
|
-
/** Branch / ref the run was triggered on. */
|
|
25
|
-
headBranch: string | null
|
|
26
|
-
headSha: string
|
|
27
|
-
headShaShort: string
|
|
28
|
-
commitMessage: string | null
|
|
29
|
-
commitAuthor: string | null
|
|
30
|
-
/** `push`, `pull_request`, `schedule`, … */
|
|
31
|
-
event: string
|
|
32
|
-
url: string
|
|
33
|
-
startedAt: string | null
|
|
34
|
-
updatedAt: string
|
|
35
|
-
/** Compute on the way out so the page can show "12m" without
|
|
36
|
-
* rolling its own diff. */
|
|
37
|
-
durationMs: number | null
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface WorkflowJob {
|
|
41
|
-
id: number
|
|
42
|
-
name: string
|
|
43
|
-
status: 'queued' | 'in_progress' | 'completed' | string
|
|
44
|
-
conclusion: WorkflowRun['conclusion']
|
|
45
|
-
startedAt: string | null
|
|
46
|
-
completedAt: string | null
|
|
47
|
-
durationMs: number | null
|
|
48
|
-
/** Direct link to GH's log viewer for this job. */
|
|
49
|
-
url: string
|
|
50
|
-
/** Step-level breakdown. UI usually only renders failed/cancelled
|
|
51
|
-
* steps but the full list is here for completeness. */
|
|
52
|
-
steps: Array<{
|
|
53
|
-
name: string
|
|
54
|
-
status: WorkflowJob['status']
|
|
55
|
-
conclusion: WorkflowRun['conclusion']
|
|
56
|
-
number: number
|
|
57
|
-
}>
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface FetchRunsOptions {
|
|
61
|
-
/** Max runs to return. Defaults to 20. Hard-capped at 100 — anything
|
|
62
|
-
* larger should paginate (separate workstream). */
|
|
63
|
-
limit?: number
|
|
64
|
-
/** Filter to a specific branch. Defaults to no filter (every run on
|
|
65
|
-
* the default branch shows). */
|
|
66
|
-
branch?: string
|
|
67
|
-
/** Filter to a specific event type (`push`, `pull_request`, …). */
|
|
68
|
-
event?: string
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function shortSha(sha: string): string {
|
|
72
|
-
return sha.length > 7 ? sha.slice(0, 7) : sha
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function diffMs(start: string | null | undefined, end: string | null | undefined): number | null {
|
|
76
|
-
if (!start || !end) return null
|
|
77
|
-
const s = Date.parse(start)
|
|
78
|
-
const e = Date.parse(end)
|
|
79
|
-
if (Number.isNaN(s) || Number.isNaN(e)) return null
|
|
80
|
-
if (e < s) return null
|
|
81
|
-
return e - s
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* The N most recent workflow runs for a repo. Each entry is a
|
|
86
|
-
* lightweight summary — failed-job detail is fetched on-demand via
|
|
87
|
-
* {@link fetchRunJobs} so the drawer only pays the round-trip cost
|
|
88
|
-
* when the user actually expands a row.
|
|
89
|
-
*/
|
|
90
|
-
export async function fetchWorkflowRuns(
|
|
91
|
-
owner: string,
|
|
92
|
-
name: string,
|
|
93
|
-
options: FetchRunsOptions = {},
|
|
94
|
-
): Promise<WorkflowRun[]> {
|
|
95
|
-
const limit = Math.max(1, Math.min(options.limit ?? 20, 100))
|
|
96
|
-
const params = new URLSearchParams()
|
|
97
|
-
params.set('per_page', String(limit))
|
|
98
|
-
if (options.branch) params.set('branch', options.branch)
|
|
99
|
-
if (options.event) params.set('event', options.event)
|
|
100
|
-
|
|
101
|
-
const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs?${params.toString()}`)
|
|
102
|
-
if (!res.ok) return []
|
|
103
|
-
|
|
104
|
-
const data = await res.json() as {
|
|
105
|
-
workflow_runs?: Array<{
|
|
106
|
-
id: number
|
|
107
|
-
status: string
|
|
108
|
-
conclusion: string | null
|
|
109
|
-
name: string
|
|
110
|
-
head_branch: string | null
|
|
111
|
-
head_sha: string
|
|
112
|
-
head_commit: { message: string, author: { name: string } | null } | null
|
|
113
|
-
event: string
|
|
114
|
-
html_url: string
|
|
115
|
-
actor: { login: string } | null
|
|
116
|
-
run_started_at: string | null
|
|
117
|
-
created_at: string
|
|
118
|
-
updated_at: string
|
|
119
|
-
}>
|
|
120
|
-
}
|
|
121
|
-
const runs = data.workflow_runs ?? []
|
|
122
|
-
|
|
123
|
-
return runs.map((r): WorkflowRun => ({
|
|
124
|
-
id: r.id,
|
|
125
|
-
status: r.status,
|
|
126
|
-
conclusion: r.conclusion as WorkflowRun['conclusion'],
|
|
127
|
-
name: r.name,
|
|
128
|
-
headBranch: r.head_branch,
|
|
129
|
-
headSha: r.head_sha,
|
|
130
|
-
headShaShort: shortSha(r.head_sha),
|
|
131
|
-
commitMessage: r.head_commit?.message?.split('\n')[0] ?? null,
|
|
132
|
-
commitAuthor: r.head_commit?.author?.name ?? r.actor?.login ?? null,
|
|
133
|
-
event: r.event,
|
|
134
|
-
url: r.html_url,
|
|
135
|
-
startedAt: r.run_started_at ?? r.created_at ?? null,
|
|
136
|
-
updatedAt: r.updated_at,
|
|
137
|
-
durationMs: diffMs(r.run_started_at ?? r.created_at, r.updated_at),
|
|
138
|
-
}))
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Per-job detail for a single run. Used by the drilldown drawer when
|
|
143
|
-
* the user expands a failing run to see *which* step broke. Different
|
|
144
|
-
* shape from `fetchFailedJobs` in `runs.ts` (which is at-a-glance
|
|
145
|
-
* "failed job names" for the card) — here we want everything,
|
|
146
|
-
* including step-level breakdown + timing.
|
|
147
|
-
*
|
|
148
|
-
* Returns an empty array on error so the drawer can render an empty
|
|
149
|
-
* state rather than a 500.
|
|
150
|
-
*/
|
|
151
|
-
export async function fetchRunJobs(
|
|
152
|
-
owner: string,
|
|
153
|
-
name: string,
|
|
154
|
-
runId: number,
|
|
155
|
-
): Promise<WorkflowJob[]> {
|
|
156
|
-
const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs/${runId}/jobs?per_page=100`)
|
|
157
|
-
if (!res.ok) return []
|
|
158
|
-
|
|
159
|
-
const data = await res.json() as {
|
|
160
|
-
jobs?: Array<{
|
|
161
|
-
id: number
|
|
162
|
-
name: string
|
|
163
|
-
status: string
|
|
164
|
-
conclusion: string | null
|
|
165
|
-
started_at: string | null
|
|
166
|
-
completed_at: string | null
|
|
167
|
-
html_url: string
|
|
168
|
-
steps?: Array<{
|
|
169
|
-
name: string
|
|
170
|
-
status: string
|
|
171
|
-
conclusion: string | null
|
|
172
|
-
number: number
|
|
173
|
-
}>
|
|
174
|
-
}>
|
|
175
|
-
}
|
|
176
|
-
const jobs = data.jobs ?? []
|
|
177
|
-
|
|
178
|
-
return jobs.map((j): WorkflowJob => ({
|
|
179
|
-
id: j.id,
|
|
180
|
-
name: j.name,
|
|
181
|
-
status: j.status,
|
|
182
|
-
conclusion: j.conclusion as WorkflowJob['conclusion'],
|
|
183
|
-
startedAt: j.started_at,
|
|
184
|
-
completedAt: j.completed_at,
|
|
185
|
-
durationMs: diffMs(j.started_at, j.completed_at),
|
|
186
|
-
url: j.html_url,
|
|
187
|
-
steps: (j.steps ?? []).map(s => ({
|
|
188
|
-
name: s.name,
|
|
189
|
-
status: s.status,
|
|
190
|
-
conclusion: s.conclusion as WorkflowJob['conclusion'],
|
|
191
|
-
number: s.number,
|
|
192
|
-
})),
|
|
193
|
-
}))
|
|
194
|
-
}
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure runner-pressure detector (stacksjs/stacks#1850).
|
|
3
|
-
*
|
|
4
|
-
* Given a time-series of per-org runner samples + the dashboard's
|
|
5
|
-
* memory of which orgs are currently "alerting", return the
|
|
6
|
-
* actions to take: fire a fresh alert, clear an existing one, or do
|
|
7
|
-
* nothing.
|
|
8
|
-
*
|
|
9
|
-
* **Hysteresis** matters here. Once an org has been alerted on
|
|
10
|
-
* (queue > threshold sustained for one window), we DON'T re-fire
|
|
11
|
-
* just because the queue spikes a second time within the same hour.
|
|
12
|
-
* The org has to first *clear* — drop below threshold for a full
|
|
13
|
-
* window — before another fire becomes possible. This is the
|
|
14
|
-
* difference between "useful CI pressure paging" and "every dashboard
|
|
15
|
-
* load spams Slack".
|
|
16
|
-
*
|
|
17
|
-
* Pure for the same reason as `failure-detector.ts`: easy to unit-
|
|
18
|
-
* test the hysteresis transitions, the persistence + fan-out live
|
|
19
|
-
* in defaults.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/** One time-series row per org per snapshot refresh. */
|
|
23
|
-
export interface RunnerSample {
|
|
24
|
-
org: string
|
|
25
|
-
running: number
|
|
26
|
-
queued: number
|
|
27
|
-
cap: number
|
|
28
|
-
/** ISO timestamp the sample was taken. Driven by the caller — we
|
|
29
|
-
* don't `Date.now()` in this module so tests can drive the clock. */
|
|
30
|
-
sampledAt: string
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Dashboard's last-known per-org alert state. */
|
|
34
|
-
export interface RunnerAlertState {
|
|
35
|
-
org: string
|
|
36
|
-
alerting: boolean
|
|
37
|
-
/** ISO timestamp when this org most recently transitioned to
|
|
38
|
-
* alerting. Null if it has never alerted. */
|
|
39
|
-
lastAlertedAt: string | null
|
|
40
|
-
/** ISO timestamp when this org most recently transitioned from
|
|
41
|
-
* alerting → cleared. Null if it has never cleared (e.g. has
|
|
42
|
-
* never been in an alerting state to begin with). */
|
|
43
|
-
lastClearedAt: string | null
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Action the caller should take for one org. */
|
|
47
|
-
export interface PressureAction {
|
|
48
|
-
org: string
|
|
49
|
-
/**
|
|
50
|
-
* - `fire`: org just crossed sustained-pressure threshold for one
|
|
51
|
-
* full window AND wasn't already alerting. Caller should
|
|
52
|
-
* notify + set `alerting = true`.
|
|
53
|
-
* - `clear`: org has been below threshold for one full window AND
|
|
54
|
-
* was previously alerting. Caller should set `alerting = false`
|
|
55
|
-
* (no notification — clearing alone is silent; users only want
|
|
56
|
-
* the inbound page, not the "everything is fine again" email).
|
|
57
|
-
* - `none` is not returned; the detector simply omits orgs in
|
|
58
|
-
* no-change states.
|
|
59
|
-
*/
|
|
60
|
-
action: 'fire' | 'clear'
|
|
61
|
-
/** Latest sample (so the fan-out template can show "12 queued of
|
|
62
|
-
* 20 cap"). */
|
|
63
|
-
current: RunnerSample
|
|
64
|
-
/**
|
|
65
|
-
* How long the threshold has been sustained, in milliseconds.
|
|
66
|
-
* Computed from the oldest sample in the window. Reported in the
|
|
67
|
-
* notification body ("queued > 8 for 12m").
|
|
68
|
-
*/
|
|
69
|
-
sustainedMs: number
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface DetectOptions {
|
|
73
|
-
/** Queue depth at or above this counts as pressure. */
|
|
74
|
-
queuedThreshold: number
|
|
75
|
-
/** Duration the threshold must hold (in either direction) before
|
|
76
|
-
* the detector transitions. */
|
|
77
|
-
windowMinutes: number
|
|
78
|
-
/** Reference clock — defaults to `Date.now()`. Tests pass it in. */
|
|
79
|
-
now?: number
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Compute the per-org actions.
|
|
84
|
-
*
|
|
85
|
-
* Algorithm:
|
|
86
|
-
*
|
|
87
|
-
* 1. Bucket samples by org.
|
|
88
|
-
* 2. For each org, slice the samples within the last
|
|
89
|
-
* `windowMinutes` of `now` (called `window`).
|
|
90
|
-
* 3. Skip orgs with `window.length === 0` (no samples ever) or
|
|
91
|
-
* where the window is shorter than `windowMinutes`
|
|
92
|
-
* (insufficient data — sustained-pressure can't be proven yet).
|
|
93
|
-
* 4. If the org is currently `alerting`:
|
|
94
|
-
* - If every sample in the window is < threshold → emit
|
|
95
|
-
* `clear` action.
|
|
96
|
-
* 5. If the org is NOT alerting:
|
|
97
|
-
* - If every sample in the window is >= threshold → emit
|
|
98
|
-
* `fire` action.
|
|
99
|
-
*
|
|
100
|
-
* Conditions that DON'T trigger anything:
|
|
101
|
-
*
|
|
102
|
-
* - Mixed window (some above, some below threshold) → still
|
|
103
|
-
* converging; wait for the next refresh.
|
|
104
|
-
* - Sticky alerting with sustained pressure → already alerting,
|
|
105
|
-
* no new action needed.
|
|
106
|
-
* - Sticky clear with sustained calm → no action needed.
|
|
107
|
-
*/
|
|
108
|
-
export function detectRunnerPressure(
|
|
109
|
-
samples: RunnerSample[],
|
|
110
|
-
alertStates: Map<string, RunnerAlertState>,
|
|
111
|
-
options: DetectOptions,
|
|
112
|
-
): PressureAction[] {
|
|
113
|
-
const now = options.now ?? Date.now()
|
|
114
|
-
const windowMs = options.windowMinutes * 60_000
|
|
115
|
-
const cutoffMs = now - windowMs
|
|
116
|
-
|
|
117
|
-
// Bucket samples by org.
|
|
118
|
-
const byOrg = new Map<string, RunnerSample[]>()
|
|
119
|
-
for (const s of samples) {
|
|
120
|
-
const t = Date.parse(s.sampledAt)
|
|
121
|
-
if (Number.isNaN(t) || t < cutoffMs)
|
|
122
|
-
continue
|
|
123
|
-
const list = byOrg.get(s.org) ?? []
|
|
124
|
-
list.push(s)
|
|
125
|
-
byOrg.set(s.org, list)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const actions: PressureAction[] = []
|
|
129
|
-
|
|
130
|
-
for (const [org, orgSamples] of byOrg.entries()) {
|
|
131
|
-
if (orgSamples.length === 0)
|
|
132
|
-
continue
|
|
133
|
-
|
|
134
|
-
// Sort by sampledAt ascending so [0] is oldest, [last] is newest.
|
|
135
|
-
orgSamples.sort((a, b) => Date.parse(a.sampledAt) - Date.parse(b.sampledAt))
|
|
136
|
-
|
|
137
|
-
// "Sustained for a full window" means the oldest in-window
|
|
138
|
-
// sample is at least `windowMinutes` old. If the org just
|
|
139
|
-
// started reporting, the window is shorter than required and
|
|
140
|
-
// we can't prove sustained pressure yet.
|
|
141
|
-
const oldestMs = Date.parse(orgSamples[0].sampledAt)
|
|
142
|
-
const newestMs = Date.parse(orgSamples[orgSamples.length - 1].sampledAt)
|
|
143
|
-
const sustainedMs = newestMs - oldestMs
|
|
144
|
-
if (sustainedMs < windowMs - 1000) {
|
|
145
|
-
// -1000ms tolerance — the very first sample of a fresh window
|
|
146
|
-
// can be slightly less than windowMs old due to integer math
|
|
147
|
-
// around the moment the boundary tips. Tests use exactly
|
|
148
|
-
// `windowMinutes` spacing; tolerance keeps them deterministic
|
|
149
|
-
// without making the prod boundary fuzzy.
|
|
150
|
-
continue
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const allAboveOrEqual = orgSamples.every(s => s.queued >= options.queuedThreshold)
|
|
154
|
-
const allBelow = orgSamples.every(s => s.queued < options.queuedThreshold)
|
|
155
|
-
const state = alertStates.get(org)
|
|
156
|
-
const isAlerting = state?.alerting ?? false
|
|
157
|
-
const current = orgSamples[orgSamples.length - 1]
|
|
158
|
-
|
|
159
|
-
if (isAlerting) {
|
|
160
|
-
if (allBelow) {
|
|
161
|
-
actions.push({ org, action: 'clear', current, sustainedMs })
|
|
162
|
-
}
|
|
163
|
-
// Otherwise (still elevated, or mixed) stay alerting; no action.
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
if (allAboveOrEqual) {
|
|
167
|
-
actions.push({ org, action: 'fire', current, sustainedMs })
|
|
168
|
-
}
|
|
169
|
-
// Otherwise (still calm, or mixed) stay cleared; no action.
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return actions
|
|
174
|
-
}
|
package/src/runners.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { ghFetch, GITHUB_API } from './client'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Count jobs (not runs) currently using runners. A workflow run can be
|
|
5
|
-
* "in_progress" while some of its matrix jobs are still queued — only the
|
|
6
|
-
* actually-running jobs occupy runners, so we sum at the job level.
|
|
7
|
-
*/
|
|
8
|
-
export async function fetchRepoActiveRuns(owner: string, name: string): Promise<{ running: number, queued: number }> {
|
|
9
|
-
try {
|
|
10
|
-
const runRes = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs?status=in_progress&per_page=100`)
|
|
11
|
-
if (!runRes.ok)
|
|
12
|
-
return { running: 0, queued: 0 }
|
|
13
|
-
|
|
14
|
-
const runData = await runRes.json() as { workflow_runs: Array<{ id: number }> }
|
|
15
|
-
const runs = runData.workflow_runs ?? []
|
|
16
|
-
if (runs.length === 0)
|
|
17
|
-
return { running: 0, queued: 0 }
|
|
18
|
-
|
|
19
|
-
const counts = await Promise.all(runs.map(async (r) => {
|
|
20
|
-
const jobsRes = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs/${r.id}/jobs`)
|
|
21
|
-
if (!jobsRes.ok)
|
|
22
|
-
return { running: 0, queued: 0 }
|
|
23
|
-
const jobsData = await jobsRes.json() as { jobs: Array<{ status: string }> }
|
|
24
|
-
let running = 0
|
|
25
|
-
let queued = 0
|
|
26
|
-
for (const j of jobsData.jobs ?? []) {
|
|
27
|
-
if (j.status === 'in_progress')
|
|
28
|
-
running++
|
|
29
|
-
else if (j.status === 'queued')
|
|
30
|
-
queued++
|
|
31
|
-
}
|
|
32
|
-
return { running, queued }
|
|
33
|
-
}))
|
|
34
|
-
|
|
35
|
-
return counts.reduce(
|
|
36
|
-
(a, b) => ({ running: a.running + b.running, queued: a.queued + b.queued }),
|
|
37
|
-
{ running: 0, queued: 0 },
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
return { running: 0, queued: 0 }
|
|
42
|
-
}
|
|
43
|
-
}
|
package/src/runs.ts
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import type { FailedJob, RepoStatus } from './types'
|
|
2
|
-
import { ghFetch, GITHUB_API } from './client'
|
|
3
|
-
|
|
4
|
-
async function fillLatestCommit(base: RepoStatus, owner: string, name: string, branch: string): Promise<void> {
|
|
5
|
-
try {
|
|
6
|
-
const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/commits?sha=${branch}&per_page=1`)
|
|
7
|
-
if (!res.ok)
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
-
const commits = await res.json() as Array<{
|
|
11
|
-
sha: string
|
|
12
|
-
commit: { message: string, author: { name: string, date: string } | null }
|
|
13
|
-
author: { login: string } | null
|
|
14
|
-
}>
|
|
15
|
-
if (commits.length === 0)
|
|
16
|
-
return
|
|
17
|
-
|
|
18
|
-
const c = commits[0]
|
|
19
|
-
base.commitSha = c.sha.slice(0, 7)
|
|
20
|
-
base.commitMessage = c.commit.message.split('\n')[0]
|
|
21
|
-
base.commitUrl = `https://github.com/${owner}/${name}/commit/${c.sha}`
|
|
22
|
-
base.commitAuthor = c.commit.author?.name ?? c.author?.login ?? null
|
|
23
|
-
base.updatedAt = c.commit.author?.date ?? null
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
// commit info is best-effort
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function fetchFailedJobs(owner: string, name: string, runId: number): Promise<FailedJob[]> {
|
|
31
|
-
try {
|
|
32
|
-
const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs/${runId}/jobs?filter=latest`)
|
|
33
|
-
if (!res.ok)
|
|
34
|
-
return []
|
|
35
|
-
|
|
36
|
-
const data = await res.json() as { jobs: Array<{ name: string, conclusion: string | null, html_url: string }> }
|
|
37
|
-
return data.jobs
|
|
38
|
-
.filter(j => j.conclusion && j.conclusion !== 'success' && j.conclusion !== 'skipped')
|
|
39
|
-
.map(j => ({ name: j.name, conclusion: j.conclusion!, url: j.html_url }))
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return []
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function fetchRepoStatus(owner: string, name: string, defaultBranch: string): Promise<RepoStatus> {
|
|
47
|
-
const base: RepoStatus = {
|
|
48
|
-
name,
|
|
49
|
-
owner,
|
|
50
|
-
fullName: `${owner}/${name}`,
|
|
51
|
-
url: `https://github.com/${owner}/${name}`,
|
|
52
|
-
defaultBranch,
|
|
53
|
-
status: 'no_runs',
|
|
54
|
-
conclusion: null,
|
|
55
|
-
workflowName: null,
|
|
56
|
-
commitSha: null,
|
|
57
|
-
commitMessage: null,
|
|
58
|
-
commitUrl: null,
|
|
59
|
-
commitAuthor: null,
|
|
60
|
-
commitCount: null,
|
|
61
|
-
updatedAt: null,
|
|
62
|
-
runUrl: null,
|
|
63
|
-
failedJobs: [],
|
|
64
|
-
renovatePRs: 0,
|
|
65
|
-
renovatePRsUrl: null,
|
|
66
|
-
actionsPRs: 0,
|
|
67
|
-
actionsPRsUrl: null,
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs?branch=${defaultBranch}&event=push&per_page=1`)
|
|
72
|
-
|
|
73
|
-
if (!res.ok) {
|
|
74
|
-
base.status = 'error'
|
|
75
|
-
await fillLatestCommit(base, owner, name, defaultBranch)
|
|
76
|
-
return base
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const data = await res.json() as {
|
|
80
|
-
workflow_runs: Array<{
|
|
81
|
-
id: number
|
|
82
|
-
status: string
|
|
83
|
-
conclusion: string | null
|
|
84
|
-
name: string
|
|
85
|
-
head_sha: string
|
|
86
|
-
head_commit: { message: string, author: { name: string } | null } | null
|
|
87
|
-
updated_at: string
|
|
88
|
-
html_url: string
|
|
89
|
-
actor: { login: string } | null
|
|
90
|
-
}>
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!data.workflow_runs || data.workflow_runs.length === 0) {
|
|
94
|
-
await fillLatestCommit(base, owner, name, defaultBranch)
|
|
95
|
-
return base
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const run = data.workflow_runs[0]
|
|
99
|
-
base.workflowName = run.name
|
|
100
|
-
base.commitSha = run.head_sha.slice(0, 7)
|
|
101
|
-
base.commitMessage = run.head_commit?.message.split('\n')[0] ?? null
|
|
102
|
-
base.commitUrl = `https://github.com/${owner}/${name}/commit/${run.head_sha}`
|
|
103
|
-
base.commitAuthor = run.head_commit?.author?.name ?? run.actor?.login ?? null
|
|
104
|
-
base.updatedAt = run.updated_at
|
|
105
|
-
base.runUrl = run.html_url
|
|
106
|
-
|
|
107
|
-
if (run.status === 'completed') {
|
|
108
|
-
base.status = run.conclusion === 'success' ? 'success' : 'failure'
|
|
109
|
-
base.conclusion = run.conclusion
|
|
110
|
-
|
|
111
|
-
if (base.status === 'failure')
|
|
112
|
-
base.failedJobs = await fetchFailedJobs(owner, name, run.id)
|
|
113
|
-
}
|
|
114
|
-
else {
|
|
115
|
-
base.status = 'pending'
|
|
116
|
-
base.conclusion = run.status
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
base.status = 'error'
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return base
|
|
124
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
export interface FailedJob {
|
|
2
|
-
name: string
|
|
3
|
-
conclusion: string
|
|
4
|
-
url: string
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export type RepoStatusKind = 'success' | 'failure' | 'pending' | 'no_runs' | 'error'
|
|
8
|
-
|
|
9
|
-
export interface RepoStatus {
|
|
10
|
-
name: string
|
|
11
|
-
owner: string
|
|
12
|
-
fullName: string
|
|
13
|
-
url: string
|
|
14
|
-
defaultBranch: string
|
|
15
|
-
status: RepoStatusKind
|
|
16
|
-
conclusion: string | null
|
|
17
|
-
workflowName: string | null
|
|
18
|
-
commitSha: string | null
|
|
19
|
-
commitMessage: string | null
|
|
20
|
-
commitUrl: string | null
|
|
21
|
-
commitAuthor: string | null
|
|
22
|
-
commitCount: number | null
|
|
23
|
-
updatedAt: string | null
|
|
24
|
-
runUrl: string | null
|
|
25
|
-
failedJobs: FailedJob[]
|
|
26
|
-
renovatePRs: number
|
|
27
|
-
renovatePRsUrl: string | null
|
|
28
|
-
actionsPRs: number
|
|
29
|
-
actionsPRsUrl: string | null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface OrgRunnerUsage {
|
|
33
|
-
running: number
|
|
34
|
-
queued: number
|
|
35
|
-
cap: number
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface DashboardData {
|
|
39
|
-
repos: RepoStatus[]
|
|
40
|
-
fetchedAt: string
|
|
41
|
-
total: number
|
|
42
|
-
passing: number
|
|
43
|
-
failing: number
|
|
44
|
-
pending: number
|
|
45
|
-
noRuns: number
|
|
46
|
-
runners: Record<string, OrgRunnerUsage>
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface DashboardOptions {
|
|
50
|
-
/** Orgs to include in the snapshot. */
|
|
51
|
-
orgs: string[]
|
|
52
|
-
/** Self-hosted runner caps per org. Missing entries fall back to {@link defaultRunnerCap}. */
|
|
53
|
-
runnerCaps?: Record<string, number>
|
|
54
|
-
/** Fallback runner cap when {@link runnerCaps} omits an org. */
|
|
55
|
-
defaultRunnerCap?: number
|
|
56
|
-
/** Repo names to exclude (matches against the bare repo name). */
|
|
57
|
-
ignoreRepos?: string[]
|
|
58
|
-
/** Cache TTL in ms. Defaults to 30s. */
|
|
59
|
-
cacheTtlMs?: number
|
|
60
|
-
/** On-disk cache path. Defaults to `.cache/dashboard.json`. */
|
|
61
|
-
cachePath?: string
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface Repo {
|
|
65
|
-
name: string
|
|
66
|
-
owner: string
|
|
67
|
-
full_name: string
|
|
68
|
-
html_url: string
|
|
69
|
-
default_branch: string
|
|
70
|
-
archived: boolean
|
|
71
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|