@stacksjs/github 0.70.53 → 0.70.55

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2023 Open Web Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/github",
3
3
  "type": "module",
4
- "version": "0.70.53",
4
+ "version": "0.70.55",
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": [
@@ -36,7 +36,8 @@
36
36
  "types": "dist/index.d.ts",
37
37
  "files": [
38
38
  "README.md",
39
- "dist"
39
+ "dist",
40
+ "src"
40
41
  ],
41
42
  "scripts": {
42
43
  "build": "bun build.ts",
package/src/bots.ts ADDED
@@ -0,0 +1,34 @@
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 ADDED
@@ -0,0 +1,64 @@
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
+ }
@@ -0,0 +1,180 @@
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
+ }
@@ -0,0 +1,194 @@
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 ADDED
@@ -0,0 +1,36 @@
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 ADDED
@@ -0,0 +1,47 @@
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
+ }
@@ -0,0 +1,194 @@
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
+ }
@@ -0,0 +1,174 @@
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 ADDED
@@ -0,0 +1,43 @@
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 ADDED
@@ -0,0 +1,124 @@
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 ADDED
@@ -0,0 +1,71 @@
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
+ }