From 9e15e6831768be7abf2eac517f6a1c242627c3c0 Mon Sep 17 00:00:00 2001 From: pntech20 Date: Sat, 23 May 2026 09:58:59 +0700 Subject: [PATCH] feat: add stellar bounty command --- README.md | 32 +++++++++- dist/index.js | 130 +++++++++++++++++++++++++++++++++---- src/index.ts | 174 +++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 306 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d0006fd..1eb18e4 100644 --- a/README.md +++ b/README.md @@ -1 +1,31 @@ -# issueflow-cli \ No newline at end of file +# issueflow-cli + +CLI tool for managing GitHub issues and attaching Stellar USDC bounty details. + +## Commands + +### List open issues + +```bash +issueflow list --repo owner/repo +``` + +### Attach a bounty to an issue + +```bash +issueflow bounty --repo owner/repo --issue 42 --amount 25 --network testnet +``` + +The bounty command validates the target issue, token, amount, and Stellar +network, then posts a bounty summary comment to the GitHub issue. Pass +`--dry-run` to preview the request without changing GitHub. + +Optional flags: + +- `--contract-id ` records the Soroban bounty contract used for the + request. It can also be provided through `STELLAR_BOUNTY_CONTRACT_ID`. +- `--no-comment` prepares and prints the bounty payload without posting a GitHub + issue comment. + +The command reads `GITHUB_TOKEN`, `GH_TOKEN`, or `githubToken` from `.issueflow` +for authenticated GitHub API calls. diff --git a/dist/index.js b/dist/index.js index 70f224b..3393bcd 100644 --- a/dist/index.js +++ b/dist/index.js @@ -10,54 +10,99 @@ const chalk_1 = __importDefault(require("chalk")); const config_1 = require("./config"); const program = new commander_1.Command(); const config = (0, config_1.loadConfig)(); -const token = config.githubToken || process.env.GITHUB_TOKEN; +const githubToken = config.githubToken || process.env.GITHUB_TOKEN || process.env.GH_TOKEN; function createOctokit() { - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; - return new rest_1.Octokit(token ? { auth: token } : {}); + return new rest_1.Octokit(githubToken ? { auth: githubToken } : {}); } function parseRepo(value) { const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(value.trim()); - if (!match) + if (!match) { throw new Error('Repository must be in the form owner/repo.'); + } return { owner: match[1], repo: match[2] }; } function parseIssueNumber(value) { const issueNumber = Number(value); - if (!Number.isInteger(issueNumber) || issueNumber <= 0) + if (!Number.isInteger(issueNumber) || issueNumber <= 0) { throw new Error('Issue number must be a positive integer.'); + } return issueNumber; } function parseAmount(value) { const amount = Number(value); - if (!Number.isFinite(amount) || amount <= 0) + if (!Number.isFinite(amount) || amount <= 0) { throw new Error('Amount must be a positive number.'); + } return amount; } function parseToken(value = 'USDC') { const normalized = value.trim().toUpperCase(); - if (normalized !== 'USDC') + if (normalized !== 'USDC') { throw new Error(`Unsupported token: ${value}. Only USDC is supported in this MVP.`); + } return normalized; } function parseNetwork(value = 'testnet') { const normalized = value.trim().toLowerCase(); - if (normalized !== 'testnet' && normalized !== 'mainnet') + if (normalized !== 'testnet' && normalized !== 'mainnet') { throw new Error(`Unsupported network: ${value}. Use testnet or mainnet.`); + } return normalized; } function createBountyRequest(input) { - return { repository: input.repository, issueNumber: input.issueNumber, issueTitle: input.issueTitle, amount: input.amount, token: input.token, network: input.network, chain: 'stellar', status: input.dryRun ? 'dry-run' : 'pending-contract-integration' }; + return { + repository: input.repository, + issueNumber: input.issueNumber, + issueTitle: input.issueTitle, + issueUrl: input.issueUrl, + amount: input.amount, + token: input.token, + network: input.network, + contractId: input.contractId, + chain: 'stellar', + status: input.dryRun ? 'dry-run' : input.comment ? 'attached-to-issue' : 'prepared', + }; } function printSummary(request) { console.log(chalk_1.default.cyan('\nBounty request summary')); console.log(` Repository : ${request.repository}`); - console.log(` Issue : #${request.issueNumber} — ${request.issueTitle}`); + console.log(` Issue : #${request.issueNumber} - ${request.issueTitle}`); console.log(` Amount : ${request.amount} ${request.token}`); console.log(` Network : ${request.network}`); console.log(` Chain : ${request.chain}`); + if (request.contractId) { + console.log(` Contract : ${request.contractId}`); + } + console.log(` Status : ${request.status}`); } -async function submitBountyRequest(request) { - return { message: 'Contract submission is not wired up yet in this MVP.', payload: request }; +function buildBountyComment(request) { + const lines = [ + '### Stellar USDC bounty', + '', + `- Amount: ${request.amount} ${request.token}`, + `- Network: ${request.network}`, + `- Issue: ${request.issueUrl}`, + `- Status: ${request.contractId ? 'contract configured' : 'ready for contract funding'}`, + ]; + if (request.contractId) { + lines.push(`- Soroban contract: \`${request.contractId}\``); + } + return lines.join('\n'); +} +async function submitBountyRequest(request, options) { + if (options.dryRun) { + return { message: 'Dry run completed. No GitHub changes were made.', payload: request }; + } + if (!options.comment) { + return { message: 'Bounty payload prepared. No GitHub comment was posted.', payload: request }; + } + await options.octokit.issues.createComment({ + owner: options.owner, + repo: options.repo, + issue_number: request.issueNumber, + body: buildBountyComment(request), + }); + return { message: 'Bounty details attached to the GitHub issue.', payload: request }; } program .name('issueflow') @@ -82,7 +127,66 @@ program } console.log(`\nOpen issues in ${options.repo}:\n`); issues.forEach((issue) => { - console.log(` #${issue.number} — ${issue.title}`); + console.log(` #${issue.number} - ${issue.title}`); }); }); +program + .command('bounty') + .description('Attach a Stellar USDC bounty to a GitHub issue') + .option('-r, --repo ', 'target repository (org/repo)') + .requiredOption('-i, --issue ', 'GitHub issue number') + .requiredOption('-a, --amount ', 'bounty amount') + .option('-t, --token ', 'bounty token', 'USDC') + .option('-n, --network ', 'Stellar network', 'testnet') + .option('--contract-id ', 'Soroban bounty contract id', process.env.STELLAR_BOUNTY_CONTRACT_ID) + .option('--dry-run', 'validate and print the bounty without posting a GitHub comment') + .option('--no-comment', 'skip the GitHub issue comment') + .action(async (options) => { + try { + const repository = options.repo || config.defaultRepo; + if (!repository) { + throw new Error('Repository is required. Pass --repo owner/repo or set defaultRepo in .issueflow.'); + } + const { owner, repo } = parseRepo(repository); + const issueNumber = parseIssueNumber(options.issue); + const amount = parseAmount(options.amount); + const bountyToken = parseToken(options.token); + const network = parseNetwork(options.network); + const octokit = createOctokit(); + const { data: issue } = await octokit.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + if (issue.pull_request) { + throw new Error('Bounties can only be attached to issues, not pull requests.'); + } + const request = createBountyRequest({ + repository, + issueNumber, + issueTitle: issue.title, + issueUrl: issue.html_url, + amount, + token: bountyToken, + network, + contractId: options.contractId, + dryRun: Boolean(options.dryRun), + comment: Boolean(options.comment), + }); + printSummary(request); + const result = await submitBountyRequest(request, { + octokit, + owner, + repo, + comment: Boolean(options.comment), + dryRun: Boolean(options.dryRun), + }); + console.log(chalk_1.default.green(`\n${result.message}`)); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(chalk_1.default.red(message)); + process.exitCode = 1; + } +}); program.parse(); diff --git a/src/index.ts b/src/index.ts index 0bbc158..1cb08ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,72 +6,149 @@ import { loadConfig } from './config'; type SupportedToken = 'USDC'; type SupportedNetwork = 'testnet' | 'mainnet'; -interface ParsedRepo { owner: string; repo: string; } + +interface ParsedRepo { + owner: string; + repo: string; +} + interface BountyRequest { repository: string; issueNumber: number; issueTitle: string; + issueUrl: string; amount: number; token: SupportedToken; network: SupportedNetwork; + contractId?: string; chain: string; status: string; } const program = new Command(); const config = loadConfig(); -const token = config.githubToken || process.env.GITHUB_TOKEN; +const githubToken = config.githubToken || process.env.GITHUB_TOKEN || process.env.GH_TOKEN; function createOctokit(): Octokit { - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; - return new Octokit(token ? { auth: token } : {}); + return new Octokit(githubToken ? { auth: githubToken } : {}); } function parseRepo(value: string): ParsedRepo { const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(value.trim()); - if (!match) throw new Error('Repository must be in the form owner/repo.'); + if (!match) { + throw new Error('Repository must be in the form owner/repo.'); + } return { owner: match[1], repo: match[2] }; } function parseIssueNumber(value: string): number { const issueNumber = Number(value); - if (!Number.isInteger(issueNumber) || issueNumber <= 0) throw new Error('Issue number must be a positive integer.'); + if (!Number.isInteger(issueNumber) || issueNumber <= 0) { + throw new Error('Issue number must be a positive integer.'); + } return issueNumber; } function parseAmount(value: string): number { const amount = Number(value); - if (!Number.isFinite(amount) || amount <= 0) throw new Error('Amount must be a positive number.'); + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Amount must be a positive number.'); + } return amount; } function parseToken(value = 'USDC'): SupportedToken { const normalized = value.trim().toUpperCase(); - if (normalized !== 'USDC') throw new Error(`Unsupported token: ${value}. Only USDC is supported in this MVP.`); + if (normalized !== 'USDC') { + throw new Error(`Unsupported token: ${value}. Only USDC is supported in this MVP.`); + } return normalized as SupportedToken; } function parseNetwork(value = 'testnet'): SupportedNetwork { const normalized = value.trim().toLowerCase(); - if (normalized !== 'testnet' && normalized !== 'mainnet') throw new Error(`Unsupported network: ${value}. Use testnet or mainnet.`); + if (normalized !== 'testnet' && normalized !== 'mainnet') { + throw new Error(`Unsupported network: ${value}. Use testnet or mainnet.`); + } return normalized as SupportedNetwork; } -function createBountyRequest(input: { repository: string; issueNumber: number; issueTitle: string; amount: number; token: SupportedToken; network: SupportedNetwork; dryRun: boolean; }): BountyRequest { - return { repository: input.repository, issueNumber: input.issueNumber, issueTitle: input.issueTitle, amount: input.amount, token: input.token, network: input.network, chain: 'stellar', status: input.dryRun ? 'dry-run' : 'pending-contract-integration' }; +function createBountyRequest(input: { + repository: string; + issueNumber: number; + issueTitle: string; + issueUrl: string; + amount: number; + token: SupportedToken; + network: SupportedNetwork; + contractId?: string; + dryRun: boolean; + comment: boolean; +}): BountyRequest { + return { + repository: input.repository, + issueNumber: input.issueNumber, + issueTitle: input.issueTitle, + issueUrl: input.issueUrl, + amount: input.amount, + token: input.token, + network: input.network, + contractId: input.contractId, + chain: 'stellar', + status: input.dryRun ? 'dry-run' : input.comment ? 'attached-to-issue' : 'prepared', + }; } function printSummary(request: BountyRequest): void { console.log(chalk.cyan('\nBounty request summary')); console.log(` Repository : ${request.repository}`); - console.log(` Issue : #${request.issueNumber} — ${request.issueTitle}`); + console.log(` Issue : #${request.issueNumber} - ${request.issueTitle}`); console.log(` Amount : ${request.amount} ${request.token}`); console.log(` Network : ${request.network}`); console.log(` Chain : ${request.chain}`); + if (request.contractId) { + console.log(` Contract : ${request.contractId}`); + } + console.log(` Status : ${request.status}`); } -async function submitBountyRequest(request: BountyRequest): Promise<{ message: string; payload: BountyRequest }> { - return { message: 'Contract submission is not wired up yet in this MVP.', payload: request }; +function buildBountyComment(request: BountyRequest): string { + const lines = [ + '### Stellar USDC bounty', + '', + `- Amount: ${request.amount} ${request.token}`, + `- Network: ${request.network}`, + `- Issue: ${request.issueUrl}`, + `- Status: ${request.contractId ? 'contract configured' : 'ready for contract funding'}`, + ]; + + if (request.contractId) { + lines.push(`- Soroban contract: \`${request.contractId}\``); + } + + return lines.join('\n'); +} + +async function submitBountyRequest( + request: BountyRequest, + options: { octokit: Octokit; owner: string; repo: string; comment: boolean; dryRun: boolean } +): Promise<{ message: string; payload: BountyRequest }> { + if (options.dryRun) { + return { message: 'Dry run completed. No GitHub changes were made.', payload: request }; + } + + if (!options.comment) { + return { message: 'Bounty payload prepared. No GitHub comment was posted.', payload: request }; + } + + await options.octokit.issues.createComment({ + owner: options.owner, + repo: options.repo, + issue_number: request.issueNumber, + body: buildBountyComment(request), + }); + + return { message: 'Bounty details attached to the GitHub issue.', payload: request }; } program @@ -98,8 +175,73 @@ program } console.log(`\nOpen issues in ${options.repo}:\n`); issues.forEach((issue) => { - console.log(` #${issue.number} — ${issue.title}`); + console.log(` #${issue.number} - ${issue.title}`); }); }); -program.parse(); \ No newline at end of file +program + .command('bounty') + .description('Attach a Stellar USDC bounty to a GitHub issue') + .option('-r, --repo ', 'target repository (org/repo)') + .requiredOption('-i, --issue ', 'GitHub issue number') + .requiredOption('-a, --amount ', 'bounty amount') + .option('-t, --token ', 'bounty token', 'USDC') + .option('-n, --network ', 'Stellar network', 'testnet') + .option('--contract-id ', 'Soroban bounty contract id', process.env.STELLAR_BOUNTY_CONTRACT_ID) + .option('--dry-run', 'validate and print the bounty without posting a GitHub comment') + .option('--no-comment', 'skip the GitHub issue comment') + .action(async (options) => { + try { + const repository = options.repo || config.defaultRepo; + if (!repository) { + throw new Error('Repository is required. Pass --repo owner/repo or set defaultRepo in .issueflow.'); + } + + const { owner, repo } = parseRepo(repository); + const issueNumber = parseIssueNumber(options.issue); + const amount = parseAmount(options.amount); + const bountyToken = parseToken(options.token); + const network = parseNetwork(options.network); + const octokit = createOctokit(); + + const { data: issue } = await octokit.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + if (issue.pull_request) { + throw new Error('Bounties can only be attached to issues, not pull requests.'); + } + + const request = createBountyRequest({ + repository, + issueNumber, + issueTitle: issue.title, + issueUrl: issue.html_url, + amount, + token: bountyToken, + network, + contractId: options.contractId, + dryRun: Boolean(options.dryRun), + comment: Boolean(options.comment), + }); + + printSummary(request); + const result = await submitBountyRequest(request, { + octokit, + owner, + repo, + comment: Boolean(options.comment), + dryRun: Boolean(options.dryRun), + }); + + console.log(chalk.green(`\n${result.message}`)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.red(message)); + process.exitCode = 1; + } + }); + +program.parse();