| #!/usr/bin/env tsx |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import * as url from "url"; |
| import { execSync } from "child_process"; |
| import chalk from "chalk"; |
| import yargs from "yargs"; |
| import { hideBin } from "yargs/helpers"; |
| import { GitHubRestApi, type GitHubApi } from "./api.js"; |
| import { computeIssuePatch } from "./rules.js"; |
| import type { Patch } from "./types.js"; |
|
|
| |
| |
| |
| export function resolveToken(flag: string | undefined): string { |
| const token = flag || process.env["GITHUB_TOKEN"] || (() => { |
| try { |
| return execSync("gh auth token", { encoding: "utf8" }).trim(); |
| } catch { |
| return ""; |
| } |
| })(); |
| if (!token) { |
| console.error( |
| "Error: no GitHub token found. Pass --token, set GITHUB_TOKEN, or run `gh auth login`." |
| ); |
| process.exit(1); |
| } |
| return token; |
| } |
|
|
| export interface PlanIssueInput { |
| issueNumber: number; |
| api: GitHubApi; |
| } |
|
|
| |
| |
| export async function planIssue({ issueNumber, api }: PlanIssueInput): Promise<Patch> { |
| const issue = await api.getIssue(issueNumber); |
| const currentLabels = new Set(issue.labels.map((l) => l.name)); |
| return computeIssuePatch({ issue, currentLabels }); |
| } |
|
|
| |
| |
| export async function syncIssue({ issueNumber, api }: PlanIssueInput): Promise<Patch> { |
| const patch = await planIssue({ issueNumber, api }); |
| await applyPatch(patch, api); |
| return patch; |
| } |
|
|
| |
| |
| export async function applyPatch(patch: Patch, api: GitHubApi): Promise<void> { |
| for (const op of patch.ops) { |
| const ref = chalk.cyan(`#${op.target}`); |
| if (op.add.length > 0) { |
| await api.addLabels(op.target, op.add); |
| console.log(`${chalk.green("✔")} ${ref}: added [${chalk.green(op.add.join(", "))}]`); |
| } |
| for (const label of op.remove) { |
| await api.removeLabel(op.target, label); |
| console.log(`${chalk.red("✖")} ${ref}: removed "${chalk.red(label)}"`); |
| } |
| if (op.comment) { |
| await api.addComment(op.target, op.comment); |
| console.log(`${chalk.yellow("✉")} ${ref}: posted comment`); |
| } |
| } |
| } |
|
|
| |
| export function printPlan(patch: Patch, subject: string): void { |
| if (patch.ops.length === 0) { |
| console.log(`${chalk.green("✔")} ${chalk.bold(subject)}: already in sync — no changes needed.`); |
| return; |
| } |
| console.log(`${chalk.yellow("●")} ${chalk.bold(subject)}: plan (${chalk.bold(String(patch.ops.length))} target(s) to update)\n`); |
| for (const op of patch.ops) { |
| const title = op.title ? chalk.bold(` ${op.title}`) : ""; |
| const href = op.url ? `\n ${chalk.dim(chalk.blue(op.url))}` : ""; |
| console.log(` ${chalk.cyan(`#${op.target}`)}${title}${href}`); |
| if (op.add.length > 0) |
| console.log(` ${chalk.green("+")} add: ${chalk.green(op.add.join(", "))}`); |
| if (op.remove.length > 0) |
| console.log(` ${chalk.red("-")} remove: ${chalk.red(op.remove.join(", "))}`); |
| if (op.comment) |
| console.log(` ${chalk.yellow("~")} comment: ${chalk.dim(op.comment.slice(0, 80))}…`); |
| } |
| console.log(`\n${chalk.dim("Run with --execute to apply.")}`); |
| } |
|
|
| |
| |
| |
|
|
| if (process.argv[1] === url.fileURLToPath(import.meta.url)) { |
| const argv = await yargs(hideBin(process.argv)) |
| .option("issue", { type: "number", demandOption: true, description: "Issue number" }) |
| .option("repo", { type: "string", demandOption: true, description: "owner/repo" }) |
| .option("token", { type: "string", description: "GitHub token (falls back to GITHUB_TOKEN env var or `gh auth token`)" }) |
| .option("execute", { |
| type: "boolean", |
| default: false, |
| description: "Apply the patch. Without this flag only the plan is printed.", |
| }) |
| .strict() |
| .parseAsync(); |
|
|
| const [owner, repo] = argv.repo.split("/") as [string, string]; |
| const token = resolveToken(argv.token); |
| const api = new GitHubRestApi(owner, repo, token); |
|
|
| if (argv.execute) { |
| const patch = await syncIssue({ issueNumber: argv.issue, api }); |
| if (patch.ops.length === 0) { |
| console.log(`Issue #${argv.issue}: already in sync, no changes needed.`); |
| } |
| } else { |
| const patch = await planIssue({ issueNumber: argv.issue, api }); |
| printPlan(patch, `Issue #${argv.issue}`); |
| } |
| } |
|
|