Skip to content

Commit 1962dae

Browse files
tusharmathforge-code-agentautofix-ci[bot]
authored
chore(bounty): overhaul bounty automation with bulk sync and daily schedule (#2663)
Co-authored-by: ForgeCode <noreply@forgecode.dev> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent b885d5f commit 1962dae

14 files changed

Lines changed: 346 additions & 43 deletions

File tree

.github/scripts/bounty/src/api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export interface GitHubApi {
1313
getIssue(number: number): Promise<Issue>;
1414
/// Fetch a full pull request by number. Throws on non-2xx.
1515
getPullRequest(number: number): Promise<PullRequest>;
16+
/// Fetch all open issues that have any label matching the given prefix.
17+
/// Handles pagination automatically and excludes pull requests.
18+
listIssuesWithLabelPrefix(prefix: string): Promise<Issue[]>;
1619
/// Add one or more labels to an issue or PR. Batched into a single request.
1720
addLabels(target: number, labels: string[]): Promise<void>;
1821
/// Remove a single label from an issue or PR.
@@ -71,6 +74,28 @@ export class GitHubRestApi implements GitHubApi {
7174
return this.request<PullRequest>("GET", `/pulls/${number}`);
7275
}
7376

77+
async listIssuesWithLabelPrefix(prefix: string): Promise<Issue[]> {
78+
const results: Issue[] = [];
79+
let page = 1;
80+
while (true) {
81+
const batch = await this.request<Issue[]>(
82+
"GET",
83+
`/issues?state=open&per_page=100&page=${page}`
84+
);
85+
if (batch.length === 0) break;
86+
for (const issue of batch) {
87+
// Exclude pull requests (GitHub returns them in /issues)
88+
if (issue.pull_request !== undefined) continue;
89+
if (issue.labels.some((l) => l.name.startsWith(prefix))) {
90+
results.push(issue);
91+
}
92+
}
93+
if (batch.length < 100) break;
94+
page++;
95+
}
96+
return results;
97+
}
98+
7499
async addLabels(target: number, labels: string[]): Promise<void> {
75100
await this.request("POST", `/issues/${target}/labels`, { labels });
76101
}

.github/scripts/bounty/src/rules.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,13 @@ function diff(
4646
target: number,
4747
current: Set<string>,
4848
desired: Set<string>,
49-
comment?: string
49+
comment?: string,
50+
meta?: { title?: string; url?: string }
5051
): LabelOp | null {
5152
const add = [...desired].filter((l) => !current.has(l));
5253
const remove = [...current].filter((l) => !desired.has(l));
5354
if (add.length === 0 && remove.length === 0 && !comment) return null;
54-
return { target, add, remove, comment };
55+
return { target, title: meta?.title, url: meta?.url, add, remove, comment };
5556
}
5657

5758
// ---------------------------------------------------------------------------
@@ -88,7 +89,10 @@ export function computeIssuePatch({ issue, currentLabels }: IssueState): Patch {
8889
desired.delete(BOUNTY_CLAIMED);
8990
}
9091

91-
const op = diff(issue.number, currentLabels, desired);
92+
const op = diff(issue.number, currentLabels, desired, undefined, {
93+
title: issue.title,
94+
url: issue.html_url,
95+
});
9296
return { ops: op ? [op] : [] };
9397
}
9498

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

134-
const prOp = diff(pr.number, currentLabels, prDesired);
138+
const prOp = diff(pr.number, currentLabels, prDesired, undefined, {
139+
title: pr.title,
140+
url: pr.html_url,
141+
});
135142
if (prOp) ops.push(prOp);
136143

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

147-
const op = diff(issue.number, issueCurrent, issueDesired);
154+
const op = diff(issue.number, issueCurrent, issueDesired, undefined, {
155+
title: issue.title,
156+
url: issue.html_url,
157+
});
148158
if (op) ops.push(op);
149159
}
150160
} else {
151161
// Rule 1 — label propagation (pre-merge).
152-
const prOp = diff(pr.number, currentLabels, prDesired);
162+
const prOp = diff(pr.number, currentLabels, prDesired, undefined, {
163+
title: pr.title,
164+
url: pr.html_url,
165+
});
153166
if (prOp) ops.push(prOp);
154167

155168
// Rule 3 — comment on each issue whose value label was newly propagated.
@@ -159,6 +172,8 @@ export function computePrPatch({ pr, currentLabels, linkedIssues }: PrState): Pa
159172
if (hadValueLabel) {
160173
ops.push({
161174
target: issue.number,
175+
title: issue.title,
176+
url: issue.html_url,
162177
add: [],
163178
remove: [],
164179
comment: `PR [#${pr.number}](${pr.html_url}) has been opened for this bounty by @${pr.user.login}.`,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env tsx
2+
// Syncs bounty labels across ALL open issues that carry any "bounty" label.
3+
//
4+
// Without --execute: fetches all matching issues, computes the combined patch,
5+
// and prints a plan showing exactly what would change. No writes are made.
6+
//
7+
// With --execute: fetches, computes, and applies the patch for every issue.
8+
//
9+
// Usage:
10+
// tsx sync-all-issues.ts --repo <owner/repo> --token <token> [--execute]
11+
12+
import * as url from "url";
13+
import yargs from "yargs";
14+
import { hideBin } from "yargs/helpers";
15+
import { GitHubRestApi, type GitHubApi } from "./api.js";
16+
import { computeIssuePatch } from "./rules.js";
17+
import { applyPatch, printPlan, resolveToken } from "./sync-issue.js";
18+
import type { Patch } from "./types.js";
19+
20+
const BOUNTY_LABEL_PREFIX = "bounty";
21+
22+
export interface PlanAllIssuesInput {
23+
api: GitHubApi;
24+
}
25+
26+
/// Fetches all open issues with any bounty label and computes the combined patch.
27+
/// Makes no writes — safe to call at any time.
28+
export async function planAllIssues({ api }: PlanAllIssuesInput): Promise<Patch> {
29+
const issues = await api.listIssuesWithLabelPrefix(BOUNTY_LABEL_PREFIX);
30+
const ops = issues.flatMap((issue) => {
31+
const currentLabels = new Set(issue.labels.map((l) => l.name));
32+
return computeIssuePatch({ issue, currentLabels }).ops;
33+
});
34+
return { ops };
35+
}
36+
37+
/// Fetches, computes, and applies the label patch for all bounty issues.
38+
/// Returns the patch that was applied.
39+
export async function syncAllIssues({ api }: PlanAllIssuesInput): Promise<Patch> {
40+
const patch = await planAllIssues({ api });
41+
await applyPatch(patch, api);
42+
return patch;
43+
}
44+
45+
// ---------------------------------------------------------------------------
46+
// CLI entrypoint
47+
// ---------------------------------------------------------------------------
48+
49+
if (process.argv[1] === url.fileURLToPath(import.meta.url)) {
50+
const argv = await yargs(hideBin(process.argv))
51+
.option("repo", { type: "string", demandOption: true, description: "owner/repo" })
52+
.option("token", {
53+
type: "string",
54+
description: "GitHub token (falls back to GITHUB_TOKEN env var or `gh auth token`)",
55+
})
56+
.option("execute", {
57+
type: "boolean",
58+
default: false,
59+
description: "Apply the patch. Without this flag only the plan is printed.",
60+
})
61+
.strict()
62+
.parseAsync();
63+
64+
const [owner, repo] = argv.repo.split("/") as [string, string];
65+
const token = resolveToken(argv.token);
66+
const api = new GitHubRestApi(owner, repo, token);
67+
68+
if (argv.execute) {
69+
const patch = await syncAllIssues({ api });
70+
if (patch.ops.length === 0) {
71+
console.log("All issues already in sync — no changes needed.");
72+
}
73+
} else {
74+
const patch = await planAllIssues({ api });
75+
printPlan(patch, "All bounty issues");
76+
}
77+
}

.github/scripts/bounty/src/sync-issue.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import * as url from "url";
1313
import { execSync } from "child_process";
14+
import chalk from "chalk";
1415
import yargs from "yargs";
1516
import { hideBin } from "yargs/helpers";
1617
import { GitHubRestApi, type GitHubApi } from "./api.js";
@@ -62,35 +63,41 @@ export async function syncIssue({ issueNumber, api }: PlanIssueInput): Promise<P
6263
/// Each target gets one batched addLabels call; each removal is individual.
6364
export async function applyPatch(patch: Patch, api: GitHubApi): Promise<void> {
6465
for (const op of patch.ops) {
66+
const ref = chalk.cyan(`#${op.target}`);
6567
if (op.add.length > 0) {
6668
await api.addLabels(op.target, op.add);
67-
console.log(`#${op.target}: added [${op.add.join(", ")}]`);
69+
console.log(`${chalk.green("✔")} ${ref}: added [${chalk.green(op.add.join(", "))}]`);
6870
}
6971
for (const label of op.remove) {
7072
await api.removeLabel(op.target, label);
71-
console.log(`#${op.target}: removed "${label}"`);
73+
console.log(`${chalk.red("✖")} ${ref}: removed "${chalk.red(label)}"`);
7274
}
7375
if (op.comment) {
7476
await api.addComment(op.target, op.comment);
75-
console.log(`#${op.target}: posted comment`);
77+
console.log(`${chalk.yellow("✉")} ${ref}: posted comment`);
7678
}
7779
}
7880
}
7981

8082
/// Prints a human-readable plan to stdout without making any API calls.
8183
export function printPlan(patch: Patch, subject: string): void {
8284
if (patch.ops.length === 0) {
83-
console.log(`${subject}: already in sync — no changes needed.`);
85+
console.log(`${chalk.green("✔")} ${chalk.bold(subject)}: already in sync — no changes needed.`);
8486
return;
8587
}
86-
console.log(`${subject}: plan (${patch.ops.length} target(s) to update)\n`);
88+
console.log(`${chalk.yellow("●")} ${chalk.bold(subject)}: plan (${chalk.bold(String(patch.ops.length))} target(s) to update)\n`);
8789
for (const op of patch.ops) {
88-
console.log(` #${op.target}:`);
89-
if (op.add.length > 0) console.log(` + add: ${op.add.join(", ")}`);
90-
if (op.remove.length > 0) console.log(` - remove: ${op.remove.join(", ")}`);
91-
if (op.comment) console.log(` ~ comment: ${op.comment.slice(0, 80)}…`);
90+
const title = op.title ? chalk.bold(` ${op.title}`) : "";
91+
const href = op.url ? `\n ${chalk.dim(chalk.blue(op.url))}` : "";
92+
console.log(` ${chalk.cyan(`#${op.target}`)}${title}${href}`);
93+
if (op.add.length > 0)
94+
console.log(` ${chalk.green("+")} add: ${chalk.green(op.add.join(", "))}`);
95+
if (op.remove.length > 0)
96+
console.log(` ${chalk.red("-")} remove: ${chalk.red(op.remove.join(", "))}`);
97+
if (op.comment)
98+
console.log(` ${chalk.yellow("~")} comment: ${chalk.dim(op.comment.slice(0, 80))}…`);
9299
}
93-
console.log("\nRun with --execute to apply.");
100+
console.log(`\n${chalk.dim("Run with --execute to apply.")}`);
94101
}
95102

96103
// ---------------------------------------------------------------------------

.github/scripts/bounty/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface User {
2020
export interface Issue {
2121
number: number;
2222
title: string;
23+
html_url: string;
2324
state: "open" | "closed";
2425
labels: Label[];
2526
assignees: User[];
@@ -29,6 +30,7 @@ export interface Issue {
2930
/// Full pull request as returned by GET /repos/:owner/:repo/pulls/:number
3031
export interface PullRequest {
3132
number: number;
33+
title: string;
3234
state: "open" | "closed";
3335
merged: boolean;
3436
body: string | null;
@@ -64,6 +66,10 @@ export interface PrState {
6466
/// A label operation on a single target (issue or PR number).
6567
export interface LabelOp {
6668
target: number;
69+
/// Human-readable title of the issue or PR, for display in plan output.
70+
title?: string;
71+
/// URL of the issue or PR, for display in plan output.
72+
url?: string;
6773
add: string[];
6874
remove: string[];
6975
/// Optional comment to post on the target after label ops.

.github/scripts/bounty/tests/rules.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { Issue, PullRequest, IssueState, PrState } from "../src/types.js";
1717
function makeIssue(overrides: Partial<Issue> & { number: number }): Issue {
1818
return {
1919
title: "Test issue",
20+
html_url: `https://github.com/owner/repo/issues/${overrides.number}`,
2021
state: "open",
2122
labels: [],
2223
assignees: [],
@@ -26,6 +27,7 @@ function makeIssue(overrides: Partial<Issue> & { number: number }): Issue {
2627

2728
function makePr(overrides: Partial<PullRequest> & { number: number }): PullRequest {
2829
return {
30+
title: "Test PR",
2931
state: "open",
3032
merged: false,
3133
body: null,

0 commit comments

Comments
 (0)