Skip to content

Commit e946aaa

Browse files
authored
feat(bounty): add GitHub actions for bounty label lifecycle (#2636)
1 parent 9650937 commit e946aaa

17 files changed

Lines changed: 1628 additions & 2 deletions

File tree

.github/scripts/bounty/README.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Bounty Automation
2+
3+
Automates the full lifecycle of issue bounties — from label propagation when a PR is opened, through claiming when work begins, to rewarding when a PR is merged.
4+
5+
## Flow
6+
7+
```
8+
Issue created
9+
└── maintainer adds bounty: $N label
10+
11+
├── parse-sync-generic-bounty.ts → adds generic bounty label
12+
13+
14+
Issue assigned to contributor
15+
└── parse-sync-claimed.ts → adds bounty: claimed
16+
17+
18+
Contributor opens PR with "Closes #N" / "Fixes #N" / "Resolves #N"
19+
└── parse-propagate-label.ts → copies bounty: $N to PR
20+
→ posts comment on issue: "PR #X opened by @author"
21+
22+
23+
PR is merged
24+
└── parse-mark-rewarded.ts → adds bounty: rewarded to PR
25+
→ adds bounty: rewarded to linked issue(s)
26+
→ removes bounty: claimed from linked issue(s)
27+
28+
29+
Bounty lifecycle complete
30+
31+
Removal path:
32+
maintainer removes last bounty: $N label
33+
└── parse-sync-generic-bounty.ts → removes generic bounty label
34+
```
35+
36+
## Labels
37+
38+
| Label | Applied to | Set by |
39+
| -------------------------------- | ---------- | ----------------------------------------------------------- |
40+
| `bounty: $100``bounty: $5500` | Issue | Maintainer (manually) |
41+
| `bounty` | Issue | `parse-sync-generic-bounty.ts` on value label add/remove |
42+
| `bounty: claimed` | Issue | `parse-sync-claimed.ts` on assignment |
43+
| `bounty: rewarded` | Issue + PR | `parse-mark-rewarded.ts` on merge |
44+
45+
Bounty values follow the Fibonacci sequence: **$100, $200, $300, $500, $800, $1300, $2100, $3400, $5500**.
46+
47+
## Pipeline
48+
49+
Each workflow job runs a single Node process (`run.ts`) that executes all three stages in memory — no temp files, no shell piping:
50+
51+
```
52+
parse → ParsedIntent (in memory) → plan → BatchPlan (in memory) → execute
53+
```
54+
55+
**Stage 1 — parse** (`parse-*.ts`): Reads the GitHub Actions event payload from `GITHUB_EVENT_PATH` and returns a `ParsedIntent` object. Pure — makes no API calls.
56+
57+
**Stage 2 — plan** (`plan.ts`): Receives the `ParsedIntent`, fetches current labels only for targets not already known from the event payload, diffs desired vs actual state, and returns a `BatchPlan`. Minimises API calls by reusing label data already present in the event.
58+
59+
**Stage 3 — execute** (`execute.ts`): Receives the `BatchPlan` and applies all mutations. Label additions per target are sent as a single batched `POST /labels` call. Each removal is a separate `DELETE` (GitHub has no bulk-remove endpoint). Comments are posted last.
60+
61+
This design means:
62+
- The parse stage is trivially unit-testable (pure function, no mocks needed).
63+
- The plan stage is testable with a minimal mock that only needs `getLabels`.
64+
- The execute stage is testable with a mock that tracks calls — no HTTP.
65+
- API calls are minimised: additions are batched per target; label state already in the event payload is never re-fetched.
66+
67+
## Scripts
68+
69+
The workflow invokes a single orchestrator script (`run.ts`) per job. The parse, plan, and execute modules are called directly in the same Node process, passing objects in memory.
70+
71+
### `run.ts`
72+
73+
The entrypoint used by `bounty.yml`. Accepts a `--script` flag to select which parse module to run, then calls `plan` and `execute` in sequence — all in-process.
74+
75+
```sh
76+
npx tsx .github/scripts/bounty/run.ts \
77+
--script <parse-script> \
78+
--repo <owner/repo> \
79+
--token <github-token> \
80+
[--pr <number> | --issue <number>]
81+
```
82+
83+
### `parse-propagate-label.ts`
84+
85+
Triggered by: `pull_request` — opened, edited, reopened.
86+
87+
1. Parses the PR body for closing keywords (`closes`, `fixes`, `resolves`, case-insensitive).
88+
2. Returns a `ParsedIntent` with a `labelCopies` field — the plan stage fetches each linked issue's labels and copies any `bounty: $N` ones onto the PR.
89+
3. Includes a comment mutation per linked issue (the plan stage drops it if the issue has no bounty labels).
90+
91+
### `parse-sync-claimed.ts`
92+
93+
Triggered by: `issues` — assigned, unassigned.
94+
95+
- **assigned**: returns `add: ["bounty: claimed"]` if the issue has a `bounty: $N` label.
96+
- **unassigned**: returns `remove: ["bounty: claimed"]` only when no assignees remain.
97+
- Issue labels are already in the event payload and supplied as `knownLabels` — no extra fetch.
98+
99+
### `parse-sync-generic-bounty.ts`
100+
101+
Triggered by: `issues` — labeled, unlabeled.
102+
103+
Keeps the generic `bounty` label in sync with value labels. Inspects `event.label` (the label that just changed) and only acts when it matches `bounty: $`.
104+
105+
- **labeled**: returns `add: ["bounty"]`.
106+
- **unlabeled**: returns `remove: ["bounty"]` only when no value labels remain (guards against mid-tier-swap removal when a maintainer swaps one value label for another).
107+
- Issue labels from the event are supplied as `knownLabels` — no extra fetch.
108+
109+
### `parse-mark-rewarded.ts`
110+
111+
Triggered by: `pull_request_target` — closed (merged only).
112+
113+
1. Returns empty intent if the PR was not merged or has no `bounty: $N` label.
114+
2. Returns `add: ["bounty: rewarded"]` for the PR (labels known from event, no fetch).
115+
3. Parses the PR body for linked issues; returns `add: ["bounty: rewarded"], remove: ["bounty: claimed"]` for each (plan stage fetches their labels).
116+
117+
Uses `pull_request_target` so the job has write access to issues and PRs from forks.
118+
119+
### `plan.ts`
120+
121+
Receives a `ParsedIntent` from the parse stage. Resolves `labelCopies` by fetching source issue labels. Fetches current labels for any target not already in `knownLabels`. Filters out no-op adds and removes. Returns a `BatchPlan`.
122+
123+
### `execute.ts`
124+
125+
Receives a `BatchPlan` from the plan stage. For each mutation: one batched `POST` for all additions, one `DELETE` per removal, one `POST` per comment.
126+
127+
## Shared Module
128+
129+
`github-api.ts` defines:
130+
- Event payload types (`PullRequestEvent`, `IssuesEvent`)
131+
- Pipeline types (`ParsedIntent`, `BatchPlan`, `TargetMutation`)
132+
- The `GitHubApi` interface (injectable for testing)
133+
- `GitHubRestApi` — the production implementation using `node:https`
134+
135+
All scripts import types from `github-api.ts` and accept a `GitHubApi` instance in their `run()` / `plan()` / `execute()` function signature, making every step independently mockable.
136+
137+
## Tests
138+
139+
Unit tests live alongside each script (`*.test.ts`) and use Node's built-in `node:test` runner.
140+
141+
```sh
142+
npm run test:bounty
143+
```
144+
145+
- Parse tests: pure — no mock needed, just call `parse()` with a synthetic event.
146+
- Plan and execute tests: use a mock `GitHubApi` that tracks calls and returns preset label lists.
147+
- The CLI entrypoint in each script (yargs parsing + `GITHUB_EVENT_PATH` read) is guarded behind an `import.meta.url` check so it does not execute on import.
148+
149+
## Workflow Source
150+
151+
`bounty.yml` is auto-generated from Rust source in `crates/forge_ci`. Do not edit it by hand — modify `crates/forge_ci/src/workflows/bounty.rs` and regenerate with:
152+
153+
```sh
154+
cargo test -p forge_ci
155+
```

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// GitHub API abstraction for v2 bounty sync.
2+
// Uses the native `fetch` API (available in Node 18+).
3+
4+
import type { Issue, Label, PullRequest } from "./types.js";
5+
6+
// ---------------------------------------------------------------------------
7+
// Interface — injectable for testing
8+
// ---------------------------------------------------------------------------
9+
10+
/// Minimal GitHub REST API surface needed by the bounty sync commands.
11+
export interface GitHubApi {
12+
/// Fetch a full issue by number. Throws on non-2xx.
13+
getIssue(number: number): Promise<Issue>;
14+
/// Fetch a full pull request by number. Throws on non-2xx.
15+
getPullRequest(number: number): Promise<PullRequest>;
16+
/// Add one or more labels to an issue or PR. Batched into a single request.
17+
addLabels(target: number, labels: string[]): Promise<void>;
18+
/// Remove a single label from an issue or PR.
19+
removeLabel(target: number, label: string): Promise<void>;
20+
/// Post a comment on an issue or PR.
21+
addComment(target: number, body: string): Promise<void>;
22+
}
23+
24+
// ---------------------------------------------------------------------------
25+
// Production implementation
26+
// ---------------------------------------------------------------------------
27+
28+
/// Calls the real GitHub REST API v3 using `fetch`.
29+
export class GitHubRestApi implements GitHubApi {
30+
private readonly base: string;
31+
private readonly headers: Record<string, string>;
32+
33+
constructor(
34+
private readonly owner: string,
35+
private readonly repo: string,
36+
token: string
37+
) {
38+
this.base = `https://api.github.com/repos/${owner}/${repo}`;
39+
this.headers = {
40+
Authorization: `Bearer ${token}`,
41+
Accept: "application/vnd.github+json",
42+
"X-GitHub-Api-Version": "2022-11-28",
43+
"User-Agent": "bounty-bot/v2",
44+
"Content-Type": "application/json",
45+
};
46+
}
47+
48+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
49+
const res = await fetch(`${this.base}${path}`, {
50+
method,
51+
headers: this.headers,
52+
body: body !== undefined ? JSON.stringify(body) : undefined,
53+
});
54+
55+
if (!res.ok) {
56+
const text = await res.text().catch(() => "");
57+
throw new Error(`GitHub API ${method} ${path}${res.status}: ${text}`);
58+
}
59+
60+
// 204 No Content
61+
if (res.status === 204) return {} as T;
62+
63+
return res.json() as Promise<T>;
64+
}
65+
66+
async getIssue(number: number): Promise<Issue> {
67+
return this.request<Issue>("GET", `/issues/${number}`);
68+
}
69+
70+
async getPullRequest(number: number): Promise<PullRequest> {
71+
return this.request<PullRequest>("GET", `/pulls/${number}`);
72+
}
73+
74+
async addLabels(target: number, labels: string[]): Promise<void> {
75+
await this.request("POST", `/issues/${target}/labels`, { labels });
76+
}
77+
78+
async removeLabel(target: number, label: string): Promise<void> {
79+
await this.request("DELETE", `/issues/${target}/labels/${encodeURIComponent(label)}`);
80+
}
81+
82+
async addComment(target: number, body: string): Promise<void> {
83+
await this.request("POST", `/issues/${target}/comments`, { body });
84+
}
85+
}

0 commit comments

Comments
 (0)