ticket-to-pr 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +647 -0
- package/bin/ticket-to-pr.js +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +515 -0
- package/dist/config.d.ts +76 -0
- package/dist/config.js +80 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +504 -0
- package/dist/lib/notion.d.ts +46 -0
- package/dist/lib/notion.js +235 -0
- package/dist/lib/paths.d.ts +4 -0
- package/dist/lib/paths.js +23 -0
- package/dist/lib/projects.d.ts +6 -0
- package/dist/lib/projects.js +38 -0
- package/dist/lib/utils.d.ts +21 -0
- package/dist/lib/utils.js +254 -0
- package/package.json +39 -0
- package/projects.example.json +8 -0
- package/prompts/execute.md +19 -0
- package/prompts/review.md +43 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { Client } from '@notionhq/client';
|
|
2
|
+
import { CONFIG } from '../config.js';
|
|
3
|
+
let _notion = null;
|
|
4
|
+
function notion() {
|
|
5
|
+
if (!_notion) {
|
|
6
|
+
_notion = new Client({ auth: process.env.NOTION_TOKEN });
|
|
7
|
+
}
|
|
8
|
+
return _notion;
|
|
9
|
+
}
|
|
10
|
+
function databaseId() {
|
|
11
|
+
return process.env.NOTION_DATABASE_ID;
|
|
12
|
+
}
|
|
13
|
+
// -- Helpers (exported for testing) --
|
|
14
|
+
export function extractPlainText(richText) {
|
|
15
|
+
return richText.map((t) => t.plain_text).join('');
|
|
16
|
+
}
|
|
17
|
+
function getProperty(page, name) {
|
|
18
|
+
return page.properties[name];
|
|
19
|
+
}
|
|
20
|
+
function extractTitle(page) {
|
|
21
|
+
// Try 'Name' first (Notion default), then 'Title'
|
|
22
|
+
const prop = (getProperty(page, 'Name') ?? getProperty(page, 'Title'));
|
|
23
|
+
return prop?.title ? extractPlainText(prop.title) : '';
|
|
24
|
+
}
|
|
25
|
+
function extractRichText(page, name) {
|
|
26
|
+
const prop = getProperty(page, name);
|
|
27
|
+
return prop?.rich_text ? extractPlainText(prop.rich_text) : '';
|
|
28
|
+
}
|
|
29
|
+
function extractSelect(page, name) {
|
|
30
|
+
const prop = getProperty(page, name);
|
|
31
|
+
return prop?.select?.name ?? '';
|
|
32
|
+
}
|
|
33
|
+
function extractStatus(page) {
|
|
34
|
+
const prop = getProperty(page, 'Status');
|
|
35
|
+
return prop?.status?.name ?? '';
|
|
36
|
+
}
|
|
37
|
+
export function extractProjectName(page) {
|
|
38
|
+
// Support both Select and Rich Text types for Project
|
|
39
|
+
const prop = getProperty(page, 'Project');
|
|
40
|
+
if (!prop)
|
|
41
|
+
return '';
|
|
42
|
+
if (prop.type === 'select') {
|
|
43
|
+
const sel = prop.select;
|
|
44
|
+
return sel?.name ?? '';
|
|
45
|
+
}
|
|
46
|
+
if (prop.type === 'rich_text') {
|
|
47
|
+
const rt = prop.rich_text;
|
|
48
|
+
return rt ? extractPlainText(rt) : '';
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
export function pageToTicket(page) {
|
|
53
|
+
return {
|
|
54
|
+
id: page.id,
|
|
55
|
+
title: extractTitle(page),
|
|
56
|
+
project: extractProjectName(page),
|
|
57
|
+
status: extractStatus(page),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function blockToMarkdown(block) {
|
|
61
|
+
const type = block.type;
|
|
62
|
+
const data = block[type];
|
|
63
|
+
const text = data?.rich_text ? extractPlainText(data.rich_text) : '';
|
|
64
|
+
switch (type) {
|
|
65
|
+
case 'paragraph':
|
|
66
|
+
return text;
|
|
67
|
+
case 'heading_1':
|
|
68
|
+
return `# ${text}`;
|
|
69
|
+
case 'heading_2':
|
|
70
|
+
return `## ${text}`;
|
|
71
|
+
case 'heading_3':
|
|
72
|
+
return `### ${text}`;
|
|
73
|
+
case 'bulleted_list_item':
|
|
74
|
+
return `- ${text}`;
|
|
75
|
+
case 'numbered_list_item':
|
|
76
|
+
return `1. ${text}`;
|
|
77
|
+
case 'to_do': {
|
|
78
|
+
const todo = block[type];
|
|
79
|
+
return `- [${todo?.checked ? 'x' : ' '}] ${text}`;
|
|
80
|
+
}
|
|
81
|
+
case 'code': {
|
|
82
|
+
const code = block[type];
|
|
83
|
+
return `\`\`\`${code?.language ?? ''}\n${text}\n\`\`\``;
|
|
84
|
+
}
|
|
85
|
+
case 'quote':
|
|
86
|
+
return `> ${text}`;
|
|
87
|
+
case 'divider':
|
|
88
|
+
return '---';
|
|
89
|
+
default:
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// -- Exported Functions --
|
|
94
|
+
/**
|
|
95
|
+
* Fetch all tickets with a given status from the Notion database.
|
|
96
|
+
*/
|
|
97
|
+
export async function fetchTicketsByStatus(status) {
|
|
98
|
+
const response = await notion().databases.query({
|
|
99
|
+
database_id: databaseId(),
|
|
100
|
+
filter: {
|
|
101
|
+
property: 'Status',
|
|
102
|
+
status: { equals: status },
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
return response.results
|
|
106
|
+
.filter((r) => 'properties' in r)
|
|
107
|
+
.map(pageToTicket);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Read full ticket details including page body blocks.
|
|
111
|
+
*/
|
|
112
|
+
export async function fetchTicketDetails(pageId) {
|
|
113
|
+
const [page, blocksResponse] = await Promise.all([
|
|
114
|
+
notion().pages.retrieve({ page_id: pageId }),
|
|
115
|
+
notion().blocks.children.list({ block_id: pageId, page_size: 100 }),
|
|
116
|
+
]);
|
|
117
|
+
const blocks = blocksResponse.results.filter((b) => 'type' in b);
|
|
118
|
+
const bodyBlocks = blocks.map(blockToMarkdown).filter(Boolean).join('\n\n');
|
|
119
|
+
const ticket = pageToTicket(page);
|
|
120
|
+
return {
|
|
121
|
+
...ticket,
|
|
122
|
+
description: extractRichText(page, 'Description'),
|
|
123
|
+
bodyBlocks,
|
|
124
|
+
spec: extractRichText(page, 'Spec') || undefined,
|
|
125
|
+
impact: extractRichText(page, 'Impact') || undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Write review results back to the ticket properties.
|
|
130
|
+
*/
|
|
131
|
+
export async function writeReviewResults(pageId, results) {
|
|
132
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
133
|
+
const properties = {
|
|
134
|
+
Ease: { number: results.easeScore },
|
|
135
|
+
Confidence: { number: results.confidenceScore },
|
|
136
|
+
Spec: {
|
|
137
|
+
rich_text: chunkRichText(results.spec),
|
|
138
|
+
},
|
|
139
|
+
Impact: {
|
|
140
|
+
rich_text: chunkRichText(`${results.impactReport}\n\nFiles: ${results.affectedFiles.join(', ')}${results.risks ? `\n\nRisks: ${results.risks}` : ''}`),
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
await notion().pages.update({ page_id: pageId, properties });
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
// If Confidence property doesn't exist yet, retry without it
|
|
148
|
+
const errMsg = String(e);
|
|
149
|
+
if (errMsg.includes('Confidence')) {
|
|
150
|
+
delete properties.Confidence;
|
|
151
|
+
await notion().pages.update({ page_id: pageId, properties });
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Write execution results back to the ticket.
|
|
160
|
+
*/
|
|
161
|
+
export async function writeExecutionResults(pageId, results) {
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
+
const properties = {
|
|
164
|
+
Branch: {
|
|
165
|
+
rich_text: [{ text: { content: results.branch } }],
|
|
166
|
+
},
|
|
167
|
+
Cost: {
|
|
168
|
+
rich_text: [{ text: { content: `$${(Math.round(results.cost * 100) / 100).toFixed(2)}` } }],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
if (results.prUrl) {
|
|
172
|
+
properties['PR URL'] = {
|
|
173
|
+
url: results.prUrl,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
await notion().pages.update({ page_id: pageId, properties });
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Move a ticket to a new status column.
|
|
180
|
+
*/
|
|
181
|
+
export async function moveTicketStatus(pageId, newStatus) {
|
|
182
|
+
await notion().pages.update({
|
|
183
|
+
page_id: pageId,
|
|
184
|
+
properties: {
|
|
185
|
+
Status: { status: { name: newStatus } },
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Write error details and move ticket to Failed.
|
|
191
|
+
*/
|
|
192
|
+
export async function writeFailure(pageId, error) {
|
|
193
|
+
await notion().pages.update({
|
|
194
|
+
page_id: pageId,
|
|
195
|
+
properties: {
|
|
196
|
+
Status: { status: { name: CONFIG.COLUMNS.FAILED } },
|
|
197
|
+
Impact: {
|
|
198
|
+
rich_text: chunkRichText(`ERROR: ${error}`),
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Add a comment to a Notion page (best-effort).
|
|
205
|
+
* Used for agent audit trail - does not throw if it fails.
|
|
206
|
+
*/
|
|
207
|
+
export async function addComment(pageId, text) {
|
|
208
|
+
try {
|
|
209
|
+
await notion().comments.create({
|
|
210
|
+
parent: { page_id: pageId },
|
|
211
|
+
rich_text: [{ text: { content: text } }],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
// Best-effort: log but don't throw
|
|
216
|
+
console.warn(`[NOTION] Failed to add comment to ${pageId}:`, e);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export function truncate(str, maxLen) {
|
|
220
|
+
if (str.length <= maxLen)
|
|
221
|
+
return str;
|
|
222
|
+
return str.slice(0, maxLen - 3) + '...';
|
|
223
|
+
}
|
|
224
|
+
/** Chunk text into Notion rich_text segments (each max 2000 chars). */
|
|
225
|
+
export function chunkRichText(str) {
|
|
226
|
+
const LIMIT = 2000;
|
|
227
|
+
if (str.length <= LIMIT) {
|
|
228
|
+
return [{ text: { content: str } }];
|
|
229
|
+
}
|
|
230
|
+
const chunks = [];
|
|
231
|
+
for (let i = 0; i < str.length; i += LIMIT) {
|
|
232
|
+
chunks.push({ text: { content: str.slice(i, i + LIMIT) } });
|
|
233
|
+
}
|
|
234
|
+
return chunks;
|
|
235
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
/**
|
|
6
|
+
* Root of the package installation — used for bundled assets (prompts/).
|
|
7
|
+
* Walks up from the current file until it finds package.json.
|
|
8
|
+
*/
|
|
9
|
+
function findPackageRoot() {
|
|
10
|
+
let dir = __dirname;
|
|
11
|
+
while (true) {
|
|
12
|
+
if (existsSync(join(dir, 'package.json')))
|
|
13
|
+
return dir;
|
|
14
|
+
const parent = dirname(dir);
|
|
15
|
+
if (parent === dir)
|
|
16
|
+
return __dirname; // filesystem root, shouldn't happen
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Package root — for bundled assets like prompts/ */
|
|
21
|
+
export const PACKAGE_ROOT = findPackageRoot();
|
|
22
|
+
/** User's working directory — for config files (.env.local, projects.json) */
|
|
23
|
+
export const CONFIG_DIR = process.cwd();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function getProjectDir(name: string): string | undefined;
|
|
2
|
+
export declare function getProjectNames(): string[];
|
|
3
|
+
export declare function getBuildCommand(name: string): string | undefined;
|
|
4
|
+
export declare function getAllProjects(): Record<string, string>;
|
|
5
|
+
/** Reset the in-memory cache (for tests). */
|
|
6
|
+
export declare function _resetCache(): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CONFIG_DIR } from './paths.js';
|
|
4
|
+
const PROJECTS_PATH = join(CONFIG_DIR, 'projects.json');
|
|
5
|
+
let cache = null;
|
|
6
|
+
function load() {
|
|
7
|
+
if (cache)
|
|
8
|
+
return cache;
|
|
9
|
+
try {
|
|
10
|
+
const content = readFileSync(PROJECTS_PATH, 'utf-8');
|
|
11
|
+
cache = JSON.parse(content);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
cache = { projects: {} };
|
|
15
|
+
}
|
|
16
|
+
return cache;
|
|
17
|
+
}
|
|
18
|
+
export function getProjectDir(name) {
|
|
19
|
+
return load().projects[name]?.directory;
|
|
20
|
+
}
|
|
21
|
+
export function getProjectNames() {
|
|
22
|
+
return Object.keys(load().projects);
|
|
23
|
+
}
|
|
24
|
+
export function getBuildCommand(name) {
|
|
25
|
+
return load().projects[name]?.buildCommand;
|
|
26
|
+
}
|
|
27
|
+
export function getAllProjects() {
|
|
28
|
+
const data = load();
|
|
29
|
+
const result = {};
|
|
30
|
+
for (const [name, entry] of Object.entries(data.projects)) {
|
|
31
|
+
result[name] = entry.directory;
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
/** Reset the in-memory cache (for tests). */
|
|
36
|
+
export function _resetCache() {
|
|
37
|
+
cache = null;
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare function sleep(ms: number): Promise<void>;
|
|
2
|
+
export declare function clamp(val: number, min: number, max: number): number;
|
|
3
|
+
export declare function extractJsonFromOutput(text: string): Record<string, unknown> | null;
|
|
4
|
+
export declare function shellEscape(str: string): string;
|
|
5
|
+
export declare function extractNumber(ticket: {
|
|
6
|
+
impact?: string;
|
|
7
|
+
}, field: string): string;
|
|
8
|
+
export declare function loadEnv(filepath: string): void;
|
|
9
|
+
export declare function mask(str: string): string;
|
|
10
|
+
export declare function writeEnvFile(filepath: string, updates: Record<string, string>): void;
|
|
11
|
+
export declare function updateProjectsFile(filepath: string, projects: Array<{
|
|
12
|
+
name: string;
|
|
13
|
+
dir: string;
|
|
14
|
+
buildCmd?: string;
|
|
15
|
+
}>): void;
|
|
16
|
+
export declare function getDefaultBranch(projectDir: string): string;
|
|
17
|
+
/** Reset the default branch cache (for tests). */
|
|
18
|
+
export declare function _resetDefaultBranchCache(): void;
|
|
19
|
+
export declare function ensureWorktreesIgnored(projectDir: string): void;
|
|
20
|
+
export declare function createWorktree(projectDir: string, branchName: string, worktreeDir: string): void;
|
|
21
|
+
export declare function removeWorktree(projectDir: string, worktreeDir: string): void;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { rmSync, mkdirSync } from 'node:fs';
|
|
5
|
+
// -- Pure utilities --
|
|
6
|
+
export function sleep(ms) {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
export function clamp(val, min, max) {
|
|
10
|
+
return Math.max(min, Math.min(max, val));
|
|
11
|
+
}
|
|
12
|
+
export function extractJsonFromOutput(text) {
|
|
13
|
+
// Find the last JSON code block
|
|
14
|
+
const codeBlockRegex = /```(?:json)?\s*\n([\s\S]*?)\n```/g;
|
|
15
|
+
let lastMatch = null;
|
|
16
|
+
let match;
|
|
17
|
+
while ((match = codeBlockRegex.exec(text)) !== null) {
|
|
18
|
+
lastMatch = match[1];
|
|
19
|
+
}
|
|
20
|
+
// Try parsing the last code block first
|
|
21
|
+
if (lastMatch) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(lastMatch);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Fall through
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Try parsing the entire text as JSON (in case no code blocks)
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(text);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Fall through
|
|
35
|
+
}
|
|
36
|
+
// Try finding a raw JSON object
|
|
37
|
+
const jsonRegex = /\{[\s\S]*"easeScore"[\s\S]*\}/;
|
|
38
|
+
const rawMatch = text.match(jsonRegex);
|
|
39
|
+
if (rawMatch) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(rawMatch[0]);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Give up
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
export function shellEscape(str) {
|
|
50
|
+
return `'${str.replace(/'/g, "'\\''")}'`;
|
|
51
|
+
}
|
|
52
|
+
export function extractNumber(ticket, field) {
|
|
53
|
+
const text = ticket.impact ?? '';
|
|
54
|
+
if (field === 'ease') {
|
|
55
|
+
const match = text.match(/Ease[:\s]*(\d+)/i);
|
|
56
|
+
return match ? match[1] : '?';
|
|
57
|
+
}
|
|
58
|
+
if (field === 'confidence') {
|
|
59
|
+
const match = text.match(/Confidence[:\s]*(\d+)/i);
|
|
60
|
+
return match ? match[1] : '?';
|
|
61
|
+
}
|
|
62
|
+
return '?';
|
|
63
|
+
}
|
|
64
|
+
export function loadEnv(filepath) {
|
|
65
|
+
try {
|
|
66
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
67
|
+
for (const line of content.split('\n')) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
70
|
+
continue;
|
|
71
|
+
const eqIndex = trimmed.indexOf('=');
|
|
72
|
+
if (eqIndex === -1)
|
|
73
|
+
continue;
|
|
74
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
75
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
76
|
+
if (!process.env[key]) {
|
|
77
|
+
process.env[key] = value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// .env.local doesn't exist, that's fine if env vars are set elsewhere
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function mask(str) {
|
|
86
|
+
if (str.length <= 8)
|
|
87
|
+
return '****';
|
|
88
|
+
return str.slice(0, 4) + '...' + str.slice(-4);
|
|
89
|
+
}
|
|
90
|
+
export function writeEnvFile(filepath, updates) {
|
|
91
|
+
let lines = [];
|
|
92
|
+
try {
|
|
93
|
+
lines = readFileSync(filepath, 'utf-8').split('\n');
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// File doesn't exist yet
|
|
97
|
+
}
|
|
98
|
+
const remaining = { ...updates };
|
|
99
|
+
// Update existing keys in place
|
|
100
|
+
const updatedLines = lines.map((line) => {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
103
|
+
return line;
|
|
104
|
+
const eqIndex = trimmed.indexOf('=');
|
|
105
|
+
if (eqIndex === -1)
|
|
106
|
+
return line;
|
|
107
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
108
|
+
if (key in remaining) {
|
|
109
|
+
const val = remaining[key];
|
|
110
|
+
delete remaining[key];
|
|
111
|
+
return `${key}=${val}`;
|
|
112
|
+
}
|
|
113
|
+
return line;
|
|
114
|
+
});
|
|
115
|
+
// Append any new keys
|
|
116
|
+
for (const [key, val] of Object.entries(remaining)) {
|
|
117
|
+
updatedLines.push(`${key}=${val}`);
|
|
118
|
+
}
|
|
119
|
+
writeFileSync(filepath, updatedLines.join('\n'), { mode: 0o600 });
|
|
120
|
+
}
|
|
121
|
+
export function updateProjectsFile(filepath, projects) {
|
|
122
|
+
let data = {
|
|
123
|
+
projects: {},
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
127
|
+
data = JSON.parse(content);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// File doesn't exist or invalid JSON — start fresh
|
|
131
|
+
}
|
|
132
|
+
for (const proj of projects) {
|
|
133
|
+
data.projects[proj.name] = {
|
|
134
|
+
directory: proj.dir,
|
|
135
|
+
...(proj.buildCmd ? { buildCommand: proj.buildCmd } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n');
|
|
139
|
+
}
|
|
140
|
+
// -- Default branch detection --
|
|
141
|
+
const defaultBranchCache = new Map();
|
|
142
|
+
export function getDefaultBranch(projectDir) {
|
|
143
|
+
const cached = defaultBranchCache.get(projectDir);
|
|
144
|
+
if (cached)
|
|
145
|
+
return cached;
|
|
146
|
+
let branch = 'main'; // ultimate fallback
|
|
147
|
+
// Try reading the remote HEAD symbolic ref
|
|
148
|
+
try {
|
|
149
|
+
const ref = execSync('git symbolic-ref refs/remotes/origin/HEAD', {
|
|
150
|
+
cwd: projectDir,
|
|
151
|
+
stdio: 'pipe',
|
|
152
|
+
}).toString().trim();
|
|
153
|
+
// refs/remotes/origin/main → main
|
|
154
|
+
const parsed = ref.replace('refs/remotes/origin/', '');
|
|
155
|
+
if (parsed)
|
|
156
|
+
branch = parsed;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Remote HEAD not set — try common branch names
|
|
160
|
+
try {
|
|
161
|
+
execSync('git rev-parse --verify main', { cwd: projectDir, stdio: 'pipe' });
|
|
162
|
+
branch = 'main';
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
try {
|
|
166
|
+
execSync('git rev-parse --verify master', { cwd: projectDir, stdio: 'pipe' });
|
|
167
|
+
branch = 'master';
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Give up, default to 'main'
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
defaultBranchCache.set(projectDir, branch);
|
|
175
|
+
return branch;
|
|
176
|
+
}
|
|
177
|
+
/** Reset the default branch cache (for tests). */
|
|
178
|
+
export function _resetDefaultBranchCache() {
|
|
179
|
+
defaultBranchCache.clear();
|
|
180
|
+
}
|
|
181
|
+
// -- Git worktree helpers --
|
|
182
|
+
export function ensureWorktreesIgnored(projectDir) {
|
|
183
|
+
const gitignorePath = join(projectDir, '.gitignore');
|
|
184
|
+
try {
|
|
185
|
+
const content = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
|
|
186
|
+
const lines = content.split('\n');
|
|
187
|
+
if (!lines.some((line) => line.trim() === '.worktrees' || line.trim() === '.worktrees/')) {
|
|
188
|
+
const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
|
|
189
|
+
writeFileSync(gitignorePath, content + separator + '.worktrees/\n');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Best effort — don't block worktree creation over gitignore
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export function createWorktree(projectDir, branchName, worktreeDir) {
|
|
197
|
+
mkdirSync(join(projectDir, '.worktrees'), { recursive: true });
|
|
198
|
+
ensureWorktreesIgnored(projectDir);
|
|
199
|
+
// Clean up stale worktree if it exists from a crashed run
|
|
200
|
+
if (existsSync(worktreeDir)) {
|
|
201
|
+
try {
|
|
202
|
+
execSync(`git worktree remove ${shellEscape(worktreeDir)} --force`, {
|
|
203
|
+
cwd: projectDir,
|
|
204
|
+
stdio: 'pipe',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
rmSync(worktreeDir, { recursive: true, force: true });
|
|
209
|
+
execSync('git worktree prune', { cwd: projectDir, stdio: 'pipe' });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Try creating with a new branch first
|
|
213
|
+
try {
|
|
214
|
+
execSync(`git worktree add ${shellEscape(worktreeDir)} -b ${shellEscape(branchName)}`, {
|
|
215
|
+
cwd: projectDir,
|
|
216
|
+
stdio: 'pipe',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Branch might already exist (retry scenario) — attach to existing branch
|
|
221
|
+
try {
|
|
222
|
+
execSync(`git worktree add ${shellEscape(worktreeDir)} ${shellEscape(branchName)}`, {
|
|
223
|
+
cwd: projectDir,
|
|
224
|
+
stdio: 'pipe',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
throw new Error(`Failed to create worktree for branch ${branchName}: ${e}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export function removeWorktree(projectDir, worktreeDir) {
|
|
233
|
+
try {
|
|
234
|
+
execSync(`git worktree remove ${shellEscape(worktreeDir)} --force`, {
|
|
235
|
+
cwd: projectDir,
|
|
236
|
+
stdio: 'pipe',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Fallback: manual cleanup
|
|
241
|
+
try {
|
|
242
|
+
rmSync(worktreeDir, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Best effort
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
execSync('git worktree prune', { cwd: projectDir, stdio: 'pipe' });
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Best effort
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ticket-to-pr",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Drag a Notion ticket, get a pull request. AI-powered dev automation.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ticket-to-pr": "bin/ticket-to-pr.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"bin",
|
|
12
|
+
"prompts",
|
|
13
|
+
"projects.example.json",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.0.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"prepublishOnly": "npm run test && npm run build",
|
|
22
|
+
"start": "tsx index.ts",
|
|
23
|
+
"dry-run": "tsx index.ts --dry-run",
|
|
24
|
+
"once": "tsx index.ts --once",
|
|
25
|
+
"init": "tsx index.ts init",
|
|
26
|
+
"doctor": "tsx index.ts doctor",
|
|
27
|
+
"test": "vitest run"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.0",
|
|
31
|
+
"@notionhq/client": "^2.2.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"typescript": "^5.7.0",
|
|
35
|
+
"tsx": "^4.19.0",
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"vitest": "^3.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
You are a senior engineer implementing a ticket.
|
|
2
|
+
|
|
3
|
+
## Your Task
|
|
4
|
+
You have been given a ticket with an implementation spec. Follow the spec and implement the changes.
|
|
5
|
+
|
|
6
|
+
## Rules
|
|
7
|
+
1. You are on a feature branch in a git worktree. Do NOT create or switch branches.
|
|
8
|
+
2. Follow the project's existing patterns and conventions.
|
|
9
|
+
3. The project's CLAUDE.md contains critical rules — read and follow them.
|
|
10
|
+
4. Make atomic commits with clear messages as you work.
|
|
11
|
+
5. DO NOT run `git push`. TicketToPR handles pushing after validation.
|
|
12
|
+
6. DO NOT run destructive commands (rm -rf, drop tables, reset --hard, etc.).
|
|
13
|
+
7. DO NOT run `npx prisma db push` or any database migration commands.
|
|
14
|
+
8. If the spec is unclear, implement the most conservative interpretation.
|
|
15
|
+
9. Run existing tests if available, but do not add new test files unless the spec explicitly requires it.
|
|
16
|
+
10. Do not modify files outside the scope of the spec.
|
|
17
|
+
|
|
18
|
+
## When Done
|
|
19
|
+
Commit all changes with a final commit message summarizing what was done. The commit message should reference the ticket title.
|