Skip to content
Closed
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
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,31 @@
# issueflow-cli
# 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 <contractId>` 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.
130 changes: 117 additions & 13 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 <repo>', 'target repository (org/repo)')
.requiredOption('-i, --issue <number>', 'GitHub issue number')
.requiredOption('-a, --amount <amount>', 'bounty amount')
.option('-t, --token <token>', 'bounty token', 'USDC')
.option('-n, --network <network>', 'Stellar network', 'testnet')
.option('--contract-id <contractId>', '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();
Loading
Loading