@stacksjs/github 0.70.87 → 0.70.88

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/github",
3
3
  "type": "module",
4
- "version": "0.70.87",
4
+ "version": "0.70.88",
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": [
package/dist/bots.d.ts DELETED
@@ -1,7 +0,0 @@
1
- /**
2
- * Count open PRs authored by a GitHub App across every repo in an org.
3
- * Returns a map keyed by `owner/repo` so callers can attribute counts back
4
- * to the right repo card. Used to surface Renovate / GitHub Actions bot
5
- * traffic in the dashboard.
6
- */
7
- export declare function fetchBotPRCounts(org: string, authorSlug: string): Promise<Map<string, number>>;
package/dist/bots.js DELETED
@@ -1,21 +0,0 @@
1
- import { ghFetch, GITHUB_API } from "./client";
2
- export async function fetchBotPRCounts(org, authorSlug) {
3
- const counts = new Map;
4
- let page = 1;
5
- while (!0) {
6
- const q = `is:pr is:open org:${org} author:app/${authorSlug}`, res = await ghFetch(`${GITHUB_API}/search/issues?q=${encodeURIComponent(q)}&per_page=100&page=${page}`);
7
- if (!res.ok)
8
- break;
9
- const data = await res.json();
10
- if (!data.items || data.items.length === 0)
11
- break;
12
- for (const item of data.items) {
13
- const fullName = item.repository_url.replace(`${GITHUB_API}/repos/`, "");
14
- counts.set(fullName, (counts.get(fullName) ?? 0) + 1);
15
- }
16
- if (data.items.length < 100)
17
- break;
18
- page++;
19
- }
20
- return counts;
21
- }
package/dist/client.d.ts DELETED
@@ -1,15 +0,0 @@
1
- export declare function ghHeaders(): Record<string, string>;
2
- /**
3
- * `fetch` against the GitHub API that retries on secondary rate limits.
4
- *
5
- * GitHub signals back-off via either a `Retry-After` header (preferred) or
6
- * an `x-ratelimit-reset` epoch second. The retry budget is bounded so a
7
- * permanently rate-limited token doesn't hang callers indefinitely.
8
- */
9
- export declare function ghFetch(url: string, attempt?: number): Promise<Response>;
10
- /**
11
- * Run `fn` over `items` with at most `limit` concurrent invocations.
12
- * Result indices match input indices.
13
- */
14
- export declare function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
15
- export declare const GITHUB_API: 'https://api.github.com';
package/dist/client.js DELETED
@@ -1,43 +0,0 @@
1
- export const GITHUB_API = "https://api.github.com";
2
- function getToken() {
3
- const token = process.env.GITHUB_TOKEN;
4
- if (!token)
5
- throw Error("GITHUB_TOKEN environment variable is required");
6
- return token;
7
- }
8
- export function ghHeaders() {
9
- return {
10
- Authorization: `Bearer ${getToken()}`,
11
- Accept: "application/vnd.github+json",
12
- "X-GitHub-Api-Version": "2022-11-28"
13
- };
14
- }
15
- export async function ghFetch(url, attempt = 0) {
16
- const res = await fetch(url, { headers: ghHeaders() });
17
- if (res.ok || attempt >= 3)
18
- return res;
19
- if (!(res.status === 429 || res.status === 403 && (res.headers.get("x-ratelimit-remaining") === "0" || res.headers.get("retry-after"))))
20
- return res;
21
- const retryAfterHeader = res.headers.get("retry-after"), resetHeader = res.headers.get("x-ratelimit-reset");
22
- let waitMs = 1000 * 2 ** attempt;
23
- if (retryAfterHeader)
24
- waitMs = Number(retryAfterHeader) * 1000;
25
- else if (resetHeader)
26
- waitMs = Math.max(0, Number(resetHeader) * 1000 - Date.now()) + 500;
27
- await new Promise((r) => setTimeout(r, Math.min(waitMs, 30000)));
28
- return ghFetch(url, attempt + 1);
29
- }
30
- export async function mapWithConcurrency(items, limit, fn) {
31
- const results = Array.from({ length: items.length });
32
- let next = 0;
33
- async function worker() {
34
- while (!0) {
35
- const i = next++;
36
- if (i >= items.length)
37
- return;
38
- results[i] = await fn(items[i]);
39
- }
40
- }
41
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
42
- return results;
43
- }
@@ -1,17 +0,0 @@
1
- import type { DashboardData, DashboardOptions } from './types';
2
- /**
3
- * Aggregated CI/runner snapshot across the configured orgs.
4
- *
5
- * Stale-while-revalidate: if a cached snapshot exists, it is returned
6
- * immediately and a background refresh kicks off once the TTL has elapsed.
7
- * First-ever call (no in-memory and no on-disk cache) waits for the build
8
- * so the dashboard renders against real data instead of `null`.
9
- */
10
- export declare function getDashboardData(opts: DashboardOptions): Promise<DashboardData>;
11
- /**
12
- * Drop the in-memory cache for a given cache path. Disk cache is left
13
- * alone — callers that want a true reset should `Bun.write` an empty file
14
- * or delete it before re-fetching. Used by tests and by an eventual
15
- * "Refresh now" UI action.
16
- */
17
- export declare function clearDashboardCache(cachePath?: unknown): void;
package/dist/dashboard.js DELETED
@@ -1,110 +0,0 @@
1
- import { fetchBotPRCounts } from "./bots";
2
- import { mapWithConcurrency } from "./client";
3
- import { fetchAllRepos } from "./repos";
4
- import { fetchRepoActiveRuns } from "./runners";
5
- import { fetchRepoStatus } from "./runs";
6
- const DEFAULT_TTL_MS = 30000, DEFAULT_CACHE_PATH = ".cache/dashboard.json", DEFAULT_RUNNER_CAP = 20, caches = new Map;
7
- function entryFor(path) {
8
- let e = caches.get(path);
9
- if (!e) {
10
- e = { data: null, savedAt: 0, inflight: null, diskLoaded: !1 };
11
- caches.set(path, e);
12
- }
13
- return e;
14
- }
15
- async function loadCacheFromDisk(entry, path) {
16
- if (entry.diskLoaded)
17
- return;
18
- entry.diskLoaded = !0;
19
- try {
20
- const file = Bun.file(path);
21
- if (!await file.exists())
22
- return;
23
- const stored = await file.json();
24
- entry.data = stored.data;
25
- entry.savedAt = stored.savedAt;
26
- } catch {}
27
- }
28
- async function saveCacheToDisk(path, data, savedAt) {
29
- try {
30
- await Bun.write(path, JSON.stringify({ data, savedAt }));
31
- } catch (err) {
32
- console.warn("[github/dashboard] cache write failed:", err);
33
- }
34
- }
35
- async function buildDashboardData(opts) {
36
- const orgs = opts.orgs, runnerCaps = opts.runnerCaps ?? {}, defaultRunnerCap = opts.defaultRunnerCap ?? DEFAULT_RUNNER_CAP, repos = await fetchAllRepos(orgs, opts.ignoreRepos), statuses = await mapWithConcurrency(repos, 8, (r) => fetchRepoStatus(r.owner, r.name, r.default_branch)), prCountMaps = await Promise.all(orgs.flatMap((org) => [
37
- fetchBotPRCounts(org, "renovate").then((m) => ({ type: "renovate", map: m })),
38
- fetchBotPRCounts(org, "github-actions").then((m) => ({ type: "actions", map: m }))
39
- ])), renovateCounts = new Map, actionsCounts = new Map;
40
- for (const { type, map } of prCountMaps) {
41
- const target = type === "renovate" ? renovateCounts : actionsCounts;
42
- for (const [k, v] of map)
43
- target.set(k, (target.get(k) ?? 0) + v);
44
- }
45
- for (const s of statuses) {
46
- const rCount = renovateCounts.get(s.fullName) ?? 0, aCount = actionsCounts.get(s.fullName) ?? 0;
47
- s.renovatePRs = rCount;
48
- s.actionsPRs = aCount;
49
- if (rCount > 0)
50
- s.renovatePRsUrl = `https://github.com/${s.fullName}/pulls?q=${encodeURIComponent("is:pr is:open author:app/renovate")}`;
51
- if (aCount > 0)
52
- s.actionsPRsUrl = `https://github.com/${s.fullName}/pulls?q=${encodeURIComponent("is:pr is:open author:app/github-actions")}`;
53
- }
54
- const runnerCounts = await mapWithConcurrency(repos, 8, async (r) => ({
55
- owner: r.owner,
56
- ...await fetchRepoActiveRuns(r.owner, r.name)
57
- })), runners = {};
58
- for (const org of orgs)
59
- runners[org] = { running: 0, queued: 0, cap: runnerCaps[org] ?? defaultRunnerCap };
60
- for (const c of runnerCounts) {
61
- if (!runners[c.owner])
62
- runners[c.owner] = { running: 0, queued: 0, cap: runnerCaps[c.owner] ?? defaultRunnerCap };
63
- runners[c.owner].running += c.running;
64
- runners[c.owner].queued += c.queued;
65
- }
66
- const order = { failure: 0, error: 1, pending: 2, success: 3, no_runs: 4 };
67
- statuses.sort((a, b) => (order[a.status] ?? 5) - (order[b.status] ?? 5));
68
- return {
69
- repos: statuses,
70
- fetchedAt: new Date().toISOString(),
71
- total: statuses.length,
72
- passing: statuses.filter((r) => r.status === "success").length,
73
- failing: statuses.filter((r) => r.status === "failure" || r.status === "error").length,
74
- pending: statuses.filter((r) => r.status === "pending").length,
75
- noRuns: statuses.filter((r) => r.status === "no_runs").length,
76
- runners
77
- };
78
- }
79
- export async function getDashboardData(opts) {
80
- const ttl = opts.cacheTtlMs ?? DEFAULT_TTL_MS, path = opts.cachePath ?? DEFAULT_CACHE_PATH, entry = entryFor(path);
81
- await loadCacheFromDisk(entry, path);
82
- const now = Date.now();
83
- if (entry.data) {
84
- if (now - entry.savedAt >= ttl && !entry.inflight) {
85
- entry.inflight = buildDashboardData(opts).then(async (data) => {
86
- entry.data = data;
87
- entry.savedAt = Date.now();
88
- await saveCacheToDisk(path, data, entry.savedAt);
89
- return data;
90
- }).finally(() => {
91
- entry.inflight = null;
92
- });
93
- entry.inflight.catch((err) => console.warn("[github/dashboard] refresh failed:", err));
94
- }
95
- return entry.data;
96
- }
97
- if (!entry.inflight)
98
- entry.inflight = buildDashboardData(opts).then(async (data) => {
99
- entry.data = data;
100
- entry.savedAt = Date.now();
101
- await saveCacheToDisk(path, data, entry.savedAt);
102
- return data;
103
- }).finally(() => {
104
- entry.inflight = null;
105
- });
106
- return entry.inflight;
107
- }
108
- export function clearDashboardCache(cachePath = DEFAULT_CACHE_PATH) {
109
- caches.delete(cachePath);
110
- }
@@ -1,59 +0,0 @@
1
- import type { RepoStatus } from './types';
2
- /**
3
- * Compute the failed-transition list.
4
- *
5
- * The "transition" is gated on:
6
- *
7
- * 1. The repo is currently in a failed state.
8
- * 2. The repo is NOT in flight (pending). Pending → failure happens
9
- * mid-run; firing on it would just produce noise once the run
10
- * finishes and the same failure resurfaces with a final
11
- * conclusion.
12
- * 3. The previous state was *not* failed. (Sticky-red repos don't
13
- * keep firing.)
14
- * 4. The run id changed — if the same failed run id is still
15
- * surfacing, this is a duplicate poll, not a new failure.
16
- * 5. Cooldown: `lastNotifiedAt` is either null or older than
17
- * `cooldownMs` ago.
18
- */
19
- export declare function detectNewlyFailedRuns(snapshot: { repos: RepoStatus[] }, previousStates: Map<string, PreviousRunState>, options?: DetectOptions): FailedTransition[];
20
- /**
21
- * Pure failure-transition detector (stacksjs/stacks#1849).
22
- *
23
- * Given a fresh CI snapshot and the previous per-repo states the
24
- * dashboard knows about, return the list of repos that *just*
25
- * transitioned to a failed conclusion — the moment worth firing a
26
- * notification on. Anything still failing, or newly passing, or
27
- * still in flight, is silenced.
28
- *
29
- * Pure for a reason: easy to unit-test the transition matrix without
30
- * a DB, a notify implementation, or the network. The persistence
31
- * layer + notification fan-out live in defaults so framework
32
- * packages stay infrastructure-free.
33
- *
34
- * @see PreviousRunState what the persistence layer hands in
35
- * @see FailedTransition what we return — caller decides how to notify
36
- */
37
- /** What a caller stored about a repo on its last snapshot pass. */
38
- export declare interface PreviousRunState {
39
- repoFullName: string
40
- lastConclusion: string | null
41
- lastRunId: number | null
42
- lastNotifiedAt: string | null
43
- }
44
- /** Per-transition payload the detector hands back to the caller. */
45
- export declare interface FailedTransition {
46
- repoFullName: string
47
- conclusion: string
48
- runId: number | null
49
- workflowName: string | null
50
- commitSha: string | null
51
- commitMessage: string | null
52
- commitAuthor: string | null
53
- runUrl: string | null
54
- previousConclusion: string | null
55
- }
56
- export declare interface DetectOptions {
57
- cooldownMs?: number
58
- now?: number
59
- }
@@ -1,67 +0,0 @@
1
- const DEFAULT_COOLDOWN_MS = 300000, FAILED_CONCLUSIONS = new Set([
2
- "failure",
3
- "error",
4
- "timed_out",
5
- "startup_failure"
6
- ]);
7
- function isFailed(repo) {
8
- if (repo.status === "failure" || repo.status === "error")
9
- return !0;
10
- if (repo.conclusion && FAILED_CONCLUSIONS.has(repo.conclusion))
11
- return !0;
12
- return !1;
13
- }
14
- function isInFlight(repo) {
15
- return repo.status === "pending";
16
- }
17
- export function detectNewlyFailedRuns(snapshot, previousStates, options = {}) {
18
- const cooldownMs = options.cooldownMs ?? DEFAULT_COOLDOWN_MS, now = options.now ?? Date.now(), transitions = [];
19
- for (const repo of snapshot.repos) {
20
- if (!isFailed(repo) || isInFlight(repo))
21
- continue;
22
- const prev = previousStates.get(repo.fullName);
23
- if (prev?.lastConclusion ? FAILED_CONCLUSIONS.has(prev.lastConclusion) || prev.lastConclusion === "failure" || prev.lastConclusion === "error" : !1) {
24
- const currentRunId = parseRunIdFromUrl(repo.runUrl);
25
- if (currentRunId !== null && prev?.lastRunId !== null && currentRunId === prev?.lastRunId)
26
- continue;
27
- if (prev?.lastNotifiedAt && isWithinCooldown(prev.lastNotifiedAt, cooldownMs, now))
28
- continue;
29
- transitions.push(toTransition(repo, prev?.lastConclusion ?? null));
30
- continue;
31
- }
32
- if (prev?.lastNotifiedAt && isWithinCooldown(prev.lastNotifiedAt, cooldownMs, now))
33
- continue;
34
- transitions.push(toTransition(repo, prev?.lastConclusion ?? null));
35
- }
36
- return transitions;
37
- }
38
- function isWithinCooldown(lastNotifiedAt, cooldownMs, now) {
39
- if (cooldownMs === 0)
40
- return !1;
41
- const ts = Date.parse(lastNotifiedAt);
42
- if (Number.isNaN(ts))
43
- return !1;
44
- return now - ts < cooldownMs;
45
- }
46
- function parseRunIdFromUrl(runUrl) {
47
- if (!runUrl)
48
- return null;
49
- const match = runUrl.match(/\/actions\/runs\/(\d+)/);
50
- if (!match)
51
- return null;
52
- const id = Number(match[1]);
53
- return Number.isFinite(id) ? id : null;
54
- }
55
- function toTransition(repo, previousConclusion) {
56
- return {
57
- repoFullName: repo.fullName,
58
- conclusion: repo.conclusion ?? repo.status,
59
- runId: parseRunIdFromUrl(repo.runUrl),
60
- workflowName: repo.workflowName,
61
- commitSha: repo.commitSha,
62
- commitMessage: repo.commitMessage,
63
- commitAuthor: repo.commitAuthor,
64
- runUrl: repo.runUrl,
65
- previousConclusion
66
- };
67
- }
package/dist/index.d.ts DELETED
@@ -1,35 +0,0 @@
1
- export type { DetectOptions, FailedTransition, PreviousRunState } from './failure-detector';
2
- export type { WorkflowJob, WorkflowRun } from './run-history';
3
- export type {
4
- DetectOptions as PressureDetectOptions,
5
- PressureAction,
6
- RunnerAlertState,
7
- RunnerSample,
8
- } from './runner-pressure-detector';
9
- export type {
10
- DashboardData,
11
- DashboardOptions,
12
- FailedJob,
13
- OrgRunnerUsage,
14
- Repo,
15
- RepoStatus,
16
- RepoStatusKind,
17
- } from './types';
18
- /**
19
- * @stacksjs/github — thin GitHub API client used by Stacks framework
20
- * features (dashboard CI surface, future runner alerts, kanban→PR links).
21
- *
22
- * Top-level entry: {@link getDashboardData} returns the aggregated snapshot
23
- * the dashboard renders. Lower-level helpers (`fetchAllRepos`,
24
- * `fetchRepoStatus`, …) are also exported so other surfaces can pull a
25
- * single dimension without pulling the whole snapshot through.
26
- */
27
- export { fetchBotPRCounts } from './bots';
28
- export { ghFetch, ghHeaders, GITHUB_API, mapWithConcurrency } from './client';
29
- export { clearDashboardCache, getDashboardData } from './dashboard';
30
- export { detectNewlyFailedRuns } from './failure-detector';
31
- export { fetchAllRepos } from './repos';
32
- export { fetchRepoActiveRuns } from './runners';
33
- export { fetchRunJobs, fetchWorkflowRuns } from './run-history';
34
- export { detectRunnerPressure } from './runner-pressure-detector';
35
- export { fetchFailedJobs, fetchRepoStatus } from './runs';
package/dist/index.js DELETED
@@ -1,9 +0,0 @@
1
- export { fetchBotPRCounts } from "./bots";
2
- export { ghFetch, ghHeaders, GITHUB_API, mapWithConcurrency } from "./client";
3
- export { clearDashboardCache, getDashboardData } from "./dashboard";
4
- export { detectNewlyFailedRuns } from "./failure-detector";
5
- export { fetchAllRepos } from "./repos";
6
- export { fetchRepoActiveRuns } from "./runners";
7
- export { fetchRunJobs, fetchWorkflowRuns } from "./run-history";
8
- export { detectRunnerPressure } from "./runner-pressure-detector";
9
- export { fetchFailedJobs, fetchRepoStatus } from "./runs";
package/dist/repos.d.ts DELETED
@@ -1,7 +0,0 @@
1
- import type { Repo } from './types';
2
- /**
3
- * Fetch all non-archived public repos across `orgs`, filtering out any names
4
- * in `ignore` (defaults to `.github` which exists in every org and never has
5
- * project CI on it).
6
- */
7
- export declare function fetchAllRepos(orgs: string[], ignore?: string[]): Promise<Repo[]>;
package/dist/repos.js DELETED
@@ -1,26 +0,0 @@
1
- import { ghFetch, GITHUB_API } from "./client";
2
- export async function fetchAllRepos(orgs, ignore = [".github"]) {
3
- const ignored = new Set(ignore), all = [];
4
- for (const org of orgs) {
5
- let page = 1;
6
- while (!0) {
7
- const res = await ghFetch(`${GITHUB_API}/orgs/${org}/repos?per_page=100&page=${page}&type=public`);
8
- if (!res.ok)
9
- break;
10
- const repos = await res.json();
11
- if (repos.length === 0)
12
- break;
13
- for (const repo of repos)
14
- all.push({
15
- name: repo.name,
16
- owner: repo.owner.login,
17
- full_name: repo.full_name,
18
- html_url: repo.html_url,
19
- default_branch: repo.default_branch,
20
- archived: repo.archived
21
- });
22
- page++;
23
- }
24
- }
25
- return all.filter((r) => !r.archived && !ignored.has(r.name));
26
- }
@@ -1,66 +0,0 @@
1
- /**
2
- * The N most recent workflow runs for a repo. Each entry is a
3
- * lightweight summary — failed-job detail is fetched on-demand via
4
- * {@link fetchRunJobs} so the drawer only pays the round-trip cost
5
- * when the user actually expands a row.
6
- */
7
- export declare function fetchWorkflowRuns(owner: string, name: string, options?: FetchRunsOptions): Promise<WorkflowRun[]>;
8
- /**
9
- * Per-job detail for a single run. Used by the drilldown drawer when
10
- * the user expands a failing run to see *which* step broke. Different
11
- * shape from `fetchFailedJobs` in `runs.ts` (which is at-a-glance
12
- * "failed job names" for the card) — here we want everything,
13
- * including step-level breakdown + timing.
14
- *
15
- * Returns an empty array on error so the drawer can render an empty
16
- * state rather than a 500.
17
- */
18
- export declare function fetchRunJobs(owner: string, name: string, runId: number): Promise<WorkflowJob[]>;
19
- /**
20
- * Recent-workflow-runs + per-run job detail for the dashboard CI
21
- * drilldown (stacksjs/stacks#1848).
22
- *
23
- * Distinct from `runs.ts` which is the *aggregator* — it folds the
24
- * latest run per repo into a `RepoStatus` for the at-a-glance card.
25
- * Here we want history: the last N runs for a single repo, plus the
26
- * per-job detail for a single run. Different shapes, different
27
- * callers, easier to keep them in separate files than to overload
28
- * one module.
29
- */
30
- export declare interface WorkflowRun {
31
- id: number
32
- status: 'queued' | 'in_progress' | 'completed' | string
33
- conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | 'timed_out' | 'action_required' | 'neutral' | 'startup_failure' | null
34
- name: string
35
- headBranch: string | null
36
- headSha: string
37
- headShaShort: string
38
- commitMessage: string | null
39
- commitAuthor: string | null
40
- event: string
41
- url: string
42
- startedAt: string | null
43
- updatedAt: string
44
- durationMs: number | null
45
- }
46
- export declare interface WorkflowJob {
47
- id: number
48
- name: string
49
- status: 'queued' | 'in_progress' | 'completed' | string
50
- conclusion: WorkflowRun['conclusion']
51
- startedAt: string | null
52
- completedAt: string | null
53
- durationMs: number | null
54
- url: string
55
- steps: Array<{
56
- name: string
57
- status: WorkflowJob['status']
58
- conclusion: WorkflowRun['conclusion']
59
- number: number
60
- }>
61
- }
62
- declare interface FetchRunsOptions {
63
- limit?: number
64
- branch?: string
65
- event?: string
66
- }
@@ -1,63 +0,0 @@
1
- import { ghFetch, GITHUB_API } from "./client";
2
- function shortSha(sha) {
3
- return sha.length > 7 ? sha.slice(0, 7) : sha;
4
- }
5
- function diffMs(start, end) {
6
- if (!start || !end)
7
- return null;
8
- const s = Date.parse(start), e = Date.parse(end);
9
- if (Number.isNaN(s) || Number.isNaN(e))
10
- return null;
11
- if (e < s)
12
- return null;
13
- return e - s;
14
- }
15
- export async function fetchWorkflowRuns(owner, name, options = {}) {
16
- const limit = Math.max(1, Math.min(options.limit ?? 20, 100)), params = new URLSearchParams;
17
- params.set("per_page", String(limit));
18
- if (options.branch)
19
- params.set("branch", options.branch);
20
- if (options.event)
21
- params.set("event", options.event);
22
- const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs?${params.toString()}`);
23
- if (!res.ok)
24
- return [];
25
- return ((await res.json()).workflow_runs ?? []).map((r) => ({
26
- id: r.id,
27
- status: r.status,
28
- conclusion: r.conclusion,
29
- name: r.name,
30
- headBranch: r.head_branch,
31
- headSha: r.head_sha,
32
- headShaShort: shortSha(r.head_sha),
33
- commitMessage: r.head_commit?.message?.split(`
34
- `)[0] ?? null,
35
- commitAuthor: r.head_commit?.author?.name ?? r.actor?.login ?? null,
36
- event: r.event,
37
- url: r.html_url,
38
- startedAt: r.run_started_at ?? r.created_at ?? null,
39
- updatedAt: r.updated_at,
40
- durationMs: diffMs(r.run_started_at ?? r.created_at, r.updated_at)
41
- }));
42
- }
43
- export async function fetchRunJobs(owner, name, runId) {
44
- const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs/${runId}/jobs?per_page=100`);
45
- if (!res.ok)
46
- return [];
47
- return ((await res.json()).jobs ?? []).map((j) => ({
48
- id: j.id,
49
- name: j.name,
50
- status: j.status,
51
- conclusion: j.conclusion,
52
- startedAt: j.started_at,
53
- completedAt: j.completed_at,
54
- durationMs: diffMs(j.started_at, j.completed_at),
55
- url: j.html_url,
56
- steps: (j.steps ?? []).map((s) => ({
57
- name: s.name,
58
- status: s.status,
59
- conclusion: s.conclusion,
60
- number: s.number
61
- }))
62
- }));
63
- }
@@ -1,74 +0,0 @@
1
- /**
2
- * Compute the per-org actions.
3
- *
4
- * Algorithm:
5
- *
6
- * 1. Bucket samples by org.
7
- * 2. For each org, slice the samples within the last
8
- * `windowMinutes` of `now` (called `window`).
9
- * 3. Skip orgs with `window.length === 0` (no samples ever) or
10
- * where the window is shorter than `windowMinutes`
11
- * (insufficient data — sustained-pressure can't be proven yet).
12
- * 4. If the org is currently `alerting`:
13
- * - If every sample in the window is < threshold → emit
14
- * `clear` action.
15
- * 5. If the org is NOT alerting:
16
- * - If every sample in the window is >= threshold → emit
17
- * `fire` action.
18
- *
19
- * Conditions that DON'T trigger anything:
20
- *
21
- * - Mixed window (some above, some below threshold) → still
22
- * converging; wait for the next refresh.
23
- * - Sticky alerting with sustained pressure → already alerting,
24
- * no new action needed.
25
- * - Sticky clear with sustained calm → no action needed.
26
- */
27
- export declare function detectRunnerPressure(samples: RunnerSample[], alertStates: Map<string, RunnerAlertState>, options: DetectOptions): PressureAction[];
28
- /**
29
- * Pure runner-pressure detector (stacksjs/stacks#1850).
30
- *
31
- * Given a time-series of per-org runner samples + the dashboard's
32
- * memory of which orgs are currently "alerting", return the
33
- * actions to take: fire a fresh alert, clear an existing one, or do
34
- * nothing.
35
- *
36
- * **Hysteresis** matters here. Once an org has been alerted on
37
- * (queue > threshold sustained for one window), we DON'T re-fire
38
- * just because the queue spikes a second time within the same hour.
39
- * The org has to first *clear* — drop below threshold for a full
40
- * window — before another fire becomes possible. This is the
41
- * difference between "useful CI pressure paging" and "every dashboard
42
- * load spams Slack".
43
- *
44
- * Pure for the same reason as `failure-detector.ts`: easy to unit-
45
- * test the hysteresis transitions, the persistence + fan-out live
46
- * in defaults.
47
- */
48
- /** One time-series row per org per snapshot refresh. */
49
- export declare interface RunnerSample {
50
- org: string
51
- running: number
52
- queued: number
53
- cap: number
54
- sampledAt: string
55
- }
56
- /** Dashboard's last-known per-org alert state. */
57
- export declare interface RunnerAlertState {
58
- org: string
59
- alerting: boolean
60
- lastAlertedAt: string | null
61
- lastClearedAt: string | null
62
- }
63
- /** Action the caller should take for one org. */
64
- export declare interface PressureAction {
65
- org: string
66
- action: 'fire' | 'clear'
67
- current: RunnerSample
68
- sustainedMs: number
69
- }
70
- export declare interface DetectOptions {
71
- queuedThreshold: number
72
- windowMinutes: number
73
- now?: number
74
- }
@@ -1,27 +0,0 @@
1
- export function detectRunnerPressure(samples, alertStates, options) {
2
- const now = options.now ?? Date.now(), windowMs = options.windowMinutes * 60000, cutoffMs = now - windowMs, byOrg = new Map;
3
- for (const s of samples) {
4
- const t = Date.parse(s.sampledAt);
5
- if (Number.isNaN(t) || t < cutoffMs)
6
- continue;
7
- const list = byOrg.get(s.org) ?? [];
8
- list.push(s);
9
- byOrg.set(s.org, list);
10
- }
11
- const actions = [];
12
- for (const [org, orgSamples] of byOrg.entries()) {
13
- if (orgSamples.length === 0)
14
- continue;
15
- orgSamples.sort((a, b) => Date.parse(a.sampledAt) - Date.parse(b.sampledAt));
16
- const oldestMs = Date.parse(orgSamples[0].sampledAt), sustainedMs = Date.parse(orgSamples[orgSamples.length - 1].sampledAt) - oldestMs;
17
- if (sustainedMs < windowMs - 1000)
18
- continue;
19
- const allAboveOrEqual = orgSamples.every((s) => s.queued >= options.queuedThreshold), allBelow = orgSamples.every((s) => s.queued < options.queuedThreshold), isAlerting = alertStates.get(org)?.alerting ?? !1, current = orgSamples[orgSamples.length - 1];
20
- if (isAlerting) {
21
- if (allBelow)
22
- actions.push({ org, action: "clear", current, sustainedMs });
23
- } else if (allAboveOrEqual)
24
- actions.push({ org, action: "fire", current, sustainedMs });
25
- }
26
- return actions;
27
- }
package/dist/runners.d.ts DELETED
@@ -1,6 +0,0 @@
1
- /**
2
- * Count jobs (not runs) currently using runners. A workflow run can be
3
- * "in_progress" while some of its matrix jobs are still queued — only the
4
- * actually-running jobs occupy runners, so we sum at the job level.
5
- */
6
- export declare function fetchRepoActiveRuns(owner: string, name: string): Promise<{ running: number, queued: number }>;
package/dist/runners.js DELETED
@@ -1,26 +0,0 @@
1
- import { ghFetch, GITHUB_API } from "./client";
2
- export async function fetchRepoActiveRuns(owner, name) {
3
- try {
4
- const runRes = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs?status=in_progress&per_page=100`);
5
- if (!runRes.ok)
6
- return { running: 0, queued: 0 };
7
- const runs = (await runRes.json()).workflow_runs ?? [];
8
- if (runs.length === 0)
9
- return { running: 0, queued: 0 };
10
- return (await Promise.all(runs.map(async (r) => {
11
- const jobsRes = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs/${r.id}/jobs`);
12
- if (!jobsRes.ok)
13
- return { running: 0, queued: 0 };
14
- const jobsData = await jobsRes.json();
15
- let running = 0, queued = 0;
16
- for (const j of jobsData.jobs ?? [])
17
- if (j.status === "in_progress")
18
- running++;
19
- else if (j.status === "queued")
20
- queued++;
21
- return { running, queued };
22
- }))).reduce((a, b) => ({ running: a.running + b.running, queued: a.queued + b.queued }), { running: 0, queued: 0 });
23
- } catch {
24
- return { running: 0, queued: 0 };
25
- }
26
- }
package/dist/runs.d.ts DELETED
@@ -1,3 +0,0 @@
1
- import type { FailedJob, RepoStatus } from './types';
2
- export declare function fetchFailedJobs(owner: string, name: string, runId: number): Promise<FailedJob[]>;
3
- export declare function fetchRepoStatus(owner: string, name: string, defaultBranch: string): Promise<RepoStatus>;
package/dist/runs.js DELETED
@@ -1,84 +0,0 @@
1
- import { ghFetch, GITHUB_API } from "./client";
2
- async function fillLatestCommit(base, owner, name, branch) {
3
- try {
4
- const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/commits?sha=${branch}&per_page=1`);
5
- if (!res.ok)
6
- return;
7
- const commits = await res.json(), [c] = commits;
8
- if (!c)
9
- return;
10
- base.commitSha = c.sha.slice(0, 7);
11
- base.commitMessage = c.commit.message.split(`
12
- `)[0] ?? null;
13
- base.commitUrl = `https://github.com/${owner}/${name}/commit/${c.sha}`;
14
- base.commitAuthor = c.commit.author?.name ?? c.author?.login ?? null;
15
- base.updatedAt = c.commit.author?.date ?? null;
16
- } catch {}
17
- }
18
- export async function fetchFailedJobs(owner, name, runId) {
19
- try {
20
- const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs/${runId}/jobs?filter=latest`);
21
- if (!res.ok)
22
- return [];
23
- return (await res.json()).jobs.filter((j) => j.conclusion && j.conclusion !== "success" && j.conclusion !== "skipped").map((j) => ({ name: j.name, conclusion: j.conclusion, url: j.html_url }));
24
- } catch {
25
- return [];
26
- }
27
- }
28
- export async function fetchRepoStatus(owner, name, defaultBranch) {
29
- const base = {
30
- name,
31
- owner,
32
- fullName: `${owner}/${name}`,
33
- url: `https://github.com/${owner}/${name}`,
34
- defaultBranch,
35
- status: "no_runs",
36
- conclusion: null,
37
- workflowName: null,
38
- commitSha: null,
39
- commitMessage: null,
40
- commitUrl: null,
41
- commitAuthor: null,
42
- commitCount: null,
43
- updatedAt: null,
44
- runUrl: null,
45
- failedJobs: [],
46
- renovatePRs: 0,
47
- renovatePRsUrl: null,
48
- actionsPRs: 0,
49
- actionsPRsUrl: null
50
- };
51
- try {
52
- const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs?branch=${defaultBranch}&event=push&per_page=1`);
53
- if (!res.ok) {
54
- base.status = "error";
55
- await fillLatestCommit(base, owner, name, defaultBranch);
56
- return base;
57
- }
58
- const data = await res.json(), [run] = data.workflow_runs ?? [];
59
- if (!run) {
60
- await fillLatestCommit(base, owner, name, defaultBranch);
61
- return base;
62
- }
63
- base.workflowName = run.name;
64
- base.commitSha = run.head_sha.slice(0, 7);
65
- base.commitMessage = run.head_commit?.message.split(`
66
- `)[0] ?? null;
67
- base.commitUrl = `https://github.com/${owner}/${name}/commit/${run.head_sha}`;
68
- base.commitAuthor = run.head_commit?.author?.name ?? run.actor?.login ?? null;
69
- base.updatedAt = run.updated_at;
70
- base.runUrl = run.html_url;
71
- if (run.status === "completed") {
72
- base.status = run.conclusion === "success" ? "success" : "failure";
73
- base.conclusion = run.conclusion;
74
- if (base.status === "failure")
75
- base.failedJobs = await fetchFailedJobs(owner, name, run.id);
76
- } else {
77
- base.status = "pending";
78
- base.conclusion = run.status;
79
- }
80
- } catch {
81
- base.status = "error";
82
- }
83
- return base;
84
- }
package/dist/types.d.ts DELETED
@@ -1,59 +0,0 @@
1
- export declare interface FailedJob {
2
- name: string
3
- conclusion: string
4
- url: string
5
- }
6
- export declare interface RepoStatus {
7
- name: string
8
- owner: string
9
- fullName: string
10
- url: string
11
- defaultBranch: string
12
- status: RepoStatusKind
13
- conclusion: string | null
14
- workflowName: string | null
15
- commitSha: string | null
16
- commitMessage: string | null
17
- commitUrl: string | null
18
- commitAuthor: string | null
19
- commitCount: number | null
20
- updatedAt: string | null
21
- runUrl: string | null
22
- failedJobs: FailedJob[]
23
- renovatePRs: number
24
- renovatePRsUrl: string | null
25
- actionsPRs: number
26
- actionsPRsUrl: string | null
27
- }
28
- export declare interface OrgRunnerUsage {
29
- running: number
30
- queued: number
31
- cap: number
32
- }
33
- export declare interface DashboardData {
34
- repos: RepoStatus[]
35
- fetchedAt: string
36
- total: number
37
- passing: number
38
- failing: number
39
- pending: number
40
- noRuns: number
41
- runners: Record<string, OrgRunnerUsage>
42
- }
43
- export declare interface DashboardOptions {
44
- orgs: string[]
45
- runnerCaps?: Record<string, number>
46
- defaultRunnerCap?: number
47
- ignoreRepos?: string[]
48
- cacheTtlMs?: number
49
- cachePath?: string
50
- }
51
- export declare interface Repo {
52
- name: string
53
- owner: string
54
- full_name: string
55
- html_url: string
56
- default_branch: string
57
- archived: boolean
58
- }
59
- export type RepoStatusKind = 'success' | 'failure' | 'pending' | 'no_runs' | 'error';
package/dist/types.js DELETED
File without changes