|
| 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 | +``` |
0 commit comments