playbook/outfitter-agents/plugins/outfitter-stack/skills/stack-feedback/scripts/create-issue.ts

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();