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.
- package/dist/utils/external-resource-validator.d.ts.map +1 -1
- package/dist/utils/external-resource-validator.js +101 -59
- package/dist/utils/external-resource-validator.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave/hooks/post-increment-change.sh +94 -0
- package/plugins/specweave/hooks/post-increment-status-change.sh +143 -0
- package/plugins/specweave/lib/hooks/sync-living-docs.ts +57 -16
- package/plugins/specweave-github/commands/specweave-github-sync-from.md +147 -0
- package/plugins/specweave-github/lib/cli-sync-increment-changes.ts +33 -0
- package/plugins/specweave-github/lib/github-issue-updater.ts +449 -0
- package/plugins/specweave-github/lib/github-sync-bidirectional.ts +342 -0
- package/plugins/specweave-github/lib/github-sync-increment-changes.ts +380 -0
- package/src/templates/AGENTS.md.template +55 -9
|
@@ -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
|
+
}
|