playbook/outfitter-agents/plugins/outfitter/commands/pr/scripts/pr-status.ts

307 lines
7.5 KiB
TypeScript

#!/usr/bin/env bun
/**
* PR Status Script
* Fetches open PRs for a repository with review comments and status info.
*
* Usage:
* bun pr-status.ts [--repo org/repo]
*
* If --repo is omitted, uses the current repository.
*/
import { $ } from "bun";
/**
* A PR review thread with resolution status.
*/
interface ReviewThread {
/** Whether the thread has been resolved */
isResolved: boolean;
/** Comments in the thread */
comments: { body: string; author: { login: string } }[];
}
/**
* Normalized pull request data for display.
*/
interface PR {
/** PR number */
number: number;
/** PR title */
title: string;
/** URL to the PR on GitHub */
url: string;
/** Whether PR is in draft state */
isDraft: boolean;
/** PR author info */
author: { login: string };
/** Branch name */
headRefName: string;
/** ISO timestamp of creation */
createdAt: string;
/** ISO timestamp of last update */
updatedAt: string;
/** Review decision (APPROVED, CHANGES_REQUESTED, etc.) */
reviewDecision: string | null;
/** CI/CD check status rollup */
statusCheckRollup: {
state: string;
contexts: { name: string; state: string; conclusion: string | null }[];
} | null;
/** Review comment threads */
reviewThreads: { nodes: ReviewThread[] };
}
/**
* Raw PR response structure from GitHub GraphQL API.
*/
interface PRResponse {
/** PR number */
number: number;
/** PR title */
title: string;
/** URL to the PR */
url: string;
/** Draft state */
isDraft: boolean;
/** Author info */
author: { login: string };
/** Branch name */
headRefName: string;
/** Creation timestamp */
createdAt: string;
/** Last update timestamp */
updatedAt: string;
/** Review decision */
reviewDecision: string | null;
/** Commits with status checks */
commits: {
nodes: {
commit: {
statusCheckRollup: {
state: string;
contexts: {
nodes: { name: string; state: string; conclusion: string | null }[];
};
} | null;
};
}[];
};
/** Review threads */
reviewThreads: { nodes: ReviewThread[] };
}
function parseArgs(): { repo: string | null } {
const args = process.argv.slice(2);
let repo: string | null = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--repo" && args[i + 1]) {
repo = args[i + 1];
i++;
}
}
return { repo };
}
async function getRepo(specified: string | null): Promise<string> {
if (specified) return specified;
const result =
await $`gh repo view --json nameWithOwner -q '.nameWithOwner'`.text();
return result.trim();
}
async function fetchPRs(repo: string): Promise<PR[]> {
const query = `
query($repo: String!, $owner: String!) {
repository(name: $repo, owner: $owner) {
pullRequests(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
title
url
isDraft
author { login }
headRefName
createdAt
updatedAt
reviewDecision
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
state
contexts(first: 20) {
nodes {
... on CheckRun {
name
conclusion
status
}
... on StatusContext {
context
state
}
}
}
}
}
}
}
reviewThreads(first: 50) {
nodes {
isResolved
comments(first: 1) {
nodes {
body
author { login }
}
}
}
}
}
}
}
}
`;
const [owner, name] = repo.split("/");
const result =
await $`gh api graphql -f query=${query} -f owner=${owner} -f repo=${name}`.json();
const prs = result.data.repository.pullRequests.nodes as PRResponse[];
return prs.map((pr) => ({
number: pr.number,
title: pr.title,
url: pr.url,
isDraft: pr.isDraft,
author: pr.author,
headRefName: pr.headRefName,
createdAt: pr.createdAt,
updatedAt: pr.updatedAt,
reviewDecision: pr.reviewDecision,
statusCheckRollup: pr.commits.nodes[0]?.commit.statusCheckRollup
? {
state: pr.commits.nodes[0]!.commit.statusCheckRollup!.state,
contexts:
pr.commits.nodes[0]!.commit.statusCheckRollup!.contexts.nodes.map(
(ctx: Record<string, unknown>) => ({
name: (ctx.name as string) || (ctx.context as string) || "",
state: (ctx.status as string) || (ctx.state as string) || "",
conclusion: (ctx.conclusion as string) || null,
}),
),
}
: null,
reviewThreads: pr.reviewThreads,
}));
}
function formatStatus(pr: PR): string {
if (!pr.statusCheckRollup) return "none";
const state = pr.statusCheckRollup.state;
switch (state) {
case "SUCCESS":
return "passing";
case "FAILURE":
case "ERROR":
return "failing";
case "PENDING":
return "pending";
default:
return state.toLowerCase();
}
}
function formatReviewDecision(decision: string | null): string {
if (!decision) return "pending";
switch (decision) {
case "APPROVED":
return "approved";
case "CHANGES_REQUESTED":
return "changes requested";
case "REVIEW_REQUIRED":
return "review required";
default:
return decision.toLowerCase();
}
}
function countUnresolvedThreads(pr: PR): number {
return pr.reviewThreads.nodes.filter((t) => !t.isResolved).length;
}
function formatOutput(repo: string, prs: PR[]): void {
console.log(`\n## PR Status: ${repo}\n`);
console.log(`**Open PRs:** ${prs.length}\n`);
if (prs.length === 0) {
console.log("No open pull requests.");
return;
}
console.log(
"| # | Title | Author | State | Checks | Review | Unresolved |",
);
console.log(
"|---|-------|--------|-------|--------|--------|------------|",
);
for (const pr of prs) {
const title =
pr.title.length > 40 ? `${pr.title.slice(0, 37)}...` : pr.title;
const state = pr.isDraft ? "draft" : "ready";
const unresolved = countUnresolvedThreads(pr);
const unresolvedStr = unresolved > 0 ? `${unresolved}` : "-";
console.log(
`| [#${pr.number}](${pr.url}) | ${title} | @${pr.author.login} | ${state} | ${formatStatus(pr)} | ${formatReviewDecision(pr.reviewDecision)} | ${unresolvedStr} |`,
);
}
// Detailed unresolved comments section
const prsWithUnresolved = prs.filter(
(pr) => countUnresolvedThreads(pr) > 0,
);
if (prsWithUnresolved.length > 0) {
console.log("\n### Unresolved Review Comments\n");
for (const pr of prsWithUnresolved) {
const threads = pr.reviewThreads.nodes.filter((t) => !t.isResolved);
console.log(`**#${pr.number}** - ${threads.length} unresolved:`);
for (const thread of threads.slice(0, 5)) {
const comment = thread.comments.nodes[0];
if (comment) {
const preview =
comment.body.length > 80
? `${comment.body.slice(0, 77)}...`
: comment.body;
console.log(` - @${comment.author.login}: ${preview}`);
}
}
if (threads.length > 5) {
console.log(` - ... and ${threads.length - 5} more`);
}
console.log();
}
}
}
async function main() {
const { repo: specifiedRepo } = parseArgs();
try {
const repo = await getRepo(specifiedRepo);
const prs = await fetchPRs(repo);
formatOutput(repo, prs);
} catch (error) {
console.error("Error fetching PR status:", error);
process.exit(1);
}
}
main();