teleportation-cli 1.0.0 → 1.0.2

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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * PRCreator - GitHub Pull Request Creation Utility
3
+ *
4
+ * Handles PR creation for remote session results using GitHub CLI (gh).
5
+ * Generates properly formatted PR titles, descriptions with metadata,
6
+ * and includes timeline links and conflict warnings.
7
+ */
8
+
9
+ import { exec } from 'child_process';
10
+ import { promisify } from 'util';
11
+ import { writeFileSync, unlinkSync } from 'fs';
12
+ import { tmpdir } from 'os';
13
+ import { join } from 'path';
14
+
15
+ const execAsync = promisify(exec);
16
+
17
+ /**
18
+ * PRCreator class
19
+ */
20
+ export class PRCreator {
21
+ /**
22
+ * @param {Object} options
23
+ * @param {string} options.repoPath - Path to git repository
24
+ * @param {boolean} [options.verbose] - Enable verbose logging
25
+ */
26
+ constructor(options) {
27
+ if (!options || !options.repoPath) {
28
+ throw new Error('repoPath is required');
29
+ }
30
+
31
+ this.repoPath = options.repoPath;
32
+ this.verbose = options.verbose || false;
33
+ }
34
+
35
+ /**
36
+ * Execute gh CLI command (async, non-blocking)
37
+ * @private
38
+ * @param {string} command - gh command (without 'gh' prefix)
39
+ * @returns {Promise<string>} Command output
40
+ * @throws {Error} If command fails
41
+ */
42
+ async _gh(command) {
43
+ const fullCommand = `gh ${command}`;
44
+
45
+ if (this.verbose) {
46
+ console.log(`[pr-creator] ${fullCommand}`);
47
+ }
48
+
49
+ try {
50
+ const { stdout, stderr } = await execAsync(fullCommand, {
51
+ cwd: this.repoPath,
52
+ encoding: 'utf-8',
53
+ timeout: 30000, // 30 second timeout
54
+ });
55
+
56
+ if (stderr && this.verbose) {
57
+ console.warn(`[pr-creator] stderr: ${stderr}`);
58
+ }
59
+
60
+ return stdout.trim();
61
+ } catch (error) {
62
+ // Handle specific gh CLI errors
63
+ if (error.code === 127 || error.message.includes('command not found')) {
64
+ throw new Error('GitHub CLI (gh) is not installed. Install from https://cli.github.com/');
65
+ }
66
+
67
+ if (error.message.includes('authentication') || error.message.includes('auth')) {
68
+ throw new Error('GitHub authentication required. Run: gh auth login');
69
+ }
70
+
71
+ if (error.message.includes('pull request not found')) {
72
+ const prNumber = command.match(/\d+/)?.[0];
73
+ throw new Error(`Pull request #${prNumber} not found`);
74
+ }
75
+
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Generate PR title
82
+ *
83
+ * @param {Object} options
84
+ * @param {string} options.sessionId - Session ID
85
+ * @param {string} [options.task] - Task description
86
+ * @param {boolean} [options.hasConflicts] - Whether PR has conflicts
87
+ * @returns {string} PR title
88
+ */
89
+ generatePRTitle(options) {
90
+ const { sessionId, task, hasConflicts } = options;
91
+
92
+ let title = `Remote Session Results: ${sessionId}`;
93
+
94
+ if (task) {
95
+ // Truncate task to keep title under 100 chars
96
+ const maxTaskLength = 50;
97
+ const truncatedTask = task.length > maxTaskLength
98
+ ? task.substring(0, maxTaskLength) + '...'
99
+ : task;
100
+
101
+ title = `${truncatedTask} (${sessionId})`;
102
+ }
103
+
104
+ if (hasConflicts) {
105
+ title = `⚠️ ${title} (Conflicts)`;
106
+ }
107
+
108
+ // Ensure title doesn't exceed GitHub's limit
109
+ return title.substring(0, 100);
110
+ }
111
+
112
+ /**
113
+ * Format duration in seconds to human-readable string
114
+ *
115
+ * @param {number} seconds - Duration in seconds
116
+ * @returns {string} Formatted duration (e.g., "1h 23m 45s")
117
+ */
118
+ formatDuration(seconds) {
119
+ if (seconds === 0) return '0s';
120
+
121
+ const hours = Math.floor(seconds / 3600);
122
+ const minutes = Math.floor((seconds % 3600) / 60);
123
+ const secs = seconds % 60;
124
+
125
+ const parts = [];
126
+
127
+ if (hours > 0) {
128
+ parts.push(`${hours}h`);
129
+ // Always show minutes when hours are present
130
+ parts.push(`${minutes}m`);
131
+ } else if (minutes > 0) {
132
+ parts.push(`${minutes}m`);
133
+ }
134
+
135
+ // Only show seconds if non-zero OR if no other parts
136
+ if (secs > 0 || parts.length === 0) {
137
+ parts.push(`${secs}s`);
138
+ }
139
+
140
+ return parts.join(' ');
141
+ }
142
+
143
+ /**
144
+ * Generate PR body with metadata
145
+ *
146
+ * @param {Object} options
147
+ * @param {string} options.task - Task description
148
+ * @param {string} options.sessionId - Session ID
149
+ * @param {string} [options.timelineUrl] - Timeline URL
150
+ * @param {Object} [options.conflicts] - Conflict information
151
+ * @param {boolean} [options.conflicts.hasConflicts] - Whether conflicts exist
152
+ * @param {string[]} [options.conflicts.conflictFiles] - List of conflicting files
153
+ * @param {Object} [options.metadata] - Session metadata
154
+ * @param {string} [options.metadata.provider] - Cloud provider used
155
+ * @param {number} [options.metadata.duration] - Session duration in seconds
156
+ * @param {string} [options.metadata.machineId] - Machine ID
157
+ * @returns {string} PR body in markdown format
158
+ */
159
+ generatePRBody(options) {
160
+ const { task, sessionId, timelineUrl, conflicts, metadata = {} } = options;
161
+
162
+ const sections = [];
163
+
164
+ // Task description
165
+ sections.push('## Task\n');
166
+ sections.push(task || 'No task description provided');
167
+ sections.push('\n');
168
+
169
+ // Conflict warning
170
+ if (conflicts?.hasConflicts) {
171
+ sections.push('## ⚠️ Conflicts Detected\n');
172
+ sections.push('This PR contains merge conflicts that must be resolved manually:\n');
173
+ sections.push('');
174
+
175
+ if (conflicts.conflictFiles && conflicts.conflictFiles.length > 0) {
176
+ conflicts.conflictFiles.forEach((file) => {
177
+ sections.push(`- \`${file}\``);
178
+ });
179
+ }
180
+
181
+ sections.push('\n**Action Required**: Review and resolve conflicts before merging.\n');
182
+ }
183
+
184
+ // Timeline link
185
+ if (timelineUrl) {
186
+ sections.push('## Session Timeline\n');
187
+ sections.push(`View the complete session timeline: [${sessionId}](${timelineUrl})\n`);
188
+ }
189
+
190
+ // Metadata footer
191
+ sections.push('---\n');
192
+ sections.push('## Session Metadata\n');
193
+ sections.push('');
194
+ sections.push(`- **Session ID**: \`${sessionId}\``);
195
+
196
+ if (metadata.provider) {
197
+ sections.push(`- **Provider**: ${metadata.provider}`);
198
+ }
199
+
200
+ if (metadata.duration) {
201
+ const duration = this.formatDuration(metadata.duration);
202
+ sections.push(`- **Duration**: ${duration}`);
203
+ }
204
+
205
+ if (metadata.machineId) {
206
+ sections.push(`- **Machine ID**: \`${metadata.machineId}\``);
207
+ }
208
+
209
+ sections.push('');
210
+ sections.push('---');
211
+ sections.push('');
212
+ sections.push('🤖 *Generated by [Teleportation](https://teleportation.dev) - Remote AI Coding*');
213
+
214
+ return sections.join('\n');
215
+ }
216
+
217
+ /**
218
+ * Create a pull request
219
+ *
220
+ * @param {Object} options
221
+ * @param {string} options.head - Head branch (remote branch with changes)
222
+ * @param {string} options.base - Base branch (target branch, usually main)
223
+ * @param {string} options.title - PR title
224
+ * @param {string} options.body - PR body (description)
225
+ * @returns {Promise<Object>} PR details
226
+ * @returns {number} return.number - PR number
227
+ * @returns {string} return.url - PR URL
228
+ * @returns {string} return.title - PR title
229
+ * @throws {Error} If PR creation fails
230
+ */
231
+ async createPR(options) {
232
+ const { head, base, title, body } = options;
233
+
234
+ if (!head || !base || !title || !body) {
235
+ throw new Error('head, base, title, and body are required for PR creation');
236
+ }
237
+
238
+ // Use temp file for body to avoid shell escaping issues (prevents command injection)
239
+ const tempFile = join(tmpdir(), `pr-body-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
240
+
241
+ try {
242
+ // Write body to temp file
243
+ writeFileSync(tempFile, body, { mode: 0o600 });
244
+
245
+ // Escape title (simple string, less risky)
246
+ const escapedTitle = title.replace(/"/g, '\\"');
247
+
248
+ // Use --body-file to avoid command injection via body content
249
+ const command = `pr create --head "${head}" --base "${base}" --title "${escapedTitle}" --body-file "${tempFile}"`;
250
+
251
+ const output = await this._gh(command);
252
+
253
+ // Extract PR URL from output
254
+ // gh CLI returns URL like: https://github.com/user/repo/pull/42
255
+ const prUrl = output.trim();
256
+ const prNumberMatch = prUrl.match(/\/pull\/(\d+)$/);
257
+
258
+ if (!prNumberMatch) {
259
+ throw new Error(`Failed to extract PR number from URL: ${prUrl}`);
260
+ }
261
+
262
+ const prNumber = parseInt(prNumberMatch[1], 10);
263
+
264
+ if (this.verbose) {
265
+ console.log(`[pr-creator] Created PR #${prNumber}: ${prUrl}`);
266
+ }
267
+
268
+ return {
269
+ number: prNumber,
270
+ url: prUrl,
271
+ title,
272
+ };
273
+ } catch (error) {
274
+ if (error.message.includes('pull request already exists')) {
275
+ throw error; // Re-throw with original message
276
+ }
277
+
278
+ throw new Error(`Failed to create pull request: ${error.message}`);
279
+ } finally {
280
+ // Always clean up temp file
281
+ try {
282
+ unlinkSync(tempFile);
283
+ } catch {
284
+ // Ignore cleanup errors
285
+ }
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Update an existing pull request
291
+ *
292
+ * @param {Object} options
293
+ * @param {number} options.prNumber - PR number
294
+ * @param {string} [options.title] - New PR title
295
+ * @param {string} [options.body] - New PR body
296
+ * @returns {Promise<void>}
297
+ * @throws {Error} If PR update fails
298
+ */
299
+ async updatePR(options) {
300
+ const { prNumber, title, body } = options;
301
+
302
+ if (!prNumber) {
303
+ throw new Error('prNumber is required for PR update');
304
+ }
305
+
306
+ if (!title && !body) {
307
+ throw new Error('At least one of title or body is required for PR update');
308
+ }
309
+
310
+ // Use temp file for body to avoid shell escaping issues
311
+ let tempFile = null;
312
+
313
+ try {
314
+ const args = [];
315
+
316
+ if (title) {
317
+ const escapedTitle = title.replace(/"/g, '\\"');
318
+ args.push(`--title "${escapedTitle}"`);
319
+ }
320
+
321
+ if (body) {
322
+ // Write body to temp file to avoid command injection
323
+ tempFile = join(tmpdir(), `pr-body-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
324
+ writeFileSync(tempFile, body, { mode: 0o600 });
325
+ args.push(`--body-file "${tempFile}"`);
326
+ }
327
+
328
+ const command = `pr edit ${prNumber} ${args.join(' ')}`;
329
+ await this._gh(command);
330
+
331
+ if (this.verbose) {
332
+ console.log(`[pr-creator] Updated PR #${prNumber}`);
333
+ }
334
+ } catch (error) {
335
+ throw new Error(`Failed to update pull request: ${error.message}`);
336
+ } finally {
337
+ // Clean up temp file if created
338
+ if (tempFile) {
339
+ try {
340
+ unlinkSync(tempFile);
341
+ } catch {
342
+ // Ignore cleanup errors
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Get PR status
350
+ *
351
+ * @param {number} prNumber - PR number
352
+ * @returns {Promise<Object>} PR status
353
+ * @returns {number} return.number - PR number
354
+ * @returns {string} return.state - PR state (OPEN, CLOSED, MERGED)
355
+ * @returns {string} return.title - PR title
356
+ * @returns {string} return.url - PR URL
357
+ * @returns {string} return.mergeable - Mergeable state (MERGEABLE, CONFLICTING, UNKNOWN)
358
+ * @throws {Error} If PR not found or query fails
359
+ */
360
+ async getPRStatus(prNumber) {
361
+ if (!prNumber) {
362
+ throw new Error('prNumber is required');
363
+ }
364
+
365
+ try {
366
+ const command = `pr view ${prNumber} --json number,state,title,url,mergeable`;
367
+ const output = await this._gh(command);
368
+
369
+ const prData = JSON.parse(output);
370
+
371
+ if (this.verbose) {
372
+ console.log(`[pr-creator] PR #${prNumber} status: ${prData.state}`);
373
+ }
374
+
375
+ return prData;
376
+ } catch (error) {
377
+ throw new Error(`Failed to get PR status: ${error.message}`);
378
+ }
379
+ }
380
+ }
381
+
382
+ export default PRCreator;