@stacksjs/github 0.70.57 → 0.70.58

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/dist/bots.js ADDED
@@ -0,0 +1,21 @@
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.js ADDED
@@ -0,0 +1,43 @@
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
+ }
@@ -0,0 +1,110 @@
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
+ }
@@ -0,0 +1,67 @@
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.js CHANGED
@@ -1,18 +1,9 @@
1
- // @bun
2
- export {
3
- mapWithConcurrency,
4
- ghHeaders,
5
- ghFetch,
6
- getDashboardData,
7
- fetchWorkflowRuns,
8
- fetchRunJobs,
9
- fetchRepoStatus,
10
- fetchRepoActiveRuns,
11
- fetchFailedJobs,
12
- fetchBotPRCounts,
13
- fetchAllRepos,
14
- detectRunnerPressure,
15
- detectNewlyFailedRuns,
16
- clearDashboardCache,
17
- GITHUB_API
18
- };
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.js ADDED
@@ -0,0 +1,26 @@
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
+ }
@@ -0,0 +1,63 @@
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
+ }
@@ -0,0 +1,27 @@
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
+ }
@@ -0,0 +1,26 @@
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.js ADDED
@@ -0,0 +1,86 @@
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();
8
+ if (commits.length === 0)
9
+ return;
10
+ const c = commits[0];
11
+ base.commitSha = c.sha.slice(0, 7);
12
+ base.commitMessage = c.commit.message.split(`
13
+ `)[0];
14
+ base.commitUrl = `https://github.com/${owner}/${name}/commit/${c.sha}`;
15
+ base.commitAuthor = c.commit.author?.name ?? c.author?.login ?? null;
16
+ base.updatedAt = c.commit.author?.date ?? null;
17
+ } catch {}
18
+ }
19
+ export async function fetchFailedJobs(owner, name, runId) {
20
+ try {
21
+ const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs/${runId}/jobs?filter=latest`);
22
+ if (!res.ok)
23
+ return [];
24
+ 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 }));
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+ export async function fetchRepoStatus(owner, name, defaultBranch) {
30
+ const base = {
31
+ name,
32
+ owner,
33
+ fullName: `${owner}/${name}`,
34
+ url: `https://github.com/${owner}/${name}`,
35
+ defaultBranch,
36
+ status: "no_runs",
37
+ conclusion: null,
38
+ workflowName: null,
39
+ commitSha: null,
40
+ commitMessage: null,
41
+ commitUrl: null,
42
+ commitAuthor: null,
43
+ commitCount: null,
44
+ updatedAt: null,
45
+ runUrl: null,
46
+ failedJobs: [],
47
+ renovatePRs: 0,
48
+ renovatePRsUrl: null,
49
+ actionsPRs: 0,
50
+ actionsPRsUrl: null
51
+ };
52
+ try {
53
+ const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${name}/actions/runs?branch=${defaultBranch}&event=push&per_page=1`);
54
+ if (!res.ok) {
55
+ base.status = "error";
56
+ await fillLatestCommit(base, owner, name, defaultBranch);
57
+ return base;
58
+ }
59
+ const data = await res.json();
60
+ if (!data.workflow_runs || data.workflow_runs.length === 0) {
61
+ await fillLatestCommit(base, owner, name, defaultBranch);
62
+ return base;
63
+ }
64
+ const run = data.workflow_runs[0];
65
+ base.workflowName = run.name;
66
+ base.commitSha = run.head_sha.slice(0, 7);
67
+ base.commitMessage = run.head_commit?.message.split(`
68
+ `)[0] ?? null;
69
+ base.commitUrl = `https://github.com/${owner}/${name}/commit/${run.head_sha}`;
70
+ base.commitAuthor = run.head_commit?.author?.name ?? run.actor?.login ?? null;
71
+ base.updatedAt = run.updated_at;
72
+ base.runUrl = run.html_url;
73
+ if (run.status === "completed") {
74
+ base.status = run.conclusion === "success" ? "success" : "failure";
75
+ base.conclusion = run.conclusion;
76
+ if (base.status === "failure")
77
+ base.failedJobs = await fetchFailedJobs(owner, name, run.id);
78
+ } else {
79
+ base.status = "pending";
80
+ base.conclusion = run.status;
81
+ }
82
+ } catch {
83
+ base.status = "error";
84
+ }
85
+ return base;
86
+ }
package/dist/types.js ADDED
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/github",
3
3
  "type": "module",
4
- "version": "0.70.57",
4
+ "version": "0.70.58",
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": [