Skip to content
Draft
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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,50 @@ source ~/.bashrc # for bash
| `copse ` (with space) | subcommands: `approval`, `create-prs`, `pr-status`, etc. |
| After a subcommand | `--dry-run`, `--all`, `--mine`, `--help` |

## Configuration

### Default Repos (.copserc)

You can configure default repos using a `.copserc` JSON file in your project root or any parent directory:

```json
{
"repos": ["acme/cool-project"]
}
```

### Comment Templates (~/.copse/comment-templates/)

Create custom canned responses for `copse create-issue` by adding Markdown files to `~/.copse/comment-templates/` in your home directory. Files are loaded alphabetically, so prefix with numbers to control order:

**Example: ~/.copse/comment-templates/01-research.md**
```markdown
---
label: Research – deeply investigate the issue
---
please deeply research this issue. Look at the codebase and related code, and provide a thorough analysis of what's involved, what the root cause is, and what options exist.
```

**Example: ~/.copse/comment-templates/02-plan.md**
```markdown
---
label: Plan – create an implementation plan
---
please look at the codebase and create a detailed plan for implementing this. Don't make changes yet, just outline the approach, which files need changing, and any trade-offs.
```

**Example: ~/.copse/comment-templates/03-fix.md**
```markdown
---
label: Fix – go and build this
---
please go and build this.
```

When creating issues, your custom templates will be presented as options. The agent mention (e.g. `@cursor`) is automatically prepended to your message if not already present. Multi-line messages are supported.

These templates are global to your user account and will be available across all projects.

## Commands

### Configuration (`.copserc`)
Expand Down
44 changes: 33 additions & 11 deletions commands/create-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { resolve, join } from "path";
import { tmpdir } from "os";
import { REPO_PATTERN, validateRepo, validateAgent } from "../lib/gh.js";
import { getOriginRepo } from "../lib/utils.js";
import { getCommentTemplates } from "../lib/config.js";
import { loadConfig } from "../lib/config.js";
import { launchAgentForRepository } from "../lib/cursor-api.js";

Expand Down Expand Up @@ -226,27 +227,27 @@ async function promptTitle(rl: readline.Interface): Promise<string> {
}
}

async function promptComment(rl: readline.Interface, mention: string): Promise<string | null> {
async function promptComment(rl: readline.Interface, mention: string, templates: CommentTemplate[]): Promise<string | null> {
console.error(`\n${ANSI.bold}Select comment for the agent:${ANSI.reset}`);
console.error(` ${ANSI.cyan}[0]${ANSI.reset} No comment`);
for (let i = 0; i < COMMENT_TEMPLATES.length; i++) {
console.error(` ${ANSI.cyan}[${i + 1}]${ANSI.reset} ${COMMENT_TEMPLATES[i].label}`);
for (let i = 0; i < templates.length; i++) {
console.error(` ${ANSI.cyan}[${i + 1}]${ANSI.reset} ${templates[i].label}`);
}
console.error(` ${ANSI.cyan}[${COMMENT_TEMPLATES.length + 1}]${ANSI.reset} Custom – type your own message`);
console.error(` ${ANSI.cyan}[${templates.length + 1}]${ANSI.reset} Custom – type your own message`);

for (;;) {
const raw = await rl.question(`\n${ANSI.bold}Choice (0-${COMMENT_TEMPLATES.length + 1}):${ANSI.reset} `);
const raw = await rl.question(`\n${ANSI.bold}Choice (0-${templates.length + 1}):${ANSI.reset} `);
const choice = parseInt(raw.trim(), 10);

if (choice === 0) return null;

if (choice >= 1 && choice <= COMMENT_TEMPLATES.length) {
const comment = COMMENT_TEMPLATES[choice - 1].build(mention);
if (choice >= 1 && choice <= templates.length) {
const comment = templates[choice - 1].build(mention);
console.error(`${ANSI.dim}→ ${comment}${ANSI.reset}`);
return comment;
}

if (choice === COMMENT_TEMPLATES.length + 1) {
if (choice === templates.length + 1) {
const custom = await rl.question(`${ANSI.bold}Comment:${ANSI.reset} `);
const trimmed = custom.trim();
if (!trimmed) {
Expand All @@ -267,6 +268,23 @@ function isAgent(s: string | undefined): boolean {
return !!s && AGENTS.includes(s.toLowerCase());
}

function loadCommentTemplates(): CommentTemplate[] {
const customTemplates = getCommentTemplates();
if (customTemplates && customTemplates.length > 0) {
return customTemplates.map((t) => ({
label: t.label,
build: (mention: string) => {
const msg = t.message.trim();
if (msg.startsWith(mention)) {
return msg;
}
return `${mention} ${msg}`;
},
}));
}
return COMMENT_TEMPLATES;
}

async function main(): Promise<void> {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
Expand All @@ -283,6 +301,8 @@ async function main(): Promise<void> {
return true;
});

const commentTemplates = loadCommentTemplates();

const help = `Usage: create-issue [repo] [title] [body] [agent] [options]

repo GitHub repo in owner/name format (e.g. acme/cool-project).
Expand Down Expand Up @@ -425,11 +445,13 @@ Examples:

if (!noComment) {
if (dryRun) {
comment = COMMENT_TEMPLATES[2].build(mention);
const defaultIdx = Math.min(2, commentTemplates.length - 1);
comment = commentTemplates[defaultIdx].build(mention);
} else if (rl) {
comment = await promptComment(rl, mention);
comment = await promptComment(rl, mention, commentTemplates);
} else {
comment = COMMENT_TEMPLATES[2].build(mention);
const defaultIdx = Math.min(2, commentTemplates.length - 1);
comment = commentTemplates[defaultIdx].build(mention);
}
}

Expand Down
60 changes: 59 additions & 1 deletion lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,22 @@
* 1. ~/.copserc (global config)
* 2. .copserc in cwd or parent directories (local config)
* Format: { "repos": ["owner/name", ...] }
*
* Comment templates are loaded from ~/.copse/comment-templates/*.md files
* with frontmatter for the label and body for the message.
*/

import { readFileSync, existsSync } from "fs";
import { readFileSync, existsSync, readdirSync, statSync } from "fs";
import { join, resolve } from "path";
import { homedir } from "os";

const CONFIG_FILENAME = ".copserc";
const COMMENT_TEMPLATES_DIR = join(homedir(), ".copse", "comment-templates");

export interface CommentTemplate {
label: string;
message: string;
}

export interface Copserc {
repos?: string[];
Expand Down Expand Up @@ -71,3 +80,52 @@ export function getConfiguredRepos(cwd: string = process.cwd()): string[] | null
if (!config?.repos || config.repos.length === 0) return null;
return config.repos;
}

function stripFrontmatter(content: string): { label: string | null; body: string } {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { label: null, body: content.trim() };

const frontmatter = match[1];
const body = match[2].trim();

const labelMatch = frontmatter.match(/^label:\s*(.+)$/m);
const label = labelMatch ? labelMatch[1].trim() : null;

return { label, body };
}

export function getCommentTemplates(): CommentTemplate[] | null {
if (!existsSync(COMMENT_TEMPLATES_DIR)) return null;

try {
const stat = statSync(COMMENT_TEMPLATES_DIR);
if (!stat.isDirectory()) return null;
} catch {
return null;
}

const templatesDir = COMMENT_TEMPLATES_DIR;

try {
const files = readdirSync(templatesDir)
.filter((f) => f.endsWith(".md"))
.sort();

if (files.length === 0) return null;

const templates: CommentTemplate[] = [];

for (const file of files) {
const content = readFileSync(join(templatesDir, file), "utf-8");
const { label, body } = stripFrontmatter(content);

if (!label || !body) continue;

templates.push({ label, message: body });
}

return templates.length > 0 ? templates : null;
} catch {
return null;
}
}
158 changes: 158 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import test from "node:test";
import assert from "node:assert/strict";
import { writeFileSync, unlinkSync, mkdirSync, existsSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir, homedir } from "os";

import { loadConfig, getCommentTemplates, getConfiguredRepos } from "../lib/config.js";

const TEMPLATES_DIR = join(homedir(), ".copse", "comment-templates");

function makeTempDir(): string {
const dir = join(tmpdir(), `copse-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}

function writeConfig(dir: string, config: object): void {
writeFileSync(join(dir, ".copserc"), JSON.stringify(config, null, 2));
}

function writeTemplate(filename: string, label: string, message: string): void {
mkdirSync(TEMPLATES_DIR, { recursive: true });
const content = `---\nlabel: ${label}\n---\n${message}`;
writeFileSync(join(TEMPLATES_DIR, filename), content);
}

function cleanupTemplates(): void {
try {
rmSync(TEMPLATES_DIR, { recursive: true, force: true });
} catch {
// ignore
}
}

function cleanup(dir: string): void {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
}

test("loadConfig returns null when no .copserc exists", () => {
const dir = makeTempDir();
const config = loadConfig(dir);
assert.equal(config, null);
});

test("loadConfig loads repos config", () => {
const dir = makeTempDir();
writeConfig(dir, { repos: ["owner/repo1", "owner/repo2"] });

const config = loadConfig(dir);
assert.deepEqual(config, { repos: ["owner/repo1", "owner/repo2"] });

cleanup(dir);
});

test("loadConfig only loads repos (commentTemplates moved to .copse/)", () => {
const dir = makeTempDir();
writeConfig(dir, { repos: ["owner/repo"] });

const config = loadConfig(dir);
assert.deepEqual(config, { repos: ["owner/repo"] });

cleanup(dir);
});

test("loadConfig returns null for invalid repos", () => {
const dir = makeTempDir();
writeConfig(dir, { repos: ["valid", 123, null] });

const config = loadConfig(dir);
assert.equal(config, null);

cleanup(dir);
});

test("getConfiguredRepos returns repos array", () => {
const dir = makeTempDir();
writeConfig(dir, { repos: ["owner/repo1", "owner/repo2"] });

const repos = getConfiguredRepos(dir);
assert.deepEqual(repos, ["owner/repo1", "owner/repo2"]);

cleanup(dir);
});

test("getConfiguredRepos returns null when no config file", () => {
const dir = makeTempDir();

const repos = getConfiguredRepos(dir);
assert.equal(repos, null);

cleanup(dir);
});

test("getCommentTemplates returns templates from MD files", () => {
cleanupTemplates();
writeTemplate("01-research.md", "Research", "please research this");
writeTemplate("02-fix.md", "Fix", "please fix this");

const result = getCommentTemplates();
assert.deepEqual(result, [
{ label: "Research", message: "please research this" },
{ label: "Fix", message: "please fix this" },
]);

cleanupTemplates();
});

test("getCommentTemplates returns null when no templates dir", () => {
cleanupTemplates();

const result = getCommentTemplates();
assert.equal(result, null);
});

test("getCommentTemplates sorts templates alphabetically", () => {
cleanupTemplates();
writeTemplate("z-zebra.md", "Zebra", "last");
writeTemplate("a-apple.md", "Apple", "first");
writeTemplate("m-middle.md", "Middle", "middle");

const result = getCommentTemplates();
assert.deepEqual(result, [
{ label: "Apple", message: "first" },
{ label: "Middle", message: "middle" },
{ label: "Zebra", message: "last" },
]);

cleanupTemplates();
});

test("getCommentTemplates skips templates without frontmatter label", () => {
cleanupTemplates();
writeTemplate("01-valid.md", "Valid", "valid message");
writeFileSync(join(TEMPLATES_DIR, "02-invalid.md"), "no frontmatter");

const result = getCommentTemplates();
assert.deepEqual(result, [
{ label: "Valid", message: "valid message" },
]);

cleanupTemplates();
});

test("getCommentTemplates handles multiline messages", () => {
cleanupTemplates();
writeTemplate("01-multiline.md", "Multiline", "line 1\nline 2\nline 3");

const result = getCommentTemplates();
assert.deepEqual(result, [
{ label: "Multiline", message: "line 1\nline 2\nline 3" },
]);

cleanupTemplates();
});
Loading