Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/scripts/bounty/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export interface GitHubApi {
getIssue(number: number): Promise<Issue>;
/// Fetch a full pull request by number. Throws on non-2xx.
getPullRequest(number: number): Promise<PullRequest>;
/// Fetch all open issues that have any label matching the given prefix.
/// Handles pagination automatically and excludes pull requests.
listIssuesWithLabelPrefix(prefix: string): Promise<Issue[]>;
/// Add one or more labels to an issue or PR. Batched into a single request.
addLabels(target: number, labels: string[]): Promise<void>;
/// Remove a single label from an issue or PR.
Expand Down Expand Up @@ -71,6 +74,28 @@ export class GitHubRestApi implements GitHubApi {
return this.request<PullRequest>("GET", `/pulls/${number}`);
}

async listIssuesWithLabelPrefix(prefix: string): Promise<Issue[]> {
const results: Issue[] = [];
let page = 1;
while (true) {
const batch = await this.request<Issue[]>(
"GET",
`/issues?state=open&per_page=100&page=${page}`
);
if (batch.length === 0) break;
for (const issue of batch) {
// Exclude pull requests (GitHub returns them in /issues)
if (issue.pull_request !== undefined) continue;
if (issue.labels.some((l) => l.name.startsWith(prefix))) {
results.push(issue);
}
}
if (batch.length < 100) break;
page++;
}
return results;
}

async addLabels(target: number, labels: string[]): Promise<void> {
await this.request("POST", `/issues/${target}/labels`, { labels });
}
Expand Down
27 changes: 21 additions & 6 deletions .github/scripts/bounty/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ function diff(
target: number,
current: Set<string>,
desired: Set<string>,
comment?: string
comment?: string,
meta?: { title?: string; url?: string }
): LabelOp | null {
const add = [...desired].filter((l) => !current.has(l));
const remove = [...current].filter((l) => !desired.has(l));
if (add.length === 0 && remove.length === 0 && !comment) return null;
return { target, add, remove, comment };
return { target, title: meta?.title, url: meta?.url, add, remove, comment };
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -88,7 +89,10 @@ export function computeIssuePatch({ issue, currentLabels }: IssueState): Patch {
desired.delete(BOUNTY_CLAIMED);
}

const op = diff(issue.number, currentLabels, desired);
const op = diff(issue.number, currentLabels, desired, undefined, {
title: issue.title,
url: issue.html_url,
});
return { ops: op ? [op] : [] };
}

Expand Down Expand Up @@ -131,7 +135,10 @@ export function computePrPatch({ pr, currentLabels, linkedIssues }: PrState): Pa
// Rule 2 — rewarded lifecycle.
prDesired.add(BOUNTY_REWARDED);

const prOp = diff(pr.number, currentLabels, prDesired);
const prOp = diff(pr.number, currentLabels, prDesired, undefined, {
title: pr.title,
url: pr.html_url,
});
if (prOp) ops.push(prOp);

// Update linked issues — only those that already have a bounty value label.
Expand All @@ -144,12 +151,18 @@ export function computePrPatch({ pr, currentLabels, linkedIssues }: PrState): Pa
issueDesired.add(BOUNTY_REWARDED);
issueDesired.delete(BOUNTY_CLAIMED);

const op = diff(issue.number, issueCurrent, issueDesired);
const op = diff(issue.number, issueCurrent, issueDesired, undefined, {
title: issue.title,
url: issue.html_url,
});
if (op) ops.push(op);
}
} else {
// Rule 1 — label propagation (pre-merge).
const prOp = diff(pr.number, currentLabels, prDesired);
const prOp = diff(pr.number, currentLabels, prDesired, undefined, {
title: pr.title,
url: pr.html_url,
});
if (prOp) ops.push(prOp);

// Rule 3 — comment on each issue whose value label was newly propagated.
Expand All @@ -159,6 +172,8 @@ export function computePrPatch({ pr, currentLabels, linkedIssues }: PrState): Pa
if (hadValueLabel) {
ops.push({
target: issue.number,
title: issue.title,
url: issue.html_url,
add: [],
remove: [],
comment: `PR [#${pr.number}](${pr.html_url}) has been opened for this bounty by @${pr.user.login}.`,
Expand Down
77 changes: 77 additions & 0 deletions .github/scripts/bounty/src/sync-all-issues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env tsx
// Syncs bounty labels across ALL open issues that carry any "bounty" label.
//
// Without --execute: fetches all matching issues, computes the combined patch,
// and prints a plan showing exactly what would change. No writes are made.
//
// With --execute: fetches, computes, and applies the patch for every issue.
//
// Usage:
// tsx sync-all-issues.ts --repo <owner/repo> --token <token> [--execute]

import * as url from "url";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { GitHubRestApi, type GitHubApi } from "./api.js";
import { computeIssuePatch } from "./rules.js";
import { applyPatch, printPlan, resolveToken } from "./sync-issue.js";
import type { Patch } from "./types.js";

const BOUNTY_LABEL_PREFIX = "bounty";

export interface PlanAllIssuesInput {
api: GitHubApi;
}

/// Fetches all open issues with any bounty label and computes the combined patch.
/// Makes no writes — safe to call at any time.
export async function planAllIssues({ api }: PlanAllIssuesInput): Promise<Patch> {
const issues = await api.listIssuesWithLabelPrefix(BOUNTY_LABEL_PREFIX);
const ops = issues.flatMap((issue) => {
const currentLabels = new Set(issue.labels.map((l) => l.name));
return computeIssuePatch({ issue, currentLabels }).ops;
});
return { ops };
}

/// Fetches, computes, and applies the label patch for all bounty issues.
/// Returns the patch that was applied.
export async function syncAllIssues({ api }: PlanAllIssuesInput): Promise<Patch> {
const patch = await planAllIssues({ api });
await applyPatch(patch, api);
return patch;
}

// ---------------------------------------------------------------------------
// CLI entrypoint
// ---------------------------------------------------------------------------

if (process.argv[1] === url.fileURLToPath(import.meta.url)) {
const argv = await yargs(hideBin(process.argv))
.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 syncAllIssues({ api });
if (patch.ops.length === 0) {
console.log("All issues already in sync — no changes needed.");
}
} else {
const patch = await planAllIssues({ api });
printPlan(patch, "All bounty issues");
}
}
27 changes: 17 additions & 10 deletions .github/scripts/bounty/src/sync-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

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";
Expand Down Expand Up @@ -62,35 +63,41 @@ export async function syncIssue({ issueNumber, api }: PlanIssueInput): Promise<P
/// Each target gets one batched addLabels call; each removal is individual.
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(`#${op.target}: added [${op.add.join(", ")}]`);
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(`#${op.target}: removed "${label}"`);
console.log(`${chalk.red("✖")} ${ref}: removed "${chalk.red(label)}"`);
}
if (op.comment) {
await api.addComment(op.target, op.comment);
console.log(`#${op.target}: posted comment`);
console.log(`${chalk.yellow("✉")} ${ref}: posted comment`);
}
}
}

/// Prints a human-readable plan to stdout without making any API calls.
export function printPlan(patch: Patch, subject: string): void {
if (patch.ops.length === 0) {
console.log(`${subject}: already in sync — no changes needed.`);
console.log(`${chalk.green("✔")} ${chalk.bold(subject)}: already in sync — no changes needed.`);
return;
}
console.log(`${subject}: plan (${patch.ops.length} target(s) to update)\n`);
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) {
console.log(` #${op.target}:`);
if (op.add.length > 0) console.log(` + add: ${op.add.join(", ")}`);
if (op.remove.length > 0) console.log(` - remove: ${op.remove.join(", ")}`);
if (op.comment) console.log(` ~ comment: ${op.comment.slice(0, 80)}…`);
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("\nRun with --execute to apply.");
console.log(`\n${chalk.dim("Run with --execute to apply.")}`);
}

// ---------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions .github/scripts/bounty/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface User {
export interface Issue {
number: number;
title: string;
html_url: string;
state: "open" | "closed";
labels: Label[];
assignees: User[];
Expand All @@ -29,6 +30,7 @@ export interface Issue {
/// Full pull request as returned by GET /repos/:owner/:repo/pulls/:number
export interface PullRequest {
number: number;
title: string;
state: "open" | "closed";
merged: boolean;
body: string | null;
Expand Down Expand Up @@ -64,6 +66,10 @@ export interface PrState {
/// A label operation on a single target (issue or PR number).
export interface LabelOp {
target: number;
/// Human-readable title of the issue or PR, for display in plan output.
title?: string;
/// URL of the issue or PR, for display in plan output.
url?: string;
add: string[];
remove: string[];
/// Optional comment to post on the target after label ops.
Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/bounty/tests/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { Issue, PullRequest, IssueState, PrState } from "../src/types.js";
function makeIssue(overrides: Partial<Issue> & { number: number }): Issue {
return {
title: "Test issue",
html_url: `https://github.com/owner/repo/issues/${overrides.number}`,
state: "open",
labels: [],
assignees: [],
Expand All @@ -26,6 +27,7 @@ function makeIssue(overrides: Partial<Issue> & { number: number }): Issue {

function makePr(overrides: Partial<PullRequest> & { number: number }): PullRequest {
return {
title: "Test PR",
state: "open",
merged: false,
body: null,
Expand Down
Loading
Loading