323 lines
8.6 KiB
TypeScript
323 lines
8.6 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Create GitHub issues for stack-feedback skill
|
|
*
|
|
* Usage:
|
|
* bun scripts/create-issue.ts --type bug --title "..." --package "@outfitter/result" ...
|
|
*
|
|
* Options:
|
|
* --type Issue type (bug, enhancement, docs, unclear-pattern, dx,
|
|
* migration-pattern, conversion-helper, compatibility, migration-docs)
|
|
* --title Issue title (prefix added automatically)
|
|
* --submit Actually create the issue (default: dry-run)
|
|
* --help Show this help message
|
|
*
|
|
* All other options are passed as template fields (e.g., --package, --description)
|
|
*/
|
|
|
|
import { parseArgs } from "util";
|
|
import { templates, templateTypes, type IssueTemplate } from "../templates";
|
|
|
|
const REPO = "outfitter-dev/outfitter";
|
|
|
|
/**
|
|
* Detect the origin repo from git remote.
|
|
* Returns GitHub owner/repo format or null if not detectable.
|
|
*/
|
|
async function detectOriginRepo(): Promise<string | null> {
|
|
try {
|
|
const proc = Bun.spawn(["git", "remote", "get-url", "origin"], {
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const exitCode = await proc.exited;
|
|
|
|
if (exitCode !== 0) {
|
|
// Silently fail - this is expected when not in a git repo
|
|
return null;
|
|
}
|
|
|
|
const url = stdout.trim();
|
|
|
|
// Handle SSH format: git@github.com:owner/repo.git
|
|
const sshMatch = url.match(/git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
if (sshMatch) {
|
|
return sshMatch[1];
|
|
}
|
|
|
|
// Handle HTTPS format: https://github.com/owner/repo.git
|
|
const httpsMatch = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
if (httpsMatch) {
|
|
return httpsMatch[1];
|
|
}
|
|
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface ParsedArgs {
|
|
type?: string;
|
|
title?: string;
|
|
submit?: boolean;
|
|
help?: boolean;
|
|
[key: string]: string | boolean | undefined;
|
|
}
|
|
|
|
function showHelp(): void {
|
|
console.log(`
|
|
stack-feedback: Create GitHub issues for Outfitter Stack feedback
|
|
|
|
USAGE
|
|
bun scripts/create-issue.ts --type <type> --title <title> [--field value]... [--submit]
|
|
|
|
OPTIONS
|
|
--type <type> Issue type (required)
|
|
--title <title> Issue title without prefix (required)
|
|
--submit Create issue (default: dry-run mode)
|
|
--help Show this help
|
|
|
|
ISSUE TYPES
|
|
${templateTypes.map((t) => ` ${t}`).join("\n")}
|
|
|
|
TEMPLATE FIELDS
|
|
Each issue type has required and optional fields. Pass them as --fieldName "value".
|
|
Use --type <type> without other args to see required fields.
|
|
|
|
EXAMPLES
|
|
# Dry-run a bug report
|
|
bun scripts/create-issue.ts \\
|
|
--type bug \\
|
|
--title "Result.unwrap throws on valid input" \\
|
|
--package "@outfitter/result" \\
|
|
--description "When calling unwrap on Ok, it throws" \\
|
|
--actual "Throws TypeError"
|
|
|
|
# Submit an enhancement request
|
|
bun scripts/create-issue.ts \\
|
|
--type enhancement \\
|
|
--title "Add Result.tap helper" \\
|
|
--package "@outfitter/result" \\
|
|
--description "Helper to run side effects without unwrapping" \\
|
|
--useCase "Logging without breaking chains" \\
|
|
--submit
|
|
`);
|
|
}
|
|
|
|
function showTypeHelp(template: IssueTemplate): void {
|
|
console.log(`
|
|
Type: ${template.type}
|
|
Labels: ${template.labels.join(", ")}
|
|
Title prefix: ${template.titlePrefix}
|
|
|
|
Required fields:
|
|
${template.requiredFields.map((f) => ` --${f}`).join("\n")}
|
|
|
|
Optional fields:
|
|
${template.optionalFields.map((f) => ` --${f}`).join("\n")}
|
|
`);
|
|
}
|
|
|
|
function interpolate(template: string, fields: Record<string, string>): string {
|
|
let result = template;
|
|
|
|
// Replace all {placeholder} patterns
|
|
const placeholderRegex = /\{(\w+)\}/g;
|
|
result = result.replace(placeholderRegex, (match, fieldName) => {
|
|
const value = fields[fieldName];
|
|
if (value !== undefined && value !== "") {
|
|
return value;
|
|
}
|
|
// Remove the section if the field is empty
|
|
return "_No information provided_";
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Escape single quotes for shell display.
|
|
* NOTE: This is ONLY used for displaying the command to users (dry-run output).
|
|
* Actual issue creation uses Bun.spawn with array arguments, which is safe.
|
|
*/
|
|
function escapeForShell(str: string): string {
|
|
return str.replace(/'/g, "'\\''");
|
|
}
|
|
|
|
function buildGhCommand(
|
|
title: string,
|
|
labels: string[],
|
|
body: string
|
|
): string {
|
|
const labelArgs = labels.map((l) => `--label '${l}'`).join(" ");
|
|
const escapedTitle = escapeForShell(title);
|
|
const escapedBody = escapeForShell(body);
|
|
|
|
return `gh issue create --repo ${REPO} --title '${escapedTitle}' ${labelArgs} --body '${escapedBody}'`;
|
|
}
|
|
|
|
async function createIssue(
|
|
title: string,
|
|
labels: string[],
|
|
body: string
|
|
): Promise<string> {
|
|
const proc = Bun.spawn(
|
|
[
|
|
"gh",
|
|
"issue",
|
|
"create",
|
|
"--repo",
|
|
REPO,
|
|
"--title",
|
|
title,
|
|
...labels.flatMap((l) => ["--label", l]),
|
|
"--body",
|
|
body,
|
|
],
|
|
{
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
}
|
|
);
|
|
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
const exitCode = await proc.exited;
|
|
|
|
if (exitCode !== 0) {
|
|
throw new Error(`gh issue create failed: ${stderr}`);
|
|
}
|
|
|
|
return stdout.trim();
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
// Parse known options first, collect rest as fields
|
|
const args = process.argv.slice(2);
|
|
|
|
// Handle --help early
|
|
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
showHelp();
|
|
process.exit(0);
|
|
}
|
|
|
|
// Build options object for parseArgs
|
|
const options: Record<string, { type: "string" | "boolean" }> = {
|
|
type: { type: "string" },
|
|
title: { type: "string" },
|
|
submit: { type: "boolean" },
|
|
help: { type: "boolean" },
|
|
};
|
|
|
|
// Add all possible template fields as string options
|
|
const allFields = new Set<string>();
|
|
for (const template of Object.values(templates)) {
|
|
for (const field of [...template.requiredFields, ...template.optionalFields]) {
|
|
allFields.add(field);
|
|
options[field] = { type: "string" };
|
|
}
|
|
}
|
|
|
|
let parsed: ParsedArgs;
|
|
try {
|
|
const result = parseArgs({
|
|
args,
|
|
options,
|
|
strict: false, // Allow unknown options
|
|
});
|
|
parsed = result.values as ParsedArgs;
|
|
} catch (error) {
|
|
console.error("Error parsing arguments:", error);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Validate type
|
|
const issueType = parsed.type;
|
|
if (!issueType) {
|
|
console.error("Error: --type is required");
|
|
console.error(`Available types: ${templateTypes.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const template = templates[issueType];
|
|
if (!template) {
|
|
console.error(`Error: Unknown type '${issueType}'`);
|
|
console.error(`Available types: ${templateTypes.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// If only type provided, show type help
|
|
if (!parsed.title && Object.keys(parsed).length <= 1) {
|
|
showTypeHelp(template);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Validate title
|
|
if (!parsed.title) {
|
|
console.error("Error: --title is required");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Collect field values
|
|
const fields: Record<string, string> = {};
|
|
for (const field of [...template.requiredFields, ...template.optionalFields]) {
|
|
const value = parsed[field];
|
|
if (typeof value === "string") {
|
|
fields[field] = value;
|
|
}
|
|
}
|
|
|
|
// Validate required fields
|
|
const missingFields = template.requiredFields.filter(
|
|
(f) => !fields[f] || fields[f] === ""
|
|
);
|
|
if (missingFields.length > 0) {
|
|
console.error(`Error: Missing required fields: ${missingFields.join(", ")}`);
|
|
showTypeHelp(template);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Build issue
|
|
const fullTitle = `${template.titlePrefix} ${parsed.title}`;
|
|
let body = interpolate(template.bodyTemplate, fields);
|
|
|
|
// Add origin repo context if detectable
|
|
const originRepo = await detectOriginRepo();
|
|
if (originRepo && originRepo !== REPO) {
|
|
// Insert before the footer
|
|
const footerMarker = "---\n\n*Created via";
|
|
if (body.includes(footerMarker)) {
|
|
body = body.replace(
|
|
footerMarker,
|
|
`## Discovered In\n\n[\`${originRepo}\`](https://github.com/${originRepo})\n\n${footerMarker}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (parsed.submit) {
|
|
// Submit mode: actually create the issue
|
|
try {
|
|
const url = await createIssue(fullTitle, template.labels, body);
|
|
console.log(`Created issue: ${url}`);
|
|
} catch (error) {
|
|
console.error("Failed to create issue:", error);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
// Dry-run mode: output JSON with command
|
|
const command = buildGhCommand(fullTitle, template.labels, body);
|
|
const output = {
|
|
command,
|
|
title: fullTitle,
|
|
labels: template.labels,
|
|
body,
|
|
};
|
|
console.log(JSON.stringify(output, null, 2));
|
|
}
|
|
}
|
|
|
|
main();
|