Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .flowpulse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: 1
rules:
- name: Core architecture bounty
label: bounty-approved
amount: 150
token: XLM
duration_hours: 48

- name: Documentation and diagrams
label: area: docs
amount: 50
token: XLM
duration_hours: 24
31 changes: 31 additions & 0 deletions .github/workflows/bounty.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: FlowPulse Bounty Stream

on:
pull_request:
types: [closed]

jobs:
evaluate-bounty:
name: Evaluate Merge Bounty
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4

- name: Evaluate FlowPulse Bounty Rules
id: bounty
uses: ./ci/github-action
with:
config-path: ".flowpulse.yml"
dry-run: "true"
stellar-secret-key: ${{ secrets.STELLAR_SPONSOR_KEY }}
network: "testnet"

- name: Log Payout Decision
if: steps.bounty.outputs.matched == 'true'
run: |
echo "Bounty matched: ${{ steps.bounty.outputs.rule-name }}"
echo "Recipient: ${{ steps.bounty.outputs.recipient }}"
echo "Amount: ${{ steps.bounty.outputs.amount }} ${{ steps.bounty.outputs.token }}"
echo "Stream duration: ${{ steps.bounty.outputs.duration-hours }}h"
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ In the app, open the wallet menu and use **Friendbot** to fund your Freighter ac

## How it works

> Detailed contract architecture, mathematical formulas, and sequence/flow diagrams are documented in [`docs/architecture.md`](docs/architecture.md).

### StreamRouter
Accrual is a pure function of ledger close time, so no keeper, cron, or oracle is needed:

Expand Down
81 changes: 81 additions & 0 deletions ci/github-action/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# FlowPulse GitHub Action

Headless CI/CD action that automatically evaluates merged Pull Requests against `.flowpulse.yml` rules, extracts the author's Stellar address, and provisions continuous per-second micro-streams on Soroban.

## Features
- **Deterministic Rule Evaluation**: Matches merged PRs by labels, authors, or base branches.
- **Stellar Address Extraction**: Automatically parses contributor Ed25519 public keys (`stellar: G...` or bare addresses) from PR descriptions.
- **Zero-Friction Dry Run Mode**: Verifies matching logic and logs planned stream parameters without moving funds.
- **Per-Second Streaming**: Provisions micro-streams via FlowPulse `StreamRouter` rather than lump-sum transfers.

---

## Configuration (`.flowpulse.yml`)

Place `.flowpulse.yml` in the root of your repository:

```yaml
version: 1
rules:
- name: Core feature bounty
label: bounty-approved
amount: 150
token: XLM
duration_hours: 48

- name: Documentation bounty
label: area: docs
amount: 50
token: XLM
duration_hours: 24
```

---

## Example Workflow (`.github/workflows/bounty.yml`)

```yaml
name: FlowPulse Bounty Stream

on:
pull_request:
types: [closed]

jobs:
bounty:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Evaluate FlowPulse Bounty
uses: ./ci/github-action
with:
config-path: ".flowpulse.yml"
dry-run: "false"
stellar-secret-key: ${{ secrets.STELLAR_SPONSOR_KEY }}
```

---

## Inputs

| Name | Description | Default | Required |
|---|---|---|---|
| `config-path` | Path to `.flowpulse.yml` | `.flowpulse.yml` | No |
| `dry-run` | Evaluate rules without submitting on-chain txs | `true` | No |
| `stellar-secret-key` | Ed25519 secret key (`S...`) of the funding account | - | Only in live mode |
| `network` | Stellar network (`testnet` or `mainnet`) | `testnet` | No |

## Outputs

| Name | Description |
|---|---|
| `matched` | `true` if PR satisfied bounty conditions |
| `rule-name` | Name of the matched rule |
| `recipient` | Extracted Stellar address (`G...`) |
| `amount` | Bounty token amount |
| `token` | Token symbol (`XLM` / `USDC`) |
| `duration-hours` | Stream runway in hours |
| `status` | `dry_run_success`, `submitted`, `not_matched`, `error` |
126 changes: 126 additions & 0 deletions ci/github-action/__tests__/action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import fs from "fs";
import path from "path";
import {
extractStellarAddress,
parseRuleConfig,
evaluatePullRequest,
type PullRequestPayload,
} from "../src/evaluator";

const VALID_ADDR = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";

describe("FlowPulse GitHub Action Evaluator", () => {
it("extracts Stellar address from various formatting styles", () => {
expect(extractStellarAddress(`stellar: ${VALID_ADDR}`)).toBe(VALID_ADDR);
expect(extractStellarAddress(`stellar address: \`${VALID_ADDR}\``)).toBe(VALID_ADDR);
expect(extractStellarAddress(`Stellar=${VALID_ADDR}`)).toBe(VALID_ADDR);
expect(extractStellarAddress(`Please send payout to ${VALID_ADDR} thanks!`)).toBe(VALID_ADDR);
expect(extractStellarAddress("no address here")).toBeNull();
expect(extractStellarAddress(null)).toBeNull();
expect(extractStellarAddress("G1234invalid")).toBeNull();
});

it("parses .flowpulse.yml configuration structure", () => {
const yaml = `
version: 1
rules:
- name: Core feature bounty
label: bounty-approved
amount: 150
token: XLM
duration_hours: 48
- name: Docs bounty
label: area: docs
amount: 50
duration_hours: 24
`;
const config = parseRuleConfig(yaml);
expect(config.rules.length).toBe(2);
expect(config.rules[0].name).toBe("Core feature bounty");
expect(config.rules[0].label).toBe("bounty-approved");
expect(config.rules[0].amount).toBe("150");
expect(config.rules[0].token).toBe("XLM");
expect(config.rules[0].duration_hours).toBe(48);

expect(config.rules[1].name).toBe("Docs bounty");
expect(config.rules[1].label).toBe("area: docs");
expect(config.rules[1].amount).toBe("50");
});

it("evaluates a merged PR against fixture payload successfully", () => {
const fixturePath = path.resolve(__dirname, "../fixtures/pull_request_merged.json");
const raw = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
const pr = raw.pull_request;

const prPayload: PullRequestPayload = {
number: pr.number,
title: pr.title,
url: pr.html_url,
author: pr.user.login,
merged: pr.merged,
mergedAt: pr.merged_at,
labels: pr.labels.map((l: { name: string }) => l.name),
body: pr.body,
};

const config = {
rules: [
{
name: "Bounty Approved",
label: "bounty-approved",
amount: "100",
duration_hours: 48,
},
],
};

const result = evaluatePullRequest(config, prPayload);
expect(result.matched).toBe(true);
expect(result.ruleName).toBe("Bounty Approved");
expect(result.amount).toBe("100");
expect(result.durationHours).toBe(48);
expect(result.recipientAddress).toBe(VALID_ADDR);
});

it("rejects unmerged pull requests", () => {
const prPayload: PullRequestPayload = {
number: 10,
title: "closed PR",
url: "https://github.com/test/test/pull/10",
author: "alice",
merged: false,
labels: ["bounty-approved"],
body: `stellar: ${VALID_ADDR}`,
};

const config = {
rules: [{ name: "Rule 1", label: "bounty-approved", amount: "50" }],
};

const result = evaluatePullRequest(config, prPayload);
expect(result.matched).toBe(false);
expect(result.reason).toContain("closed without being merged");
});

it("fails matching gracefully if author did not provide Stellar address", () => {
const prPayload: PullRequestPayload = {
number: 11,
title: "no address PR",
url: "https://github.com/test/test/pull/11",
author: "bob",
merged: true,
labels: ["bounty-approved"],
body: "Fixed a bug with zero address attached.",
};

const config = {
rules: [{ name: "Rule 1", label: "bounty-approved", amount: "50" }],
};

const result = evaluatePullRequest(config, prPayload);
expect(result.matched).toBe(false);
expect(result.recipientAddress).toBeNull();
expect(result.reason).toContain("no valid Stellar address found");
});
});
44 changes: 44 additions & 0 deletions ci/github-action/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: "FlowPulse Bounty Stream Evaluator"
description: "Evaluates merged pull requests against .flowpulse.yml rules, extracts the contributor's Stellar address, and opens an automated per-second micro-stream bounty."
author: "FlowPulse"

branding:
icon: "bolt"
color: "blue"

inputs:
config-path:
description: "Path to the FlowPulse bounty rule configuration file"
required: false
default: ".flowpulse.yml"
dry-run:
description: "When true, evaluates rules and logs decisions without submitting on-chain transactions"
required: false
default: "true"
stellar-secret-key:
description: "Stellar secret key (S...) of the repository sponsor account for funding bounty streams"
required: false
network:
description: "Stellar network ('testnet' or 'mainnet')"
required: false
default: "testnet"

outputs:
matched:
description: "Boolean indicating whether the pull request matched a bounty rule"
rule-name:
description: "Name of the matched bounty rule"
recipient:
description: "Extracted recipient Stellar address"
amount:
description: "Bounty payout amount"
token:
description: "Payout token code (e.g. XLM, USDC)"
duration-hours:
description: "Stream duration in hours"
status:
description: "Execution status ('dry_run_success', 'submitted', 'not_matched', 'error')"

runs:
using: "node20"
main: "dist/index.js"
Loading