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.
Files changed (128) hide show
  1. package/README.md +242 -0
  2. package/bin.js +3 -0
  3. package/dist/pickle +0 -0
  4. package/dist/worker-executor.js +207 -0
  5. package/package.json +53 -0
  6. package/src/games/GameSidebarManager.test.ts +64 -0
  7. package/src/games/GameSidebarManager.ts +78 -0
  8. package/src/games/gameboy/GameboyView.test.ts +25 -0
  9. package/src/games/gameboy/GameboyView.ts +100 -0
  10. package/src/games/gameboy/gameboy-polyfills.ts +313 -0
  11. package/src/games/index.test.ts +9 -0
  12. package/src/games/index.ts +4 -0
  13. package/src/games/snake/SnakeGame.test.ts +35 -0
  14. package/src/games/snake/SnakeGame.ts +145 -0
  15. package/src/games/snake/SnakeView.test.ts +25 -0
  16. package/src/games/snake/SnakeView.ts +290 -0
  17. package/src/index.test.ts +24 -0
  18. package/src/index.ts +141 -0
  19. package/src/services/commands/worker.test.ts +14 -0
  20. package/src/services/commands/worker.ts +262 -0
  21. package/src/services/config/index.ts +2 -0
  22. package/src/services/config/settings.test.ts +42 -0
  23. package/src/services/config/settings.ts +220 -0
  24. package/src/services/config/state.test.ts +88 -0
  25. package/src/services/config/state.ts +130 -0
  26. package/src/services/config/types.ts +39 -0
  27. package/src/services/execution/index.ts +1 -0
  28. package/src/services/execution/pickle-source.test.ts +88 -0
  29. package/src/services/execution/pickle-source.ts +264 -0
  30. package/src/services/execution/prompt.test.ts +93 -0
  31. package/src/services/execution/prompt.ts +322 -0
  32. package/src/services/execution/sequential.test.ts +91 -0
  33. package/src/services/execution/sequential.ts +422 -0
  34. package/src/services/execution/worker-client.ts +94 -0
  35. package/src/services/execution/worker-executor.ts +41 -0
  36. package/src/services/execution/worker.test.ts +73 -0
  37. package/src/services/git/branch.test.ts +147 -0
  38. package/src/services/git/branch.ts +128 -0
  39. package/src/services/git/diff.test.ts +113 -0
  40. package/src/services/git/diff.ts +323 -0
  41. package/src/services/git/index.ts +4 -0
  42. package/src/services/git/pr.test.ts +104 -0
  43. package/src/services/git/pr.ts +192 -0
  44. package/src/services/git/worktree.test.ts +99 -0
  45. package/src/services/git/worktree.ts +141 -0
  46. package/src/services/providers/base.test.ts +86 -0
  47. package/src/services/providers/base.ts +438 -0
  48. package/src/services/providers/codex.test.ts +39 -0
  49. package/src/services/providers/codex.ts +208 -0
  50. package/src/services/providers/gemini.test.ts +40 -0
  51. package/src/services/providers/gemini.ts +169 -0
  52. package/src/services/providers/index.test.ts +28 -0
  53. package/src/services/providers/index.ts +41 -0
  54. package/src/services/providers/opencode.test.ts +64 -0
  55. package/src/services/providers/opencode.ts +228 -0
  56. package/src/services/providers/types.ts +44 -0
  57. package/src/skills/code-implementer.md +105 -0
  58. package/src/skills/code-researcher.md +78 -0
  59. package/src/skills/implementation-planner.md +105 -0
  60. package/src/skills/plan-reviewer.md +100 -0
  61. package/src/skills/prd-drafter.md +123 -0
  62. package/src/skills/research-reviewer.md +79 -0
  63. package/src/skills/ruthless-refactorer.md +52 -0
  64. package/src/skills/ticket-manager.md +135 -0
  65. package/src/types/index.ts +2 -0
  66. package/src/types/rpc.ts +14 -0
  67. package/src/types/tasks.ts +50 -0
  68. package/src/types.d.ts +9 -0
  69. package/src/ui/common.ts +28 -0
  70. package/src/ui/components/FilePickerView.test.ts +79 -0
  71. package/src/ui/components/FilePickerView.ts +161 -0
  72. package/src/ui/components/MultiLineInput.test.ts +27 -0
  73. package/src/ui/components/MultiLineInput.ts +233 -0
  74. package/src/ui/components/SessionChip.test.ts +69 -0
  75. package/src/ui/components/SessionChip.ts +481 -0
  76. package/src/ui/components/ToyboxSidebar.test.ts +36 -0
  77. package/src/ui/components/ToyboxSidebar.ts +329 -0
  78. package/src/ui/components/refactor_plan.md +35 -0
  79. package/src/ui/controllers/DashboardController.integration.test.ts +43 -0
  80. package/src/ui/controllers/DashboardController.ts +650 -0
  81. package/src/ui/dashboard.test.ts +43 -0
  82. package/src/ui/dashboard.ts +309 -0
  83. package/src/ui/dialogs/DashboardDialog.test.ts +146 -0
  84. package/src/ui/dialogs/DashboardDialog.ts +399 -0
  85. package/src/ui/dialogs/Dialog.test.ts +50 -0
  86. package/src/ui/dialogs/Dialog.ts +241 -0
  87. package/src/ui/dialogs/DialogSidebar.test.ts +60 -0
  88. package/src/ui/dialogs/DialogSidebar.ts +71 -0
  89. package/src/ui/dialogs/DiffViewDialog.test.ts +57 -0
  90. package/src/ui/dialogs/DiffViewDialog.ts +510 -0
  91. package/src/ui/dialogs/PRPreviewDialog.test.ts +50 -0
  92. package/src/ui/dialogs/PRPreviewDialog.ts +346 -0
  93. package/src/ui/dialogs/test-utils.ts +232 -0
  94. package/src/ui/file-picker-utils.test.ts +71 -0
  95. package/src/ui/file-picker-utils.ts +200 -0
  96. package/src/ui/input-chrome.test.ts +62 -0
  97. package/src/ui/input-chrome.ts +172 -0
  98. package/src/ui/logger.test.ts +68 -0
  99. package/src/ui/logger.ts +45 -0
  100. package/src/ui/mock-factory.ts +6 -0
  101. package/src/ui/spinner.test.ts +65 -0
  102. package/src/ui/spinner.ts +41 -0
  103. package/src/ui/test-setup.ts +300 -0
  104. package/src/ui/theme.test.ts +23 -0
  105. package/src/ui/theme.ts +16 -0
  106. package/src/ui/views/LandingView.integration.test.ts +21 -0
  107. package/src/ui/views/LandingView.test.ts +24 -0
  108. package/src/ui/views/LandingView.ts +221 -0
  109. package/src/ui/views/LogView.test.ts +24 -0
  110. package/src/ui/views/LogView.ts +277 -0
  111. package/src/ui/views/ToyboxView.test.ts +46 -0
  112. package/src/ui/views/ToyboxView.ts +323 -0
  113. package/src/utils/clipboard.test.ts +86 -0
  114. package/src/utils/clipboard.ts +100 -0
  115. package/src/utils/index.test.ts +68 -0
  116. package/src/utils/index.ts +95 -0
  117. package/src/utils/persona.test.ts +12 -0
  118. package/src/utils/persona.ts +8 -0
  119. package/src/utils/project-root.test.ts +38 -0
  120. package/src/utils/project-root.ts +22 -0
  121. package/src/utils/resources.test.ts +64 -0
  122. package/src/utils/resources.ts +92 -0
  123. package/src/utils/search.test.ts +48 -0
  124. package/src/utils/search.ts +103 -0
  125. package/src/utils/session-tracker.test.ts +46 -0
  126. package/src/utils/session-tracker.ts +67 -0
  127. package/src/utils/spinner.test.ts +54 -0
  128. 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,4 @@
1
+ export * from "./branch.js";
2
+ export * from "./pr.js";
3
+ export * from "./worktree.js";
4
+ export * from "./diff.js";
@@ -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
+ }