im-pickle-rick 0.1.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 +242 -0
- package/bin.js +3 -0
- package/dist/pickle +0 -0
- package/dist/worker-executor.js +207 -0
- package/package.json +53 -0
- package/src/games/GameSidebarManager.test.ts +64 -0
- package/src/games/GameSidebarManager.ts +78 -0
- package/src/games/gameboy/GameboyView.test.ts +25 -0
- package/src/games/gameboy/GameboyView.ts +100 -0
- package/src/games/gameboy/gameboy-polyfills.ts +313 -0
- package/src/games/index.test.ts +9 -0
- package/src/games/index.ts +4 -0
- package/src/games/snake/SnakeGame.test.ts +35 -0
- package/src/games/snake/SnakeGame.ts +145 -0
- package/src/games/snake/SnakeView.test.ts +25 -0
- package/src/games/snake/SnakeView.ts +290 -0
- package/src/index.test.ts +24 -0
- package/src/index.ts +141 -0
- package/src/services/commands/worker.test.ts +14 -0
- package/src/services/commands/worker.ts +262 -0
- package/src/services/config/index.ts +2 -0
- package/src/services/config/settings.test.ts +42 -0
- package/src/services/config/settings.ts +220 -0
- package/src/services/config/state.test.ts +88 -0
- package/src/services/config/state.ts +130 -0
- package/src/services/config/types.ts +39 -0
- package/src/services/execution/index.ts +1 -0
- package/src/services/execution/pickle-source.test.ts +88 -0
- package/src/services/execution/pickle-source.ts +264 -0
- package/src/services/execution/prompt.test.ts +93 -0
- package/src/services/execution/prompt.ts +322 -0
- package/src/services/execution/sequential.test.ts +91 -0
- package/src/services/execution/sequential.ts +422 -0
- package/src/services/execution/worker-client.ts +94 -0
- package/src/services/execution/worker-executor.ts +41 -0
- package/src/services/execution/worker.test.ts +73 -0
- package/src/services/git/branch.test.ts +147 -0
- package/src/services/git/branch.ts +128 -0
- package/src/services/git/diff.test.ts +113 -0
- package/src/services/git/diff.ts +323 -0
- package/src/services/git/index.ts +4 -0
- package/src/services/git/pr.test.ts +104 -0
- package/src/services/git/pr.ts +192 -0
- package/src/services/git/worktree.test.ts +99 -0
- package/src/services/git/worktree.ts +141 -0
- package/src/services/providers/base.test.ts +86 -0
- package/src/services/providers/base.ts +438 -0
- package/src/services/providers/codex.test.ts +39 -0
- package/src/services/providers/codex.ts +208 -0
- package/src/services/providers/gemini.test.ts +40 -0
- package/src/services/providers/gemini.ts +169 -0
- package/src/services/providers/index.test.ts +28 -0
- package/src/services/providers/index.ts +41 -0
- package/src/services/providers/opencode.test.ts +64 -0
- package/src/services/providers/opencode.ts +228 -0
- package/src/services/providers/types.ts +44 -0
- package/src/skills/code-implementer.md +105 -0
- package/src/skills/code-researcher.md +78 -0
- package/src/skills/implementation-planner.md +105 -0
- package/src/skills/plan-reviewer.md +100 -0
- package/src/skills/prd-drafter.md +123 -0
- package/src/skills/research-reviewer.md +79 -0
- package/src/skills/ruthless-refactorer.md +52 -0
- package/src/skills/ticket-manager.md +135 -0
- package/src/types/index.ts +2 -0
- package/src/types/rpc.ts +14 -0
- package/src/types/tasks.ts +50 -0
- package/src/types.d.ts +9 -0
- package/src/ui/common.ts +28 -0
- package/src/ui/components/FilePickerView.test.ts +79 -0
- package/src/ui/components/FilePickerView.ts +161 -0
- package/src/ui/components/MultiLineInput.test.ts +27 -0
- package/src/ui/components/MultiLineInput.ts +233 -0
- package/src/ui/components/SessionChip.test.ts +69 -0
- package/src/ui/components/SessionChip.ts +481 -0
- package/src/ui/components/ToyboxSidebar.test.ts +36 -0
- package/src/ui/components/ToyboxSidebar.ts +329 -0
- package/src/ui/components/refactor_plan.md +35 -0
- package/src/ui/controllers/DashboardController.integration.test.ts +43 -0
- package/src/ui/controllers/DashboardController.ts +650 -0
- package/src/ui/dashboard.test.ts +43 -0
- package/src/ui/dashboard.ts +309 -0
- package/src/ui/dialogs/DashboardDialog.test.ts +146 -0
- package/src/ui/dialogs/DashboardDialog.ts +399 -0
- package/src/ui/dialogs/Dialog.test.ts +50 -0
- package/src/ui/dialogs/Dialog.ts +241 -0
- package/src/ui/dialogs/DialogSidebar.test.ts +60 -0
- package/src/ui/dialogs/DialogSidebar.ts +71 -0
- package/src/ui/dialogs/DiffViewDialog.test.ts +57 -0
- package/src/ui/dialogs/DiffViewDialog.ts +510 -0
- package/src/ui/dialogs/PRPreviewDialog.test.ts +50 -0
- package/src/ui/dialogs/PRPreviewDialog.ts +346 -0
- package/src/ui/dialogs/test-utils.ts +232 -0
- package/src/ui/file-picker-utils.test.ts +71 -0
- package/src/ui/file-picker-utils.ts +200 -0
- package/src/ui/input-chrome.test.ts +62 -0
- package/src/ui/input-chrome.ts +172 -0
- package/src/ui/logger.test.ts +68 -0
- package/src/ui/logger.ts +45 -0
- package/src/ui/mock-factory.ts +6 -0
- package/src/ui/spinner.test.ts +65 -0
- package/src/ui/spinner.ts +41 -0
- package/src/ui/test-setup.ts +300 -0
- package/src/ui/theme.test.ts +23 -0
- package/src/ui/theme.ts +16 -0
- package/src/ui/views/LandingView.integration.test.ts +21 -0
- package/src/ui/views/LandingView.test.ts +24 -0
- package/src/ui/views/LandingView.ts +221 -0
- package/src/ui/views/LogView.test.ts +24 -0
- package/src/ui/views/LogView.ts +277 -0
- package/src/ui/views/ToyboxView.test.ts +46 -0
- package/src/ui/views/ToyboxView.ts +323 -0
- package/src/utils/clipboard.test.ts +86 -0
- package/src/utils/clipboard.ts +100 -0
- package/src/utils/index.test.ts +68 -0
- package/src/utils/index.ts +95 -0
- package/src/utils/persona.test.ts +12 -0
- package/src/utils/persona.ts +8 -0
- package/src/utils/project-root.test.ts +38 -0
- package/src/utils/project-root.ts +22 -0
- package/src/utils/resources.test.ts +64 -0
- package/src/utils/resources.ts +92 -0
- package/src/utils/search.test.ts +48 -0
- package/src/utils/search.ts +103 -0
- package/src/utils/session-tracker.test.ts +46 -0
- package/src/utils/session-tracker.ts +67 -0
- package/src/utils/spinner.test.ts +54 -0
- package/src/utils/spinner.ts +87 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import simpleGit, { type SimpleGit } from "simple-git";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface ChangedFile {
|
|
6
|
+
path: string;
|
|
7
|
+
status: "added" | "modified" | "deleted" | "renamed";
|
|
8
|
+
additions: number;
|
|
9
|
+
deletions: number;
|
|
10
|
+
oldPath?: string; // For renamed files
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get list of changed files between worktree branch and base branch
|
|
15
|
+
* Includes both committed changes AND uncommitted working tree changes
|
|
16
|
+
*/
|
|
17
|
+
export async function getChangedFiles(
|
|
18
|
+
worktreeDir: string,
|
|
19
|
+
baseBranch: string,
|
|
20
|
+
gitInstance?: SimpleGit
|
|
21
|
+
): Promise<ChangedFile[]> {
|
|
22
|
+
const git: SimpleGit = gitInstance || simpleGit(worktreeDir);
|
|
23
|
+
const filesMap = new Map<string, ChangedFile>();
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
// 1. Get committed changes between base branch and HEAD
|
|
27
|
+
try {
|
|
28
|
+
const committedDiff = await git.diffSummary([`${baseBranch}...HEAD`]);
|
|
29
|
+
for (const file of committedDiff.files) {
|
|
30
|
+
let path = file.file;
|
|
31
|
+
let oldPath: string | undefined;
|
|
32
|
+
let status: ChangedFile["status"] = "modified";
|
|
33
|
+
|
|
34
|
+
// Parse renamed files (format: "old => new" or "{old => new}")
|
|
35
|
+
if (file.file.includes(" => ")) {
|
|
36
|
+
status = "renamed";
|
|
37
|
+
let match = file.file.match(/(.*?){(.+?)\s*=>\s*(.+?)}(.*)/);
|
|
38
|
+
if (match) {
|
|
39
|
+
const prefix = match[1] || "";
|
|
40
|
+
const suffix = match[4] || "";
|
|
41
|
+
oldPath = prefix + match[2] + suffix;
|
|
42
|
+
path = prefix + match[3] + suffix;
|
|
43
|
+
} else {
|
|
44
|
+
match = file.file.match(/(.+?)\s*=>\s*(.+)/);
|
|
45
|
+
if (match) {
|
|
46
|
+
oldPath = match[1];
|
|
47
|
+
path = match[2];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
filesMap.set(path, {
|
|
53
|
+
path,
|
|
54
|
+
oldPath,
|
|
55
|
+
status,
|
|
56
|
+
additions: "insertions" in file ? file.insertions : 0,
|
|
57
|
+
deletions: "deletions" in file ? file.deletions : 0,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Get accurate status using --name-status for committed changes
|
|
62
|
+
const nameStatus = await git.raw(["diff", "--name-status", `${baseBranch}...HEAD`]);
|
|
63
|
+
for (const line of nameStatus.split("\n").filter(Boolean)) {
|
|
64
|
+
const [statusStr, ...pathParts] = line.split("\t");
|
|
65
|
+
const filePath = pathParts.join("\t");
|
|
66
|
+
const existing = filesMap.get(filePath);
|
|
67
|
+
if (existing) {
|
|
68
|
+
const statusChar = statusStr[0];
|
|
69
|
+
if (statusChar === "A") existing.status = "added";
|
|
70
|
+
else if (statusChar === "D") existing.status = "deleted";
|
|
71
|
+
else if (statusChar === "R") existing.status = "renamed";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// No committed changes
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Get uncommitted working tree changes (staged + unstaged)
|
|
79
|
+
try {
|
|
80
|
+
// Get working tree changes (unstaged)
|
|
81
|
+
const workingTreeDiff = await git.diffSummary();
|
|
82
|
+
for (const file of workingTreeDiff.files) {
|
|
83
|
+
const path = file.file;
|
|
84
|
+
const existing = filesMap.get(path);
|
|
85
|
+
if (existing) {
|
|
86
|
+
// Add to existing stats
|
|
87
|
+
existing.additions += "insertions" in file ? file.insertions : 0;
|
|
88
|
+
existing.deletions += "deletions" in file ? file.deletions : 0;
|
|
89
|
+
} else {
|
|
90
|
+
filesMap.set(path, {
|
|
91
|
+
path,
|
|
92
|
+
status: "modified",
|
|
93
|
+
additions: "insertions" in file ? file.insertions : 0,
|
|
94
|
+
deletions: "deletions" in file ? file.deletions : 0,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Get staged changes
|
|
100
|
+
const stagedDiff = await git.diffSummary(["--staged"]);
|
|
101
|
+
for (const file of stagedDiff.files) {
|
|
102
|
+
const path = file.file;
|
|
103
|
+
const existing = filesMap.get(path);
|
|
104
|
+
if (existing) {
|
|
105
|
+
existing.additions += "insertions" in file ? file.insertions : 0;
|
|
106
|
+
existing.deletions += "deletions" in file ? file.deletions : 0;
|
|
107
|
+
} else {
|
|
108
|
+
filesMap.set(path, {
|
|
109
|
+
path,
|
|
110
|
+
status: "modified",
|
|
111
|
+
additions: "insertions" in file ? file.insertions : 0,
|
|
112
|
+
deletions: "deletions" in file ? file.deletions : 0,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get status for untracked files
|
|
118
|
+
const status = await git.status();
|
|
119
|
+
for (const file of status.not_added) {
|
|
120
|
+
if (!filesMap.has(file)) {
|
|
121
|
+
filesMap.set(file, {
|
|
122
|
+
path: file,
|
|
123
|
+
status: "added",
|
|
124
|
+
additions: 0,
|
|
125
|
+
deletions: 0,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const file of status.created) {
|
|
130
|
+
const existing = filesMap.get(file);
|
|
131
|
+
if (existing) {
|
|
132
|
+
existing.status = "added";
|
|
133
|
+
} else {
|
|
134
|
+
filesMap.set(file, {
|
|
135
|
+
path: file,
|
|
136
|
+
status: "added",
|
|
137
|
+
additions: 0,
|
|
138
|
+
deletions: 0,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const file of status.deleted) {
|
|
143
|
+
const existing = filesMap.get(file);
|
|
144
|
+
if (existing) {
|
|
145
|
+
existing.status = "deleted";
|
|
146
|
+
} else {
|
|
147
|
+
filesMap.set(file, {
|
|
148
|
+
path: file,
|
|
149
|
+
status: "deleted",
|
|
150
|
+
additions: 0,
|
|
151
|
+
deletions: 0,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// No working tree changes
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return Array.from(filesMap.values());
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error("Error getting changed files:", error);
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get unified diff for a specific file
|
|
168
|
+
* Compares the file against baseBranch, including uncommitted changes
|
|
169
|
+
* For new files, creates a diff showing all lines as additions
|
|
170
|
+
*/
|
|
171
|
+
export async function getFileDiff(
|
|
172
|
+
worktreeDir: string,
|
|
173
|
+
baseBranch: string,
|
|
174
|
+
filePath: string,
|
|
175
|
+
fileStatus?: "added" | "modified" | "deleted" | "renamed",
|
|
176
|
+
gitInstance?: SimpleGit
|
|
177
|
+
): Promise<string> {
|
|
178
|
+
const git: SimpleGit = gitInstance || simpleGit(worktreeDir);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
// Get diff from baseBranch to current working tree (includes uncommitted changes)
|
|
182
|
+
let diff = await git.diff([baseBranch, "--", filePath]);
|
|
183
|
+
|
|
184
|
+
// If diff is empty and file is added/new, create a synthetic diff
|
|
185
|
+
if (!diff && (fileStatus === "added" || !fileStatus)) {
|
|
186
|
+
// Check if file exists in base branch
|
|
187
|
+
let existsInBase = true;
|
|
188
|
+
try {
|
|
189
|
+
await git.show([`${baseBranch}:${filePath}`]);
|
|
190
|
+
} catch {
|
|
191
|
+
existsInBase = false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// If file doesn't exist in base, it's a new file - read and create diff
|
|
195
|
+
if (!existsInBase) {
|
|
196
|
+
try {
|
|
197
|
+
const fullPath = join(worktreeDir, filePath);
|
|
198
|
+
const content = await readFile(fullPath, "utf-8");
|
|
199
|
+
const lines = content.split("\n");
|
|
200
|
+
const lineCount = lines.length;
|
|
201
|
+
|
|
202
|
+
// Create a unified diff format for a new file
|
|
203
|
+
diff = `diff --git a/${filePath} b/${filePath}
|
|
204
|
+
new file mode 100644
|
|
205
|
+
--- /dev/null
|
|
206
|
+
+++ b/${filePath}
|
|
207
|
+
@@ -0,0 +1,${lineCount} @@
|
|
208
|
+
${lines.map(line => `+${line}`).join("\n")}`;
|
|
209
|
+
} catch (readError) {
|
|
210
|
+
console.error(`Error reading new file ${filePath}:`, readError);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return diff;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
console.error(`Error getting diff for ${filePath}:`, error);
|
|
218
|
+
return "";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get combined diff for all files
|
|
224
|
+
*/
|
|
225
|
+
export async function getFullDiff(
|
|
226
|
+
worktreeDir: string,
|
|
227
|
+
baseBranch: string,
|
|
228
|
+
gitInstance?: SimpleGit
|
|
229
|
+
): Promise<string> {
|
|
230
|
+
const git: SimpleGit = gitInstance || simpleGit(worktreeDir);
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const diff = await git.diff([`${baseBranch}...HEAD`]);
|
|
234
|
+
return diff;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
console.error("Error getting full diff:", error);
|
|
237
|
+
return "";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get file extension for syntax highlighting
|
|
243
|
+
*/
|
|
244
|
+
export function getFileType(filePath: string): string {
|
|
245
|
+
const ext = filePath.split(".").pop()?.toLowerCase() || "";
|
|
246
|
+
|
|
247
|
+
const extMap: Record<string, string> = {
|
|
248
|
+
ts: "typescript",
|
|
249
|
+
tsx: "typescript",
|
|
250
|
+
js: "javascript",
|
|
251
|
+
jsx: "javascript",
|
|
252
|
+
py: "python",
|
|
253
|
+
rb: "ruby",
|
|
254
|
+
go: "go",
|
|
255
|
+
rs: "rust",
|
|
256
|
+
java: "java",
|
|
257
|
+
kt: "kotlin",
|
|
258
|
+
swift: "swift",
|
|
259
|
+
c: "c",
|
|
260
|
+
cpp: "cpp",
|
|
261
|
+
h: "c",
|
|
262
|
+
hpp: "cpp",
|
|
263
|
+
cs: "csharp",
|
|
264
|
+
php: "php",
|
|
265
|
+
md: "markdown",
|
|
266
|
+
json: "json",
|
|
267
|
+
yaml: "yaml",
|
|
268
|
+
yml: "yaml",
|
|
269
|
+
toml: "toml",
|
|
270
|
+
xml: "xml",
|
|
271
|
+
html: "html",
|
|
272
|
+
css: "css",
|
|
273
|
+
scss: "scss",
|
|
274
|
+
sass: "sass",
|
|
275
|
+
less: "less",
|
|
276
|
+
sql: "sql",
|
|
277
|
+
sh: "bash",
|
|
278
|
+
bash: "bash",
|
|
279
|
+
zsh: "bash",
|
|
280
|
+
fish: "fish",
|
|
281
|
+
ps1: "powershell",
|
|
282
|
+
dockerfile: "dockerfile",
|
|
283
|
+
makefile: "makefile",
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
return extMap[ext] || "text";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get status indicator character for display
|
|
291
|
+
*/
|
|
292
|
+
export function getStatusIndicator(status: ChangedFile["status"]): string {
|
|
293
|
+
switch (status) {
|
|
294
|
+
case "added":
|
|
295
|
+
return "A";
|
|
296
|
+
case "modified":
|
|
297
|
+
return "M";
|
|
298
|
+
case "deleted":
|
|
299
|
+
return "D";
|
|
300
|
+
case "renamed":
|
|
301
|
+
return "R";
|
|
302
|
+
default:
|
|
303
|
+
return "?";
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Get status color for display
|
|
309
|
+
*/
|
|
310
|
+
export function getStatusColor(status: ChangedFile["status"]): string {
|
|
311
|
+
switch (status) {
|
|
312
|
+
case "added":
|
|
313
|
+
return "#4caf50"; // Green
|
|
314
|
+
case "modified":
|
|
315
|
+
return "#2196f3"; // Blue
|
|
316
|
+
case "deleted":
|
|
317
|
+
return "#f44336"; // Red
|
|
318
|
+
case "renamed":
|
|
319
|
+
return "#ff9800"; // Orange
|
|
320
|
+
default:
|
|
321
|
+
return "#888888";
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { mock, expect, test, describe, beforeEach } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const mockGit = {
|
|
4
|
+
push: mock(async () => {}),
|
|
5
|
+
getRemotes: mock(async () => [{ name: "origin", refs: { fetch: "git@github.com:user/repo.git" } }]),
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
mock.module("simple-git", () => ({
|
|
9
|
+
default: mock(() => mockGit),
|
|
10
|
+
simpleGit: mock(() => mockGit),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const mockExec = mock(async () => ({ stdout: "https://github.com/user/repo/pull/1", exitCode: 0 }));
|
|
14
|
+
|
|
15
|
+
mock.module("../providers/base.js", () => ({
|
|
16
|
+
execCommand: mockExec,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
mock.module("node:fs", () => ({
|
|
20
|
+
existsSync: mock((path: string) => path.endsWith("prd.md")),
|
|
21
|
+
readdirSync: mock(() => []),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module("node:fs/promises", () => ({
|
|
25
|
+
readFile: mock(async (path: string) => {
|
|
26
|
+
if (path.endsWith("prd.md")) return `# My PRD
|
|
27
|
+
## Problem Statement
|
|
28
|
+
This is a problem.`;
|
|
29
|
+
return "";
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import * as prService from "./pr.js";
|
|
34
|
+
|
|
35
|
+
describe("PR Service", () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
mockExec.mockClear();
|
|
38
|
+
Object.values(mockGit).forEach(m => m.mockClear());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("generatePRDescription", () => {
|
|
42
|
+
test("should generate description from PRD", async () => {
|
|
43
|
+
const desc = await prService.generatePRDescription("/tmp/session", "feat/test", "main");
|
|
44
|
+
|
|
45
|
+
expect(desc.title).toBe("My");
|
|
46
|
+
expect(desc.body).toContain("**Problem:** This is a problem.");
|
|
47
|
+
expect(desc.body).toContain("**Branch:** `feat/test` → `main`")
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("pushBranch", () => {
|
|
52
|
+
test("should push with upstream flag", async () => {
|
|
53
|
+
await prService.pushBranch("feat/test", undefined, mockGit as any);
|
|
54
|
+
expect(mockGit.push).toHaveBeenCalledWith("origin", "feat/test", ["--set-upstream"]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("createPullRequest", () => {
|
|
59
|
+
test("should push branch and then call gh pr create", async () => {
|
|
60
|
+
const url = await prService.createPullRequest(
|
|
61
|
+
"feat/test",
|
|
62
|
+
"main",
|
|
63
|
+
"Title",
|
|
64
|
+
"Body",
|
|
65
|
+
false,
|
|
66
|
+
undefined,
|
|
67
|
+
mockGit as any
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(mockGit.push).toHaveBeenCalled();
|
|
71
|
+
expect(mockExec).toHaveBeenCalled();
|
|
72
|
+
const args = (mockExec.mock.calls[0] as any[])[1] as string[];
|
|
73
|
+
expect(args).toContain("pr");
|
|
74
|
+
expect(args).toContain("create");
|
|
75
|
+
expect(args).toContain("feat/test");
|
|
76
|
+
expect(url).toBe("https://github.com/user/repo/pull/1");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("should return null if push fails", async () => {
|
|
80
|
+
mockGit.push.mockRejectedValue(new Error("Push failed"));
|
|
81
|
+
const url = await prService.createPullRequest("feat/test", "main", "Title", "Body", false, undefined, mockGit as any);
|
|
82
|
+
expect(url).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("isGhAvailable", () => {
|
|
87
|
+
test("should return true if gh auth status succeeds", async () => {
|
|
88
|
+
mockExec.mockResolvedValueOnce({ stdout: "Logged in", exitCode: 0 });
|
|
89
|
+
expect(await prService.isGhAvailable()).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("should return false if gh auth status fails", async () => {
|
|
93
|
+
mockExec.mockResolvedValueOnce({ stdout: "Not logged in", exitCode: 1 });
|
|
94
|
+
expect(await prService.isGhAvailable()).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("getOriginUrl", () => {
|
|
99
|
+
test("should return fetch URL for origin", async () => {
|
|
100
|
+
const url = await prService.getOriginUrl(undefined, mockGit as any);
|
|
101
|
+
expect(url).toBe("git@github.com:user/repo.git");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import simpleGit, { type SimpleGit } from "simple-git";
|
|
2
|
+
import { execCommand } from "../providers/base.js";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export interface PRDescription {
|
|
8
|
+
title: string;
|
|
9
|
+
body: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate a PR description from session artifacts
|
|
14
|
+
*/
|
|
15
|
+
export async function generatePRDescription(
|
|
16
|
+
sessionDir: string,
|
|
17
|
+
branchName: string,
|
|
18
|
+
baseBranch: string
|
|
19
|
+
): Promise<PRDescription> {
|
|
20
|
+
let prdContent = "";
|
|
21
|
+
let ticketSummaries: string[] = [];
|
|
22
|
+
|
|
23
|
+
// Try to read PRD
|
|
24
|
+
const prdPath = join(sessionDir, "prd.md");
|
|
25
|
+
if (existsSync(prdPath)) {
|
|
26
|
+
try {
|
|
27
|
+
prdContent = await readFile(prdPath, "utf-8");
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Extract title from PRD (first heading)
|
|
32
|
+
let title = "Pickle Rick Session Changes";
|
|
33
|
+
const titleMatch = prdContent.match(/^#\s+(.*)$/m);
|
|
34
|
+
if (titleMatch) {
|
|
35
|
+
title = titleMatch[1].replace(/\s+PRD$/i, "").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Gather ticket summaries from implementation docs
|
|
39
|
+
try {
|
|
40
|
+
const entries = readdirSync(sessionDir, { withFileTypes: true });
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "debug") {
|
|
43
|
+
const ticketDir = join(sessionDir, entry.name);
|
|
44
|
+
const implPath = join(ticketDir, "implementation.md");
|
|
45
|
+
const ticketPath = join(ticketDir, `linear_ticket_${entry.name}.md`);
|
|
46
|
+
|
|
47
|
+
let ticketTitle = entry.name;
|
|
48
|
+
if (existsSync(ticketPath)) {
|
|
49
|
+
try {
|
|
50
|
+
const ticketContent = await readFile(ticketPath, "utf-8");
|
|
51
|
+
const ticketTitleMatch = ticketContent.match(/^#\s+(.+)$/m);
|
|
52
|
+
if (ticketTitleMatch) ticketTitle = ticketTitleMatch[1];
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (existsSync(implPath)) {
|
|
57
|
+
try {
|
|
58
|
+
const implContent = await readFile(implPath, "utf-8");
|
|
59
|
+
// Extract summary section or first few lines
|
|
60
|
+
const summaryMatch = implContent.match(/##\s*Summary\s*\n([\s\S]*?)(?=\n##|$)/i);
|
|
61
|
+
const summary = summaryMatch
|
|
62
|
+
? summaryMatch[1].trim().slice(0, 500)
|
|
63
|
+
: implContent.slice(0, 300).trim();
|
|
64
|
+
ticketSummaries.push(`### ${ticketTitle}\n${summary}`);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
|
|
71
|
+
// Extract key sections from PRD for the body
|
|
72
|
+
let problemStatement = "";
|
|
73
|
+
let objective = "";
|
|
74
|
+
const problemMatch = prdContent.match(/##\s*Problem Statement\s*\n([\s\S]*?)(?=\n##|$)/i);
|
|
75
|
+
if (problemMatch) problemStatement = problemMatch[1].trim();
|
|
76
|
+
const objectiveMatch = prdContent.match(/##\s*Objective.*?\n([\s\S]*?)(?=\n##|$)/i);
|
|
77
|
+
if (objectiveMatch) objective = objectiveMatch[1].trim();
|
|
78
|
+
|
|
79
|
+
// Build PR body
|
|
80
|
+
const body = `## Summary
|
|
81
|
+
|
|
82
|
+
${problemStatement ? `**Problem:** ${problemStatement.split('\n')[0]}` : 'Automated changes from Pickle Rick session.'}
|
|
83
|
+
|
|
84
|
+
${objective ? `**Objective:** ${objective.split('\n')[0]}` : ''}
|
|
85
|
+
|
|
86
|
+
## Changes
|
|
87
|
+
|
|
88
|
+
${ticketSummaries.length > 0 ? ticketSummaries.join('\n\n') : 'See commit history for details.'}
|
|
89
|
+
|
|
90
|
+
## Test Plan
|
|
91
|
+
|
|
92
|
+
- [ ] Review code changes
|
|
93
|
+
- [ ] Run tests locally
|
|
94
|
+
- [ ] Verify functionality
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
🥒 Generated by [Pickle Rick CLI](https://github.com/anthropics/pickle-rick)
|
|
99
|
+
|
|
100
|
+
**Branch:** \`${branchName}\` → \`${baseBranch}\`
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
return { title, body };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Push a branch to origin
|
|
108
|
+
*/
|
|
109
|
+
export async function pushBranch(branch: string, workDir = process.cwd(), gitInstance?: SimpleGit): Promise<boolean> {
|
|
110
|
+
const git: SimpleGit = gitInstance || simpleGit(workDir);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await git.push("origin", branch, ["--set-upstream"]);
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a pull request using gh CLI
|
|
122
|
+
*/
|
|
123
|
+
export async function createPullRequest(
|
|
124
|
+
branch: string,
|
|
125
|
+
baseBranch: string,
|
|
126
|
+
title: string,
|
|
127
|
+
body: string,
|
|
128
|
+
draft = false,
|
|
129
|
+
workDir = process.cwd(),
|
|
130
|
+
gitInstance?: SimpleGit
|
|
131
|
+
): Promise<string | null> {
|
|
132
|
+
// Push branch first
|
|
133
|
+
const pushed = await pushBranch(branch, workDir, gitInstance);
|
|
134
|
+
if (!pushed) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Build gh pr create command args
|
|
139
|
+
const args = [
|
|
140
|
+
"pr",
|
|
141
|
+
"create",
|
|
142
|
+
"--base",
|
|
143
|
+
baseBranch,
|
|
144
|
+
"--head",
|
|
145
|
+
branch,
|
|
146
|
+
"--title",
|
|
147
|
+
title,
|
|
148
|
+
"--body",
|
|
149
|
+
body,
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
if (draft) {
|
|
153
|
+
args.push("--draft");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Execute gh CLI
|
|
157
|
+
const { stdout, exitCode } = await execCommand("gh", args, workDir);
|
|
158
|
+
|
|
159
|
+
if (exitCode !== 0) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Return the PR URL (gh outputs the URL on success)
|
|
164
|
+
return stdout.trim() || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Check if gh CLI is available and authenticated
|
|
169
|
+
*/
|
|
170
|
+
export async function isGhAvailable(): Promise<boolean> {
|
|
171
|
+
try {
|
|
172
|
+
const { exitCode } = await execCommand("gh", ["auth", "status"], process.cwd());
|
|
173
|
+
return exitCode === 0;
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get the remote URL for origin
|
|
181
|
+
*/
|
|
182
|
+
export async function getOriginUrl(workDir = process.cwd(), gitInstance?: SimpleGit): Promise<string | null> {
|
|
183
|
+
const git: SimpleGit = gitInstance || simpleGit(workDir);
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const remotes = await git.getRemotes(true);
|
|
187
|
+
const origin = remotes.find((r) => r.name === "origin");
|
|
188
|
+
return origin?.refs?.fetch || null;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|