specweave 0.12.0 → 0.12.1

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,342 @@
1
+ /**
2
+ * Bidirectional GitHub Sync
3
+ *
4
+ * Syncs state from GitHub back to SpecWeave.
5
+ * Handles issue state changes, comments, assignees, labels, milestones.
6
+ *
7
+ * @module github-sync-bidirectional
8
+ */
9
+
10
+ import fs from 'fs-extra';
11
+ import path from 'path';
12
+ import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
13
+ import {
14
+ loadIncrementMetadata,
15
+ IncrementMetadata,
16
+ detectRepo
17
+ } from './github-issue-updater.js';
18
+
19
+ export interface GitHubIssueState {
20
+ number: number;
21
+ title: string;
22
+ body: string;
23
+ state: 'open' | 'closed';
24
+ labels: string[];
25
+ assignees: string[];
26
+ milestone?: string;
27
+ comments: GitHubComment[];
28
+ updated_at: string;
29
+ }
30
+
31
+ export interface GitHubComment {
32
+ id: number;
33
+ author: string;
34
+ body: string;
35
+ created_at: string;
36
+ }
37
+
38
+ export interface SyncConflict {
39
+ type: 'status' | 'assignee' | 'label';
40
+ githubValue: any;
41
+ specweaveValue: any;
42
+ resolution: 'github-wins' | 'specweave-wins' | 'prompt';
43
+ }
44
+
45
+ /**
46
+ * Sync from GitHub to SpecWeave
47
+ */
48
+ export async function syncFromGitHub(incrementId: string): Promise<void> {
49
+ console.log(`\nšŸ”„ Syncing from GitHub for increment: ${incrementId}`);
50
+
51
+ try {
52
+ // 1. Load metadata
53
+ const metadata = await loadIncrementMetadata(incrementId);
54
+ if (!metadata?.github?.issue) {
55
+ console.log('ā„¹ļø No GitHub issue linked, nothing to sync');
56
+ return;
57
+ }
58
+
59
+ // 2. Detect repository
60
+ const repoInfo = await detectRepo();
61
+ if (!repoInfo) {
62
+ console.log('āš ļø Could not detect GitHub repository');
63
+ return;
64
+ }
65
+
66
+ const { owner, repo } = repoInfo;
67
+ const issueNumber = metadata.github.issue;
68
+
69
+ console.log(` Syncing from ${owner}/${repo}#${issueNumber}`);
70
+
71
+ // 3. Fetch current GitHub state
72
+ const githubState = await fetchGitHubIssueState(issueNumber, owner, repo);
73
+
74
+ // 4. Compare with local state
75
+ const conflicts = detectConflicts(metadata, githubState);
76
+
77
+ if (conflicts.length === 0) {
78
+ console.log('āœ… No conflicts - GitHub and SpecWeave in sync');
79
+ return;
80
+ }
81
+
82
+ console.log(`āš ļø Detected ${conflicts.length} conflict(s)`);
83
+
84
+ // 5. Resolve conflicts
85
+ await resolveConflicts(incrementId, metadata, githubState, conflicts);
86
+
87
+ // 6. Sync comments
88
+ await syncComments(incrementId, githubState.comments);
89
+
90
+ // 7. Update metadata
91
+ await updateMetadata(incrementId, githubState);
92
+
93
+ console.log('āœ… Bidirectional sync complete');
94
+
95
+ } catch (error) {
96
+ console.error('āŒ Error syncing from GitHub:', error);
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Fetch current GitHub issue state
103
+ */
104
+ async function fetchGitHubIssueState(
105
+ issueNumber: number,
106
+ owner: string,
107
+ repo: string
108
+ ): Promise<GitHubIssueState> {
109
+ // Fetch issue details
110
+ const issueResult = await execFileNoThrow('gh', [
111
+ 'issue',
112
+ 'view',
113
+ String(issueNumber),
114
+ '--repo',
115
+ `${owner}/${repo}`,
116
+ '--json',
117
+ 'number,title,body,state,labels,assignees,milestone,updatedAt'
118
+ ]);
119
+
120
+ if (issueResult.status !== 0) {
121
+ throw new Error(`Failed to fetch issue: ${issueResult.stderr}`);
122
+ }
123
+
124
+ const issue = JSON.parse(issueResult.stdout);
125
+
126
+ // Fetch comments
127
+ const commentsResult = await execFileNoThrow('gh', [
128
+ 'api',
129
+ `repos/${owner}/${repo}/issues/${issueNumber}/comments`,
130
+ '--jq',
131
+ '.[] | {id: .id, author: .user.login, body: .body, created_at: .created_at}'
132
+ ]);
133
+
134
+ let comments: GitHubComment[] = [];
135
+ if (commentsResult.status === 0 && commentsResult.stdout.trim()) {
136
+ const commentLines = commentsResult.stdout.trim().split('\n');
137
+ comments = commentLines.map(line => JSON.parse(line));
138
+ }
139
+
140
+ return {
141
+ number: issue.number,
142
+ title: issue.title,
143
+ body: issue.body,
144
+ state: issue.state,
145
+ labels: issue.labels?.map((l: any) => l.name) || [],
146
+ assignees: issue.assignees?.map((a: any) => a.login) || [],
147
+ milestone: issue.milestone?.title,
148
+ comments,
149
+ updated_at: issue.updatedAt
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Detect conflicts between GitHub and SpecWeave
155
+ */
156
+ function detectConflicts(
157
+ metadata: IncrementMetadata,
158
+ githubState: GitHubIssueState
159
+ ): SyncConflict[] {
160
+ const conflicts: SyncConflict[] = [];
161
+
162
+ // Status conflict
163
+ const specweaveStatus = metadata.status; // "active", "completed", "paused", "abandoned"
164
+ const githubStatus = githubState.state; // "open", "closed"
165
+
166
+ const expectedGitHubStatus = mapSpecWeaveStatusToGitHub(specweaveStatus);
167
+
168
+ if (githubStatus !== expectedGitHubStatus) {
169
+ conflicts.push({
170
+ type: 'status',
171
+ githubValue: githubStatus,
172
+ specweaveValue: specweaveStatus,
173
+ resolution: 'prompt' // Ask user
174
+ });
175
+ }
176
+
177
+ // TODO: Add assignee/label conflicts if needed in future
178
+
179
+ return conflicts;
180
+ }
181
+
182
+ /**
183
+ * Resolve conflicts
184
+ */
185
+ async function resolveConflicts(
186
+ incrementId: string,
187
+ metadata: IncrementMetadata,
188
+ githubState: GitHubIssueState,
189
+ conflicts: SyncConflict[]
190
+ ): Promise<void> {
191
+ for (const conflict of conflicts) {
192
+ console.log(`\nāš ļø Conflict detected: ${conflict.type}`);
193
+ console.log(` GitHub: ${conflict.githubValue}`);
194
+ console.log(` SpecWeave: ${conflict.specweaveValue}`);
195
+
196
+ if (conflict.type === 'status') {
197
+ await resolveStatusConflict(incrementId, metadata, githubState);
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Resolve status conflict
204
+ */
205
+ async function resolveStatusConflict(
206
+ incrementId: string,
207
+ metadata: IncrementMetadata,
208
+ githubState: GitHubIssueState
209
+ ): Promise<void> {
210
+ const specweaveStatus = metadata.status;
211
+ const githubStatus = githubState.state;
212
+
213
+ // GitHub closed but SpecWeave active
214
+ if (githubStatus === 'closed' && specweaveStatus === 'active') {
215
+ console.log(`\nāš ļø **CONFLICT**: GitHub issue closed but SpecWeave increment still active!`);
216
+ console.log(` Recommendation: Run /specweave:done ${incrementId} to close increment`);
217
+ console.log(` Or reopen issue on GitHub if work is not complete`);
218
+ }
219
+
220
+ // GitHub open but SpecWeave completed
221
+ if (githubStatus === 'open' && specweaveStatus === 'completed') {
222
+ console.log(`\nāš ļø **CONFLICT**: SpecWeave increment completed but GitHub issue still open!`);
223
+ console.log(` Recommendation: Close GitHub issue #${metadata.github!.issue}`);
224
+ }
225
+
226
+ // GitHub open but SpecWeave paused
227
+ if (githubStatus === 'open' && specweaveStatus === 'paused') {
228
+ console.log(`\nā„¹ļø GitHub issue open, SpecWeave increment paused (OK)`);
229
+ }
230
+
231
+ // GitHub open but SpecWeave abandoned
232
+ if (githubStatus === 'open' && specweaveStatus === 'abandoned') {
233
+ console.log(`\nāš ļø **CONFLICT**: SpecWeave increment abandoned but GitHub issue still open!`);
234
+ console.log(` Recommendation: Close GitHub issue #${metadata.github!.issue} with reason`);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Sync comments from GitHub to SpecWeave
240
+ */
241
+ async function syncComments(
242
+ incrementId: string,
243
+ comments: GitHubComment[]
244
+ ): Promise<void> {
245
+ if (comments.length === 0) {
246
+ console.log('ā„¹ļø No comments to sync');
247
+ return;
248
+ }
249
+
250
+ const commentsPath = path.join(
251
+ process.cwd(),
252
+ '.specweave/increments',
253
+ incrementId,
254
+ 'logs/github-comments.md'
255
+ );
256
+
257
+ await fs.ensureFile(commentsPath);
258
+
259
+ // Load existing comments
260
+ let existingContent = '';
261
+ if (await fs.pathExists(commentsPath)) {
262
+ existingContent = await fs.readFile(commentsPath, 'utf-8');
263
+ }
264
+
265
+ // Extract existing comment IDs
266
+ const existingIds = new Set<number>();
267
+ const idMatches = existingContent.matchAll(/<!-- comment-id: (\d+) -->/g);
268
+ for (const match of idMatches) {
269
+ existingIds.add(parseInt(match[1], 10));
270
+ }
271
+
272
+ // Append new comments
273
+ const newComments = comments.filter(c => !existingIds.has(c.id));
274
+
275
+ if (newComments.length === 0) {
276
+ console.log('ā„¹ļø All comments already synced');
277
+ return;
278
+ }
279
+
280
+ console.log(`šŸ“ Syncing ${newComments.length} new comment(s)`);
281
+
282
+ const commentsMarkdown = newComments.map(comment => `
283
+ ---
284
+
285
+ <!-- comment-id: ${comment.id} -->
286
+
287
+ **Author**: @${comment.author}
288
+ **Date**: ${new Date(comment.created_at).toLocaleString()}
289
+
290
+ ${comment.body}
291
+ `.trim()).join('\n\n');
292
+
293
+ await fs.appendFile(
294
+ commentsPath,
295
+ (existingContent ? '\n\n' : '') + commentsMarkdown
296
+ );
297
+
298
+ console.log(`āœ… Comments saved to: logs/github-comments.md`);
299
+ }
300
+
301
+ /**
302
+ * Update metadata with GitHub state
303
+ */
304
+ async function updateMetadata(
305
+ incrementId: string,
306
+ githubState: GitHubIssueState
307
+ ): Promise<void> {
308
+ const metadataPath = path.join(
309
+ process.cwd(),
310
+ '.specweave/increments',
311
+ incrementId,
312
+ 'metadata.json'
313
+ );
314
+
315
+ const metadata = await fs.readJson(metadataPath);
316
+
317
+ // Update GitHub section
318
+ metadata.github = metadata.github || {};
319
+ metadata.github.synced = new Date().toISOString();
320
+ metadata.github.lastUpdated = githubState.updated_at;
321
+ metadata.github.state = githubState.state;
322
+
323
+ await fs.writeJson(metadataPath, metadata, { spaces: 2 });
324
+
325
+ console.log('āœ… Metadata updated');
326
+ }
327
+
328
+ /**
329
+ * Map SpecWeave status to GitHub state
330
+ */
331
+ function mapSpecWeaveStatusToGitHub(status: string): 'open' | 'closed' {
332
+ switch (status) {
333
+ case 'completed':
334
+ case 'abandoned':
335
+ return 'closed';
336
+ case 'active':
337
+ case 'paused':
338
+ case 'planning':
339
+ default:
340
+ return 'open';
341
+ }
342
+ }
@@ -0,0 +1,380 @@
1
+ /**
2
+ * GitHub Sync for Increment Changes
3
+ *
4
+ * Handles syncing spec.md, plan.md, and tasks.md changes to GitHub issues.
5
+ * Detects scope changes, architecture updates, and task modifications.
6
+ *
7
+ * @module github-sync-increment-changes
8
+ */
9
+
10
+ import fs from 'fs-extra';
11
+ import path from 'path';
12
+ import { execSync } from 'child_process';
13
+ import {
14
+ loadIncrementMetadata,
15
+ detectRepo,
16
+ postScopeChangeComment
17
+ } from './github-issue-updater.js';
18
+ import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
19
+
20
+ export interface SpecChanges {
21
+ added: string[];
22
+ removed: string[];
23
+ modified: string[];
24
+ }
25
+
26
+ /**
27
+ * Sync increment file changes to GitHub
28
+ */
29
+ export async function syncIncrementChanges(
30
+ incrementId: string,
31
+ changedFile: 'spec.md' | 'plan.md' | 'tasks.md'
32
+ ): Promise<void> {
33
+ console.log(`\nšŸ”„ Syncing ${changedFile} changes to GitHub...`);
34
+
35
+ try {
36
+ // 1. Load metadata
37
+ const metadata = await loadIncrementMetadata(incrementId);
38
+ if (!metadata?.github?.issue) {
39
+ console.log('ā„¹ļø No GitHub issue linked, skipping sync');
40
+ return;
41
+ }
42
+
43
+ // 2. Detect repository
44
+ const repoInfo = await detectRepo();
45
+ if (!repoInfo) {
46
+ console.log('āš ļø Could not detect GitHub repository, skipping sync');
47
+ return;
48
+ }
49
+
50
+ const { owner, repo } = repoInfo;
51
+ const issueNumber = metadata.github.issue;
52
+
53
+ // 3. Handle different file types
54
+ switch (changedFile) {
55
+ case 'spec.md':
56
+ await syncSpecChanges(incrementId, issueNumber, owner, repo);
57
+ break;
58
+ case 'plan.md':
59
+ await syncPlanChanges(incrementId, issueNumber, owner, repo);
60
+ break;
61
+ case 'tasks.md':
62
+ await syncTasksChanges(incrementId, issueNumber, owner, repo);
63
+ break;
64
+ }
65
+
66
+ console.log(`āœ… ${changedFile} changes synced to issue #${issueNumber}`);
67
+
68
+ } catch (error) {
69
+ console.error(`āŒ Error syncing ${changedFile}:`, error);
70
+ console.error(' (Non-blocking - continuing...)');
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Sync spec.md changes (scope changes)
76
+ */
77
+ async function syncSpecChanges(
78
+ incrementId: string,
79
+ issueNumber: number,
80
+ owner: string,
81
+ repo: string
82
+ ): Promise<void> {
83
+ const specPath = path.join(
84
+ process.cwd(),
85
+ '.specweave/increments',
86
+ incrementId,
87
+ 'spec.md'
88
+ );
89
+
90
+ // Detect what changed in spec.md
91
+ const changes = await detectSpecChanges(specPath);
92
+
93
+ if (changes.added.length === 0 && changes.removed.length === 0 && changes.modified.length === 0) {
94
+ console.log('ā„¹ļø No significant spec changes detected');
95
+ return;
96
+ }
97
+
98
+ // Post scope change comment
99
+ await postScopeChangeComment(
100
+ issueNumber,
101
+ {
102
+ added: changes.added,
103
+ removed: changes.removed,
104
+ modified: changes.modified,
105
+ reason: 'Spec updated',
106
+ impact: estimateImpact(changes)
107
+ },
108
+ owner,
109
+ repo
110
+ );
111
+
112
+ // Update issue title if needed
113
+ const title = await extractSpecTitle(specPath);
114
+ if (title) {
115
+ await updateIssueTitle(issueNumber, title, owner, repo);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Sync plan.md changes (architecture updates)
121
+ */
122
+ async function syncPlanChanges(
123
+ incrementId: string,
124
+ issueNumber: number,
125
+ owner: string,
126
+ repo: string
127
+ ): Promise<void> {
128
+ const comment = `
129
+ šŸ—ļø **Architecture Plan Updated**
130
+
131
+ The implementation plan has been updated. See [\`plan.md\`](https://github.com/${owner}/${repo}/blob/develop/.specweave/increments/${incrementId}/plan.md) for details.
132
+
133
+ **Timestamp**: ${new Date().toISOString()}
134
+
135
+ ---
136
+ šŸ¤– Auto-updated by SpecWeave
137
+ `.trim();
138
+
139
+ await postComment(issueNumber, comment, owner, repo);
140
+ }
141
+
142
+ /**
143
+ * Sync tasks.md changes (task updates)
144
+ */
145
+ async function syncTasksChanges(
146
+ incrementId: string,
147
+ issueNumber: number,
148
+ owner: string,
149
+ repo: string
150
+ ): Promise<void> {
151
+ const tasksPath = path.join(
152
+ process.cwd(),
153
+ '.specweave/increments',
154
+ incrementId,
155
+ 'tasks.md'
156
+ );
157
+
158
+ // Extract task list
159
+ const tasks = await extractTasks(tasksPath);
160
+
161
+ // Update issue body with new task checklist
162
+ await updateIssueTaskChecklist(issueNumber, tasks, owner, repo);
163
+
164
+ const comment = `
165
+ šŸ“‹ **Task List Updated**
166
+
167
+ Tasks have been updated. Total tasks: ${tasks.length}
168
+
169
+ **Timestamp**: ${new Date().toISOString()}
170
+
171
+ ---
172
+ šŸ¤– Auto-updated by SpecWeave
173
+ `.trim();
174
+
175
+ await postComment(issueNumber, comment, owner, repo);
176
+ }
177
+
178
+ /**
179
+ * Detect changes in spec.md by comparing with git history
180
+ */
181
+ async function detectSpecChanges(specPath: string): Promise<SpecChanges> {
182
+ const changes: SpecChanges = {
183
+ added: [],
184
+ removed: [],
185
+ modified: []
186
+ };
187
+
188
+ try {
189
+ // Get git diff for spec.md
190
+ const diff = execSync(`git diff HEAD~1 "${specPath}" 2>/dev/null || true`, {
191
+ encoding: 'utf-8',
192
+ cwd: process.cwd()
193
+ });
194
+
195
+ if (!diff) {
196
+ return changes;
197
+ }
198
+
199
+ // Parse diff to find user story changes
200
+ const lines = diff.split('\n');
201
+ for (const line of lines) {
202
+ // Look for user story additions/removals
203
+ if (line.startsWith('+') && line.includes('US-')) {
204
+ const match = line.match(/US-\d+:([^(]+)/);
205
+ if (match) {
206
+ changes.added.push(match[1].trim());
207
+ }
208
+ } else if (line.startsWith('-') && line.includes('US-')) {
209
+ const match = line.match(/US-\d+:([^(]+)/);
210
+ if (match) {
211
+ changes.removed.push(match[1].trim());
212
+ }
213
+ }
214
+ }
215
+
216
+ } catch (error) {
217
+ console.warn('āš ļø Could not detect spec changes:', error);
218
+ }
219
+
220
+ return changes;
221
+ }
222
+
223
+ /**
224
+ * Extract title from spec.md frontmatter
225
+ */
226
+ async function extractSpecTitle(specPath: string): Promise<string | null> {
227
+ try {
228
+ const content = await fs.readFile(specPath, 'utf-8');
229
+ const match = content.match(/^#\s+(.+)$/m);
230
+ return match ? match[1].trim() : null;
231
+ } catch (error) {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Extract tasks from tasks.md
238
+ */
239
+ async function extractTasks(tasksPath: string): Promise<string[]> {
240
+ try {
241
+ const content = await fs.readFile(tasksPath, 'utf-8');
242
+ const tasks: string[] = [];
243
+ const lines = content.split('\n');
244
+
245
+ for (const line of lines) {
246
+ // Match task headers: ## T-001: Task name
247
+ const match = line.match(/^##\s+(T-\d+):\s*(.+)$/);
248
+ if (match) {
249
+ tasks.push(`${match[1]}: ${match[2]}`);
250
+ }
251
+ }
252
+
253
+ return tasks;
254
+ } catch (error) {
255
+ return [];
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Estimate impact of spec changes
261
+ */
262
+ function estimateImpact(changes: SpecChanges): string {
263
+ const addedCount = changes.added.length;
264
+ const removedCount = changes.removed.length;
265
+
266
+ if (addedCount > removedCount) {
267
+ return `+${addedCount * 8} hours (${addedCount} user stories added)`;
268
+ } else if (removedCount > addedCount) {
269
+ return `-${removedCount * 8} hours (${removedCount} user stories removed)`;
270
+ } else {
271
+ return 'Neutral (scope adjusted)';
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Update issue title
277
+ */
278
+ async function updateIssueTitle(
279
+ issueNumber: number,
280
+ title: string,
281
+ owner: string,
282
+ repo: string
283
+ ): Promise<void> {
284
+ const result = await execFileNoThrow('gh', [
285
+ 'issue',
286
+ 'edit',
287
+ String(issueNumber),
288
+ '--repo',
289
+ `${owner}/${repo}`,
290
+ '--title',
291
+ title
292
+ ]);
293
+
294
+ if (result.status !== 0) {
295
+ console.warn(`āš ļø Could not update issue title: ${result.stderr}`);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Update issue task checklist
301
+ */
302
+ async function updateIssueTaskChecklist(
303
+ issueNumber: number,
304
+ tasks: string[],
305
+ owner: string,
306
+ repo: string
307
+ ): Promise<void> {
308
+ // Get current issue body
309
+ const result = await execFileNoThrow('gh', [
310
+ 'issue',
311
+ 'view',
312
+ String(issueNumber),
313
+ '--repo',
314
+ `${owner}/${repo}`,
315
+ '--json',
316
+ 'body',
317
+ '-q',
318
+ '.body'
319
+ ]);
320
+
321
+ if (result.status !== 0) {
322
+ throw new Error(`Failed to get issue body: ${result.stderr}`);
323
+ }
324
+
325
+ const currentBody = result.stdout.trim();
326
+
327
+ // Build new task checklist
328
+ const taskChecklist = tasks.map(task => `- [ ] ${task}`).join('\n');
329
+
330
+ // Find and replace task section
331
+ const taskSectionRegex = /## Tasks\n\n[\s\S]*?(?=\n## |$)/;
332
+ const newTaskSection = `## Tasks\n\nProgress: 0/${tasks.length} tasks (0%)\n\n${taskChecklist}\n`;
333
+
334
+ let updatedBody: string;
335
+ if (taskSectionRegex.test(currentBody)) {
336
+ updatedBody = currentBody.replace(taskSectionRegex, newTaskSection);
337
+ } else {
338
+ // Append task section
339
+ updatedBody = currentBody + '\n\n' + newTaskSection;
340
+ }
341
+
342
+ // Update issue
343
+ const updateResult = await execFileNoThrow('gh', [
344
+ 'issue',
345
+ 'edit',
346
+ String(issueNumber),
347
+ '--repo',
348
+ `${owner}/${repo}`,
349
+ '--body',
350
+ updatedBody
351
+ ]);
352
+
353
+ if (updateResult.status !== 0) {
354
+ throw new Error(`Failed to update issue body: ${updateResult.stderr}`);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Post comment to issue
360
+ */
361
+ async function postComment(
362
+ issueNumber: number,
363
+ comment: string,
364
+ owner: string,
365
+ repo: string
366
+ ): Promise<void> {
367
+ const result = await execFileNoThrow('gh', [
368
+ 'issue',
369
+ 'comment',
370
+ String(issueNumber),
371
+ '--repo',
372
+ `${owner}/${repo}`,
373
+ '--body',
374
+ comment
375
+ ]);
376
+
377
+ if (result.status !== 0) {
378
+ throw new Error(`Failed to post comment: ${result.stderr}`);
379
+ }
380
+ }