| # Bounty Automation |
|
|
| 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. |
|
|
| ## Flow |
|
|
| ``` |
| Issue created |
| βββ maintainer adds bounty: $N label |
| β |
| βββ parse-sync-generic-bounty.ts β adds generic bounty label |
| β |
| βΌ |
| Issue assigned to contributor |
| βββ parse-sync-claimed.ts β adds bounty: claimed |
| β |
| βΌ |
| Contributor opens PR with "Closes #N" / "Fixes #N" / "Resolves #N" |
| βββ parse-propagate-label.ts β copies bounty: $N to PR |
| β posts comment on issue: "PR #X opened by @author" |
| β |
| βΌ |
| PR is merged |
| βββ parse-mark-rewarded.ts β adds bounty: rewarded to PR |
| β adds bounty: rewarded to linked issue(s) |
| β removes bounty: claimed from linked issue(s) |
| β |
| βΌ |
| Bounty lifecycle complete |
| |
| Removal path: |
| maintainer removes last bounty: $N label |
| βββ parse-sync-generic-bounty.ts β removes generic bounty label |
| ``` |
|
|
| ## Labels |
|
|
| | Label | Applied to | Set by | |
| | -------------------------------- | ---------- | ----------------------------------------------------------- | |
| | `bounty: $100` β¦ `bounty: $5500` | Issue | Maintainer (manually) | |
| | `bounty` | Issue | `parse-sync-generic-bounty.ts` on value label add/remove | |
| | `bounty: claimed` | Issue | `parse-sync-claimed.ts` on assignment | |
| | `bounty: rewarded` | Issue + PR | `parse-mark-rewarded.ts` on merge | |
|
|
| Bounty values follow the Fibonacci sequence: **$100, $200, $300, $500, $800, $1300, $2100, $3400, $5500**. |
|
|
| ## Pipeline |
|
|
| Each workflow job runs a single Node process (`run.ts`) that executes all three stages in memory β no temp files, no shell piping: |
|
|
| ``` |
| parse β ParsedIntent (in memory) β plan β BatchPlan (in memory) β execute |
| ``` |
|
|
| **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. |
|
|
| **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. |
|
|
| **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. |
|
|
| This design means: |
| - The parse stage is trivially unit-testable (pure function, no mocks needed). |
| - The plan stage is testable with a minimal mock that only needs `getLabels`. |
| - The execute stage is testable with a mock that tracks calls β no HTTP. |
| - API calls are minimised: additions are batched per target; label state already in the event payload is never re-fetched. |
|
|
| ## Scripts |
|
|
| 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. |
|
|
| ### `run.ts` |
|
|
| 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. |
|
|
| ```sh |
| npx tsx .github/scripts/bounty/run.ts \ |
| --script <parse-script> \ |
| --repo <owner/repo> \ |
| --token <github-token> \ |
| [--pr <number> | --issue <number>] |
| ``` |
|
|
| ### `parse-propagate-label.ts` |
|
|
| Triggered by: `pull_request` β opened, edited, reopened. |
|
|
| 1. Parses the PR body for closing keywords (`closes`, `fixes`, `resolves`, case-insensitive). |
| 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. |
| 3. Includes a comment mutation per linked issue (the plan stage drops it if the issue has no bounty labels). |
|
|
| ### `parse-sync-claimed.ts` |
|
|
| Triggered by: `issues` β assigned, unassigned. |
|
|
| - **assigned**: returns `add: ["bounty: claimed"]` if the issue has a `bounty: $N` label. |
| - **unassigned**: returns `remove: ["bounty: claimed"]` only when no assignees remain. |
| - Issue labels are already in the event payload and supplied as `knownLabels` β no extra fetch. |
|
|
| ### `parse-sync-generic-bounty.ts` |
|
|
| Triggered by: `issues` β labeled, unlabeled. |
|
|
| 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: $`. |
|
|
| - **labeled**: returns `add: ["bounty"]`. |
| - **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). |
| - Issue labels from the event are supplied as `knownLabels` β no extra fetch. |
|
|
| ### `parse-mark-rewarded.ts` |
|
|
| Triggered by: `pull_request_target` β closed (merged only). |
|
|
| 1. Returns empty intent if the PR was not merged or has no `bounty: $N` label. |
| 2. Returns `add: ["bounty: rewarded"]` for the PR (labels known from event, no fetch). |
| 3. Parses the PR body for linked issues; returns `add: ["bounty: rewarded"], remove: ["bounty: claimed"]` for each (plan stage fetches their labels). |
|
|
| Uses `pull_request_target` so the job has write access to issues and PRs from forks. |
|
|
| ### `plan.ts` |
|
|
| 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`. |
|
|
| ### `execute.ts` |
|
|
| Receives a `BatchPlan` from the plan stage. For each mutation: one batched `POST` for all additions, one `DELETE` per removal, one `POST` per comment. |
|
|
| ## Shared Module |
|
|
| `github-api.ts` defines: |
| - Event payload types (`PullRequestEvent`, `IssuesEvent`) |
| - Pipeline types (`ParsedIntent`, `BatchPlan`, `TargetMutation`) |
| - The `GitHubApi` interface (injectable for testing) |
| - `GitHubRestApi` β the production implementation using `node:https` |
|
|
| 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. |
|
|
| ## Tests |
|
|
| Unit tests live alongside each script (`*.test.ts`) and use Node's built-in `node:test` runner. |
|
|
| ```sh |
| npm run test:bounty |
| ``` |
|
|
| - Parse tests: pure β no mock needed, just call `parse()` with a synthetic event. |
| - Plan and execute tests: use a mock `GitHubApi` that tracks calls and returns preset label lists. |
| - 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. |
|
|
| ## Workflow Source |
|
|
| `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: |
|
|
| ```sh |
| cargo test -p forge_ci |
| ``` |
|
|