283 lines
6.2 KiB
TypeScript
Executable File
283 lines
6.2 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* GitHub gatherer for status
|
|
*
|
|
* Collects data via `gh` CLI:
|
|
* - Open PRs with CI status and review decisions
|
|
* - Recent workflow runs
|
|
*/
|
|
|
|
import { parseArgs } from "node:util";
|
|
import { parseTimeConstraint, toCutoffDate } from "../lib/time";
|
|
import type {
|
|
GathererResult,
|
|
GitHubData,
|
|
GitHubPR,
|
|
GitHubWorkflowRun,
|
|
} from "../lib/types";
|
|
|
|
const { values } = parseArgs({
|
|
args: Bun.argv.slice(2),
|
|
options: {
|
|
time: { type: "string", short: "t", default: "24h" },
|
|
repo: { type: "string", short: "r" },
|
|
help: { type: "boolean", short: "h" },
|
|
},
|
|
});
|
|
|
|
if (values.help) {
|
|
console.log(`
|
|
github-gatherer.ts - Gather GitHub PR and CI data
|
|
|
|
Usage:
|
|
./github-gatherer.ts [options]
|
|
|
|
Options:
|
|
-t, --time <constraint> Time constraint (24h, 7d, 2w) [default: 24h]
|
|
-r, --repo <owner/repo> Repository [default: current repo]
|
|
-h, --help Show this help
|
|
|
|
Output:
|
|
JSON GathererResult with GitHubData
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
/**
|
|
* Result of running a gh CLI command.
|
|
*/
|
|
interface GhOutput<T> {
|
|
success: boolean;
|
|
data?: T;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Runs a gh CLI command and parses JSON output.
|
|
* @param args - Arguments to pass to gh
|
|
* @returns Parsed output or error
|
|
*/
|
|
async function runGh<T>(args: string[]): Promise<GhOutput<T>> {
|
|
const repoArgs = values.repo ? ["-R", values.repo] : [];
|
|
|
|
const proc = Bun.spawn(["gh", ...repoArgs, ...args], {
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
const exitCode = await proc.exited;
|
|
|
|
if (exitCode !== 0) {
|
|
return {
|
|
success: false,
|
|
error: stderr || `gh exited with code ${exitCode}`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(stdout);
|
|
return { success: true, data };
|
|
} catch {
|
|
return { success: false, error: `Failed to parse gh output: ${stdout}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if gh CLI is installed.
|
|
* @returns True if gh is available
|
|
*/
|
|
async function checkGhAvailable(): Promise<boolean> {
|
|
const proc = Bun.spawn(["which", "gh"], {
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const exitCode = await proc.exited;
|
|
return exitCode === 0;
|
|
}
|
|
|
|
/**
|
|
* Checks if gh CLI is authenticated.
|
|
* @returns True if authenticated
|
|
*/
|
|
async function checkGhAuth(): Promise<boolean> {
|
|
const proc = Bun.spawn(["gh", "auth", "status"], {
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const exitCode = await proc.exited;
|
|
return exitCode === 0;
|
|
}
|
|
|
|
/**
|
|
* Gets the current repository name (owner/repo format).
|
|
* @returns Repository name or null if not in a repo
|
|
*/
|
|
async function getRepoName(): Promise<string | null> {
|
|
if (values.repo) return values.repo;
|
|
|
|
const proc = Bun.spawn(
|
|
["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
|
|
{
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
},
|
|
);
|
|
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const exitCode = await proc.exited;
|
|
|
|
if (exitCode !== 0) return null;
|
|
return stdout.trim();
|
|
}
|
|
|
|
// GraphQL query for PRs with status checks
|
|
const PR_QUERY_FIELDS = [
|
|
"number",
|
|
"title",
|
|
"state",
|
|
"isDraft",
|
|
"author",
|
|
"updatedAt",
|
|
"url",
|
|
"headRefName",
|
|
"statusCheckRollup",
|
|
"reviewDecision",
|
|
].join(",");
|
|
|
|
/**
|
|
* Gathers GitHub data including PRs and workflow runs.
|
|
* @returns Gatherer result with GitHub data
|
|
*/
|
|
async function gatherGitHubData(): Promise<GathererResult<GitHubData>> {
|
|
const timestamp = new Date().toISOString();
|
|
|
|
// Check gh CLI availability
|
|
const ghAvailable = await checkGhAvailable();
|
|
if (!ghAvailable) {
|
|
return {
|
|
source: "github",
|
|
status: "unavailable",
|
|
reason: "gh CLI not installed",
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
// Check authentication
|
|
const ghAuth = await checkGhAuth();
|
|
if (!ghAuth) {
|
|
return {
|
|
source: "github",
|
|
status: "unavailable",
|
|
reason: "gh CLI not authenticated (run: gh auth login)",
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
// Get repo name
|
|
const repo = await getRepoName();
|
|
if (!repo) {
|
|
return {
|
|
source: "github",
|
|
status: "unavailable",
|
|
reason: "Not in a GitHub repository",
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
// Parse time constraint
|
|
const timeValue = values.time ?? "24h";
|
|
let timeMs: number;
|
|
try {
|
|
timeMs = parseTimeConstraint(timeValue);
|
|
} catch (e) {
|
|
return {
|
|
source: "github",
|
|
status: "error",
|
|
error: e instanceof Error ? e.message : "Invalid time constraint",
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
const cutoff = toCutoffDate(timeMs);
|
|
const _cutoffDate = cutoff.toISOString().split("T")[0]; // YYYY-MM-DD for search
|
|
|
|
// Gather data in parallel
|
|
const [prsResult, runsResult] = await Promise.all([
|
|
// Get open PRs (no date filter needed - we want all open)
|
|
runGh<GitHubPR[]>([
|
|
"pr",
|
|
"list",
|
|
"--state=open",
|
|
"--json",
|
|
PR_QUERY_FIELDS,
|
|
"--limit=20",
|
|
]),
|
|
// Get recent workflow runs
|
|
runGh<GitHubWorkflowRun[]>([
|
|
"run",
|
|
"list",
|
|
"--json",
|
|
"name,status,conclusion,createdAt,url",
|
|
"--limit=20",
|
|
]),
|
|
]);
|
|
|
|
if (!prsResult.success && !runsResult.success) {
|
|
return {
|
|
source: "github",
|
|
status: "error",
|
|
error:
|
|
prsResult.error || runsResult.error || "Failed to fetch GitHub data",
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
// Transform PR data to match our types
|
|
const openPRs: GitHubPR[] = (prsResult.data || []).map(
|
|
(pr: Record<string, unknown>) => ({
|
|
number: pr.number as number,
|
|
title: pr.title as string,
|
|
state: pr.state as "OPEN" | "CLOSED" | "MERGED",
|
|
isDraft: pr.isDraft as boolean,
|
|
author: pr.author as { login: string },
|
|
updatedAt: pr.updatedAt as string,
|
|
url: pr.url as string,
|
|
headRefName: pr.headRefName as string,
|
|
statusCheckRollup: pr.statusCheckRollup as GitHubPR["statusCheckRollup"],
|
|
reviewDecision: pr.reviewDecision as GitHubPR["reviewDecision"],
|
|
}),
|
|
);
|
|
|
|
// Filter workflow runs by time
|
|
const allRuns = runsResult.data || [];
|
|
const recentRuns: GitHubWorkflowRun[] = allRuns
|
|
.filter(
|
|
(run: Record<string, unknown>) =>
|
|
new Date(run.createdAt as string) >= cutoff,
|
|
)
|
|
.map((run: Record<string, unknown>) => ({
|
|
name: run.name as string,
|
|
status: run.status as string,
|
|
conclusion: run.conclusion as string | null,
|
|
createdAt: run.createdAt as string,
|
|
url: run.url as string,
|
|
}));
|
|
|
|
return {
|
|
source: "github",
|
|
status: "success",
|
|
data: {
|
|
repo,
|
|
openPRs,
|
|
recentRuns,
|
|
},
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
// Main execution
|
|
const result = await gatherGitHubData();
|
|
console.log(JSON.stringify(result, null, 2));
|