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.
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.
- Parses the PR body for closing keywords (
closes,fixes,resolves, case-insensitive). - Returns a
ParsedIntentwith alabelCopiesfield β the plan stage fetches each linked issue's labels and copies anybounty: $Nones onto the PR. - 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 abounty: $Nlabel. - 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).
- Returns empty intent if the PR was not merged or has no
bounty: $Nlabel. - Returns
add: ["bounty: rewarded"]for the PR (labels known from event, no fetch). - 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
GitHubApiinterface (injectable for testing) GitHubRestApiβ the production implementation usingnode: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.
npm run test:bounty
- Parse tests: pure β no mock needed, just call
parse()with a synthetic event. - Plan and execute tests: use a mock
GitHubApithat tracks calls and returns preset label lists. - The CLI entrypoint in each script (yargs parsing +
GITHUB_EVENT_PATHread) is guarded behind animport.meta.urlcheck 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:
cargo test -p forge_ci