@pennyfarthing/core 7.7.0 → 7.8.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/README.md +1 -1
- package/package.json +1 -1
- package/packages/core/dist/cli/commands/doctor.d.ts +3 -0
- package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/doctor.js +134 -9
- package/packages/core/dist/cli/commands/doctor.js.map +1 -1
- package/pennyfarthing-dist/agents/sm-setup.md +37 -2
- package/pennyfarthing-dist/agents/sm.md +68 -22
- package/pennyfarthing-dist/agents/workflow-status-check.md +11 -1
- package/pennyfarthing-dist/commands/git-cleanup.md +43 -308
- package/pennyfarthing-dist/commands/solo.md +31 -0
- package/pennyfarthing-dist/guides/patterns/approval-gates-pattern.md +1 -1
- package/pennyfarthing-dist/personas/themes/gilligans-island.yaml +83 -83
- package/pennyfarthing-dist/personas/themes/the-expanse.yaml +11 -11
- package/pennyfarthing-dist/scripts/core/agent-session.sh +2 -2
- package/pennyfarthing-dist/scripts/core/check-context.sh +3 -0
- package/pennyfarthing-dist/scripts/core/handoff-marker.sh +13 -2
- package/pennyfarthing-dist/scripts/core/prime.sh +3 -157
- package/pennyfarthing-dist/scripts/core/run.sh +9 -0
- package/pennyfarthing-dist/scripts/hooks/__pycache__/question_reflector_check.cpython-314.pyc +0 -0
- package/pennyfarthing-dist/scripts/hooks/question_reflector_check.py +117 -20
- package/pennyfarthing-dist/scripts/jira/README.md +10 -7
- package/pennyfarthing-dist/scripts/misc/add-short-names.sh +13 -0
- package/pennyfarthing-dist/scripts/misc/add_short_names.py +226 -0
- package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.sh +6 -5
- package/pennyfarthing-dist/scripts/misc/migrate_bmad_workflow.py +319 -0
- package/pennyfarthing-dist/scripts/sprint/import-epic-to-future.sh +6 -5
- package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +270 -0
- package/pennyfarthing-dist/scripts/test/ensure-swebench-data.sh +59 -0
- package/pennyfarthing-dist/scripts/theme/compute-theme-tiers.sh +8 -6
- package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +402 -0
- package/pennyfarthing-dist/scripts/workflow/check.sh +3 -476
- package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +61 -0
- package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +13 -0
- package/pennyfarthing-dist/skills/judge/SKILL.md +57 -0
- package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +4 -22
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +83 -0
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-02-categorize.md +116 -0
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-03-execute.md +210 -0
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +88 -0
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +71 -0
- package/pennyfarthing-dist/workflows/git-cleanup.yaml +59 -0
- package/pennyfarthing-dist/scripts/hooks/question-reflector-check.mjs +0 -393
- package/pennyfarthing-dist/scripts/hooks/tests/question-reflector.test.mjs +0 -545
- package/pennyfarthing-dist/scripts/jira/jira-bidirectional-sync.mjs +0 -327
- package/pennyfarthing-dist/scripts/jira/jira-bidirectional-sync.test.mjs +0 -503
- package/pennyfarthing-dist/scripts/jira/jira-lib.mjs +0 -443
- package/pennyfarthing-dist/scripts/jira/jira-sync-story.mjs +0 -208
- package/pennyfarthing-dist/scripts/jira/jira-sync.mjs +0 -198
- package/pennyfarthing-dist/scripts/misc/add-short-names.mjs +0 -264
- package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.mjs +0 -474
- package/pennyfarthing-dist/scripts/sprint/import-epic-to-future.mjs +0 -377
- package/pennyfarthing-dist/scripts/theme/compute-theme-tiers.js +0 -492
- /package/pennyfarthing-dist/guides/{AGENT-COORDINATION.md → agent-coordination.md} +0 -0
- /package/pennyfarthing-dist/guides/{HOOKS.md → hooks.md} +0 -0
- /package/pennyfarthing-dist/guides/{PROMPT-PATTERNS.md → prompt-patterns.md} +0 -0
- /package/pennyfarthing-dist/guides/{SESSION-ARTIFACTS.md → session-artifacts.md} +0 -0
- /package/pennyfarthing-dist/guides/{XML-TAGS.md → xml-tags.md} +0 -0
|
@@ -1,443 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* jira-lib.mjs - Shared Jira functions for Pennyfarthing
|
|
4
|
-
*
|
|
5
|
-
* Provides utilities for interacting with Jira using the jira CLI
|
|
6
|
-
* and REST API for operations the CLI doesn't support well.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { execSync, spawnSync } from 'child_process';
|
|
10
|
-
import { readFileSync, existsSync } from 'fs';
|
|
11
|
-
import { dirname, join } from 'path';
|
|
12
|
-
import { fileURLToPath } from 'url';
|
|
13
|
-
|
|
14
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
-
const __dirname = dirname(__filename);
|
|
16
|
-
|
|
17
|
-
// Configuration
|
|
18
|
-
export const JIRA_PROJECT = process.env.JIRA_PROJECT || 'MSSCI';
|
|
19
|
-
export const JIRA_URL = process.env.JIRA_URL || 'https://1898andco.atlassian.net';
|
|
20
|
-
|
|
21
|
-
// ANSI colors
|
|
22
|
-
const colors = {
|
|
23
|
-
red: '\x1b[31m',
|
|
24
|
-
green: '\x1b[32m',
|
|
25
|
-
yellow: '\x1b[33m',
|
|
26
|
-
blue: '\x1b[34m',
|
|
27
|
-
reset: '\x1b[0m'
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Output helpers
|
|
32
|
-
*/
|
|
33
|
-
export function success(msg) {
|
|
34
|
-
console.error(`${colors.green}[OK]${colors.reset} ${msg}`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function info(msg) {
|
|
38
|
-
console.error(`${colors.blue}[INFO]${colors.reset} ${msg}`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function warn(msg) {
|
|
42
|
-
console.error(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function error(msg) {
|
|
46
|
-
console.error(`${colors.red}[ERROR]${colors.reset} ${msg}`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Find project root by looking for .claude directory
|
|
51
|
-
*/
|
|
52
|
-
export function findProjectRoot(startDir = process.cwd()) {
|
|
53
|
-
let dir = startDir;
|
|
54
|
-
while (dir !== '/') {
|
|
55
|
-
if (existsSync(join(dir, '.claude'))) {
|
|
56
|
-
return dir;
|
|
57
|
-
}
|
|
58
|
-
dir = dirname(dir);
|
|
59
|
-
}
|
|
60
|
-
throw new Error('Could not find project root (no .claude/ directory found)');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Check if jira CLI and dependencies are available
|
|
65
|
-
*/
|
|
66
|
-
export function checkDependencies(options = {}) {
|
|
67
|
-
const { quiet = false } = options;
|
|
68
|
-
const missing = [];
|
|
69
|
-
|
|
70
|
-
// Check for jira CLI
|
|
71
|
-
try {
|
|
72
|
-
execSync('which jira', { stdio: 'pipe' });
|
|
73
|
-
if (!quiet) success('jira installed');
|
|
74
|
-
} catch {
|
|
75
|
-
missing.push('jira');
|
|
76
|
-
error('jira not found');
|
|
77
|
-
console.error(' Install with: brew install ankitpokhrel/jira-cli/jira-cli');
|
|
78
|
-
console.error(' Then run: jira init');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Check for JIRA_API_TOKEN
|
|
82
|
-
if (!process.env.JIRA_API_TOKEN) {
|
|
83
|
-
missing.push('JIRA_API_TOKEN');
|
|
84
|
-
error('JIRA_API_TOKEN not set');
|
|
85
|
-
console.error(' Create token at: https://id.atlassian.com/manage-profile/security/api-tokens');
|
|
86
|
-
console.error(' Then: export JIRA_API_TOKEN="your-token"');
|
|
87
|
-
} else if (!quiet) {
|
|
88
|
-
success('JIRA_API_TOKEN set');
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Check jira config
|
|
92
|
-
const configPath = join(process.env.HOME, '.config/.jira/.config.yml');
|
|
93
|
-
if (existsSync(configPath)) {
|
|
94
|
-
if (!quiet) success('jira configured');
|
|
95
|
-
} else if (missing.indexOf('jira') === -1) {
|
|
96
|
-
missing.push('jira-config');
|
|
97
|
-
error('jira not configured');
|
|
98
|
-
console.error(' Run: jira init');
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (missing.length > 0) {
|
|
102
|
-
console.error('');
|
|
103
|
-
error(`Missing: ${missing.join(', ')}`);
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
console.error('');
|
|
108
|
-
return true;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Execute jira CLI command and return output
|
|
113
|
-
*/
|
|
114
|
-
export function jiraExec(args, options = {}) {
|
|
115
|
-
const { silent = false, allowFailure = false } = options;
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const result = spawnSync('jira', args, {
|
|
119
|
-
encoding: 'utf-8',
|
|
120
|
-
stdio: silent ? 'pipe' : ['pipe', 'pipe', 'pipe']
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
if (result.status !== 0 && !allowFailure) {
|
|
124
|
-
throw new Error(result.stderr || `jira command failed with exit code ${result.status}`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
stdout: result.stdout || '',
|
|
129
|
-
stderr: result.stderr || '',
|
|
130
|
-
status: result.status
|
|
131
|
-
};
|
|
132
|
-
} catch (err) {
|
|
133
|
-
if (allowFailure) {
|
|
134
|
-
return { stdout: '', stderr: err.message, status: 1 };
|
|
135
|
-
}
|
|
136
|
-
throw err;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Get issue as JSON from Jira
|
|
142
|
-
*/
|
|
143
|
-
export function getIssueJson(issueKey) {
|
|
144
|
-
try {
|
|
145
|
-
const result = jiraExec(['issue', 'view', issueKey, '--raw'], { silent: true, allowFailure: true });
|
|
146
|
-
if (result.status !== 0 || !result.stdout) {
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
149
|
-
return JSON.parse(result.stdout);
|
|
150
|
-
} catch {
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Extract field from Jira issue JSON
|
|
157
|
-
*/
|
|
158
|
-
export function getJiraField(issueJson, fieldPath, defaultValue = null) {
|
|
159
|
-
if (!issueJson) return defaultValue;
|
|
160
|
-
|
|
161
|
-
const parts = fieldPath.replace(/^\./, '').split('.');
|
|
162
|
-
let value = issueJson;
|
|
163
|
-
|
|
164
|
-
for (const part of parts) {
|
|
165
|
-
if (value === null || value === undefined) return defaultValue;
|
|
166
|
-
value = value[part];
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return value ?? defaultValue;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Status mapping: Pennyfarthing -> Jira
|
|
174
|
-
*/
|
|
175
|
-
export function mapStatusToJira(PennyfarthingStatus) {
|
|
176
|
-
const mapping = {
|
|
177
|
-
'backlog': 'To Do',
|
|
178
|
-
'todo': 'To Do',
|
|
179
|
-
'in-progress': 'In Progress',
|
|
180
|
-
'in_progress': 'In Progress',
|
|
181
|
-
'active': 'In Progress',
|
|
182
|
-
'review': 'In Review',
|
|
183
|
-
'in-review': 'In Review',
|
|
184
|
-
'in_review': 'In Review',
|
|
185
|
-
'done': 'Done',
|
|
186
|
-
'completed': 'Done',
|
|
187
|
-
'closed': 'Done',
|
|
188
|
-
'blocked': 'Blocked'
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
return mapping[PennyfarthingStatus?.toLowerCase()] || 'To Do';
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Status mapping: Jira -> Pennyfarthing
|
|
196
|
-
*/
|
|
197
|
-
export function mapJiraToStatus(jiraStatus) {
|
|
198
|
-
const mapping = {
|
|
199
|
-
'To Do': 'backlog',
|
|
200
|
-
'Open': 'backlog',
|
|
201
|
-
'Backlog': 'backlog',
|
|
202
|
-
'In Progress': 'in-progress',
|
|
203
|
-
'Active': 'in-progress',
|
|
204
|
-
'In Review': 'review',
|
|
205
|
-
'Review': 'review',
|
|
206
|
-
'Done': 'done',
|
|
207
|
-
'Closed': 'done',
|
|
208
|
-
'Resolved': 'done',
|
|
209
|
-
'Blocked': 'blocked'
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
return mapping[jiraStatus] || 'backlog';
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Extract Jira key from URL or return as-is
|
|
217
|
-
*/
|
|
218
|
-
export function extractJiraKey(input) {
|
|
219
|
-
if (!input) return null;
|
|
220
|
-
|
|
221
|
-
// Already in key format
|
|
222
|
-
const keyPattern = new RegExp(`^${JIRA_PROJECT}-\\d+$`);
|
|
223
|
-
if (keyPattern.test(input)) {
|
|
224
|
-
return input;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Extract from URL
|
|
228
|
-
const urlPattern = new RegExp(`(${JIRA_PROJECT}-\\d+)`);
|
|
229
|
-
const match = input.match(urlPattern);
|
|
230
|
-
return match ? match[1] : input;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Load sprint YAML file using yq CLI
|
|
235
|
-
*/
|
|
236
|
-
export function loadSprintFile(projectRoot) {
|
|
237
|
-
const sprintPath = join(projectRoot, 'sprint', 'current-sprint.yaml');
|
|
238
|
-
if (!existsSync(sprintPath)) {
|
|
239
|
-
throw new Error(`Sprint file not found: ${sprintPath}`);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Use yq to convert YAML to JSON, then parse
|
|
243
|
-
try {
|
|
244
|
-
const json = execSync(`yq -o json '.' "${sprintPath}"`, { encoding: 'utf-8' });
|
|
245
|
-
return JSON.parse(json);
|
|
246
|
-
} catch (err) {
|
|
247
|
-
throw new Error(`Failed to parse sprint file: ${err.message}`);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Find epic in sprint data (handles "24" and "epic-24" formats)
|
|
253
|
-
*/
|
|
254
|
-
export function findEpic(sprintData, epicNum) {
|
|
255
|
-
if (!sprintData?.epics) return null;
|
|
256
|
-
|
|
257
|
-
return sprintData.epics.find(e =>
|
|
258
|
-
e.id === epicNum ||
|
|
259
|
-
e.id === `epic-${epicNum}` ||
|
|
260
|
-
e.id === epicNum.replace(/^epic-/, '')
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Find story in epic
|
|
266
|
-
*/
|
|
267
|
-
export function findStory(epic, storyId) {
|
|
268
|
-
if (!epic?.stories) return null;
|
|
269
|
-
return epic.stories.find(s => s.id === storyId);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Get story field from sprint YAML
|
|
274
|
-
*/
|
|
275
|
-
export function getStoryField(sprintData, storyKey, fieldName) {
|
|
276
|
-
// Extract epic number from story key (e.g., "24-1" -> "24")
|
|
277
|
-
const epicNum = storyKey.split('-')[0];
|
|
278
|
-
|
|
279
|
-
const epic = findEpic(sprintData, epicNum);
|
|
280
|
-
if (!epic) return null;
|
|
281
|
-
|
|
282
|
-
const story = findStory(epic, storyKey);
|
|
283
|
-
if (!story) return null;
|
|
284
|
-
|
|
285
|
-
return story[fieldName] ?? null;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Transition issue to new status
|
|
290
|
-
*/
|
|
291
|
-
export function moveIssue(issueKey, targetStatus, options = {}) {
|
|
292
|
-
const { dryRun = false } = options;
|
|
293
|
-
|
|
294
|
-
if (dryRun) {
|
|
295
|
-
warn(`[DRY-RUN] Would move ${issueKey} to: ${targetStatus}`);
|
|
296
|
-
return { success: true, dryRun: true };
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Check current status first
|
|
300
|
-
const issueJson = getIssueJson(issueKey);
|
|
301
|
-
if (issueJson) {
|
|
302
|
-
const currentStatus = getJiraField(issueJson, 'fields.status.name');
|
|
303
|
-
if (currentStatus?.toLowerCase() === targetStatus.toLowerCase()) {
|
|
304
|
-
return { success: true, alreadyAtStatus: true };
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const result = jiraExec(['issue', 'move', issueKey, targetStatus], { allowFailure: true });
|
|
309
|
-
return {
|
|
310
|
-
success: result.status === 0,
|
|
311
|
-
output: result.stdout + result.stderr
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Get story points from issue
|
|
317
|
-
*/
|
|
318
|
-
export function getStoryPoints(issueKey, issueJson = null) {
|
|
319
|
-
if (!issueJson) {
|
|
320
|
-
issueJson = getIssueJson(issueKey);
|
|
321
|
-
}
|
|
322
|
-
if (!issueJson) return null;
|
|
323
|
-
|
|
324
|
-
// customfield_10031 is Story Points for 1898andco Jira
|
|
325
|
-
return getJiraField(issueJson, 'fields.customfield_10031', null);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Sync story points via REST API (jira CLI doesn't handle custom fields well)
|
|
330
|
-
*/
|
|
331
|
-
export async function syncStoryPoints(issueKey, PennyfarthingPoints, options = {}) {
|
|
332
|
-
const { dryRun = false, currentJiraPoints = null } = options;
|
|
333
|
-
|
|
334
|
-
if (!PennyfarthingPoints || PennyfarthingPoints === 'null') {
|
|
335
|
-
return { success: false, reason: 'No points in Pennyfarthing' };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Compare current points
|
|
339
|
-
const jiraPoints = currentJiraPoints ?? getStoryPoints(issueKey);
|
|
340
|
-
const jiraPointsInt = jiraPoints ? Math.floor(Number(jiraPoints)) : null;
|
|
341
|
-
|
|
342
|
-
if (jiraPointsInt === Number(PennyfarthingPoints)) {
|
|
343
|
-
return { success: true, alreadySynced: true };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
if (dryRun) {
|
|
347
|
-
warn(`[DRY-RUN] Would sync points for ${issueKey}: ${jiraPoints} -> ${PennyfarthingPoints}`);
|
|
348
|
-
return { success: true, dryRun: true };
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Use REST API to update custom field
|
|
352
|
-
const jiraUser = process.env.JIRA_USER || 'keith.avery@1898andco.io';
|
|
353
|
-
const apiUrl = `${JIRA_URL}/rest/api/3/issue/${issueKey}`;
|
|
354
|
-
|
|
355
|
-
try {
|
|
356
|
-
const response = await fetch(apiUrl, {
|
|
357
|
-
method: 'PUT',
|
|
358
|
-
headers: {
|
|
359
|
-
'Authorization': `Basic ${Buffer.from(`${jiraUser}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`,
|
|
360
|
-
'Content-Type': 'application/json'
|
|
361
|
-
},
|
|
362
|
-
body: JSON.stringify({
|
|
363
|
-
fields: {
|
|
364
|
-
customfield_10031: Number(PennyfarthingPoints)
|
|
365
|
-
}
|
|
366
|
-
})
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
if (response.ok || response.status === 204) {
|
|
370
|
-
return { success: true };
|
|
371
|
-
} else {
|
|
372
|
-
return { success: false, reason: `HTTP ${response.status}` };
|
|
373
|
-
}
|
|
374
|
-
} catch (err) {
|
|
375
|
-
return { success: false, reason: err.message };
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Add comment to issue
|
|
381
|
-
*/
|
|
382
|
-
export function addComment(issueKey, comment, options = {}) {
|
|
383
|
-
const { dryRun = false } = options;
|
|
384
|
-
|
|
385
|
-
if (dryRun) {
|
|
386
|
-
warn(`[DRY-RUN] Would add comment to ${issueKey}`);
|
|
387
|
-
return { success: true, dryRun: true };
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const result = jiraExec(['issue', 'comment', 'add', issueKey, comment], { allowFailure: true });
|
|
391
|
-
return {
|
|
392
|
-
success: result.status === 0,
|
|
393
|
-
output: result.stdout + result.stderr
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* GitHub username to Jira email mapping
|
|
399
|
-
*/
|
|
400
|
-
export function mapGithubToJira(githubUser) {
|
|
401
|
-
const mapping = {
|
|
402
|
-
'slabgorb': 'keith.avery@1898andco.io',
|
|
403
|
-
'arcaven': 'michael.pursifull@1898andco.io',
|
|
404
|
-
'RoseSecurity': 'michael.rosenfeld@1898andco.io',
|
|
405
|
-
'Zious11': 'jared.richards@1898andco.io',
|
|
406
|
-
'drbothen': 'joshua.magady@1898andco.io'
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
return mapping[githubUser] || `${githubUser}@1898andco.io`;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Parse command line arguments
|
|
414
|
-
*/
|
|
415
|
-
export function parseArgs(argv, spec) {
|
|
416
|
-
const args = { _positional: [] };
|
|
417
|
-
|
|
418
|
-
for (let i = 2; i < argv.length; i++) {
|
|
419
|
-
const arg = argv[i];
|
|
420
|
-
|
|
421
|
-
if (arg.startsWith('--')) {
|
|
422
|
-
const key = arg.slice(2);
|
|
423
|
-
const specEntry = spec[key];
|
|
424
|
-
|
|
425
|
-
if (specEntry?.type === 'boolean') {
|
|
426
|
-
args[key] = true;
|
|
427
|
-
} else if (specEntry?.type === 'string') {
|
|
428
|
-
args[key] = argv[++i];
|
|
429
|
-
} else {
|
|
430
|
-
// Unknown flag, treat as boolean
|
|
431
|
-
args[key] = true;
|
|
432
|
-
}
|
|
433
|
-
} else if (arg.startsWith('-')) {
|
|
434
|
-
// Short flags
|
|
435
|
-
const key = arg.slice(1);
|
|
436
|
-
args[key] = true;
|
|
437
|
-
} else {
|
|
438
|
-
args._positional.push(arg);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
return args;
|
|
443
|
-
}
|
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* jira-sync-story.mjs - Sync a single story to Jira
|
|
4
|
-
*
|
|
5
|
-
* Usage: node jira-sync-story.mjs <story_key> [--transition] [--points] [--comment "message"]
|
|
6
|
-
*
|
|
7
|
-
* Options:
|
|
8
|
-
* --transition Transition story to match Pennyfarthing status
|
|
9
|
-
* --points Sync story points from Pennyfarthing to Jira
|
|
10
|
-
* --comment Add a comment to the story
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
checkDependencies,
|
|
15
|
-
findProjectRoot,
|
|
16
|
-
loadSprintFile,
|
|
17
|
-
findEpic,
|
|
18
|
-
findStory,
|
|
19
|
-
extractJiraKey,
|
|
20
|
-
mapStatusToJira,
|
|
21
|
-
getIssueJson,
|
|
22
|
-
getJiraField,
|
|
23
|
-
moveIssue,
|
|
24
|
-
getStoryPoints,
|
|
25
|
-
syncStoryPoints,
|
|
26
|
-
addComment,
|
|
27
|
-
parseArgs,
|
|
28
|
-
success,
|
|
29
|
-
info,
|
|
30
|
-
warn,
|
|
31
|
-
error,
|
|
32
|
-
JIRA_URL
|
|
33
|
-
} from './jira-lib.mjs';
|
|
34
|
-
|
|
35
|
-
// Parse arguments
|
|
36
|
-
const args = parseArgs(process.argv, {
|
|
37
|
-
'transition': { type: 'boolean' },
|
|
38
|
-
'points': { type: 'boolean' },
|
|
39
|
-
'comment': { type: 'string' }
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const storyKey = args._positional[0];
|
|
43
|
-
const doTransition = args.transition || false;
|
|
44
|
-
const doSyncPoints = args.points || false;
|
|
45
|
-
const commentText = args.comment || null;
|
|
46
|
-
|
|
47
|
-
// Show usage if no story key provided
|
|
48
|
-
if (!storyKey) {
|
|
49
|
-
error('Story key required');
|
|
50
|
-
console.log(`
|
|
51
|
-
Usage: jira-sync-story.mjs <story_key> [--transition] [--points] [--comment "message"]
|
|
52
|
-
|
|
53
|
-
Examples:
|
|
54
|
-
jira-sync-story.mjs 24-1 # Show story status
|
|
55
|
-
jira-sync-story.mjs 24-1 --transition # Sync status to Jira
|
|
56
|
-
jira-sync-story.mjs 24-1 --points # Sync story points
|
|
57
|
-
jira-sync-story.mjs 24-1 --transition --points
|
|
58
|
-
jira-sync-story.mjs 24-1 --comment "Started development"
|
|
59
|
-
`);
|
|
60
|
-
process.exit(2);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Check dependencies (quiet mode - we'll show story info instead)
|
|
64
|
-
if (!checkDependencies({ quiet: false })) {
|
|
65
|
-
process.exit(2);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Find project root and load sprint data
|
|
69
|
-
let projectRoot;
|
|
70
|
-
let sprintData;
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
projectRoot = findProjectRoot();
|
|
74
|
-
sprintData = loadSprintFile(projectRoot);
|
|
75
|
-
} catch (err) {
|
|
76
|
-
error(err.message);
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Extract epic number from story key (e.g., "24-1" -> "24")
|
|
81
|
-
const epicNum = storyKey.split('-')[0];
|
|
82
|
-
|
|
83
|
-
// Find epic and story
|
|
84
|
-
const epic = findEpic(sprintData, epicNum);
|
|
85
|
-
if (!epic) {
|
|
86
|
-
error(`Epic ${epicNum} not found in sprint file`);
|
|
87
|
-
process.exit(1);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const story = findStory(epic, storyKey);
|
|
91
|
-
if (!story) {
|
|
92
|
-
error(`Story ${storyKey} not found in epic ${epicNum}`);
|
|
93
|
-
process.exit(1);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Get story details from Pennyfarthing
|
|
97
|
-
const PennyfarthingStatus = story.status || 'backlog';
|
|
98
|
-
const storyJira = story.jira;
|
|
99
|
-
const storyBranch = story.branch;
|
|
100
|
-
const storyPr = story.pr;
|
|
101
|
-
const storyPoints = story.points;
|
|
102
|
-
|
|
103
|
-
// Check if synced to Jira
|
|
104
|
-
if (!storyJira || storyJira === 'null') {
|
|
105
|
-
warn(`Story ${storyKey} not synced to Jira yet`);
|
|
106
|
-
console.log('');
|
|
107
|
-
console.log('To create this story in Jira, use:');
|
|
108
|
-
console.log(` jira issue create -tStory -s"Story ${storyKey}" -yHigh`);
|
|
109
|
-
process.exit(1);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const jiraKey = extractJiraKey(storyJira);
|
|
113
|
-
|
|
114
|
-
info(`Story: ${storyKey}`);
|
|
115
|
-
info(`Jira: ${jiraKey}`);
|
|
116
|
-
console.log('');
|
|
117
|
-
|
|
118
|
-
// Fetch current Jira state
|
|
119
|
-
const issueJson = getIssueJson(jiraKey);
|
|
120
|
-
if (!issueJson) {
|
|
121
|
-
error(`Could not fetch issue ${jiraKey}`);
|
|
122
|
-
process.exit(2);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const jiraStatus = getJiraField(issueJson, 'fields.status.name', 'Unknown');
|
|
126
|
-
const jiraAssignee = getJiraField(issueJson, 'fields.assignee.displayName', 'Unassigned');
|
|
127
|
-
const jiraSummary = getJiraField(issueJson, 'fields.summary', 'No summary');
|
|
128
|
-
const jiraPoints = getStoryPoints(jiraKey, issueJson);
|
|
129
|
-
|
|
130
|
-
// Display current state
|
|
131
|
-
console.log(` Summary: ${jiraSummary}`);
|
|
132
|
-
console.log(` Jira Status: ${jiraStatus}`);
|
|
133
|
-
console.log(` Assignee: ${jiraAssignee}`);
|
|
134
|
-
console.log(` Pennyfarthing Status: ${PennyfarthingStatus}`);
|
|
135
|
-
if (storyPoints) console.log(` Pennyfarthing Points: ${storyPoints}`);
|
|
136
|
-
if (jiraPoints) console.log(` Jira Points: ${jiraPoints}`);
|
|
137
|
-
if (storyBranch) console.log(` Branch: ${storyBranch}`);
|
|
138
|
-
if (storyPr) console.log(` PR: ${storyPr}`);
|
|
139
|
-
console.log('');
|
|
140
|
-
|
|
141
|
-
// Map Pennyfarthing status to target Jira status
|
|
142
|
-
const targetJiraStatus = mapStatusToJira(PennyfarthingStatus);
|
|
143
|
-
|
|
144
|
-
// Transition if requested
|
|
145
|
-
if (doTransition) {
|
|
146
|
-
if (jiraStatus === targetJiraStatus) {
|
|
147
|
-
success(`Already at correct status: ${jiraStatus}`);
|
|
148
|
-
} else {
|
|
149
|
-
info(`Transitioning: ${jiraStatus} -> ${targetJiraStatus}`);
|
|
150
|
-
const result = moveIssue(jiraKey, targetJiraStatus);
|
|
151
|
-
if (result.success) {
|
|
152
|
-
success(`Transitioned to ${targetJiraStatus}`);
|
|
153
|
-
} else {
|
|
154
|
-
warn('Could not transition (status may not be available from current state)');
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Sync story points if requested
|
|
160
|
-
if (doSyncPoints) {
|
|
161
|
-
info(`Syncing story points: ${jiraPoints || 'unset'} -> ${storyPoints || 'unset'}`);
|
|
162
|
-
const result = await syncStoryPoints(jiraKey, storyPoints, { currentJiraPoints: jiraPoints });
|
|
163
|
-
if (result.success) {
|
|
164
|
-
if (result.alreadySynced) {
|
|
165
|
-
success(`Story points already synced: ${storyPoints}`);
|
|
166
|
-
} else {
|
|
167
|
-
success(`Story points synced: ${storyPoints}`);
|
|
168
|
-
}
|
|
169
|
-
} else {
|
|
170
|
-
warn(`Could not sync story points: ${result.reason}`);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Add comment if provided
|
|
175
|
-
if (commentText) {
|
|
176
|
-
info('Adding comment...');
|
|
177
|
-
const result = addComment(jiraKey, commentText);
|
|
178
|
-
if (result.success) {
|
|
179
|
-
success('Comment added');
|
|
180
|
-
} else {
|
|
181
|
-
warn('Could not add comment');
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Auto-comment for transitions (if no manual comment provided)
|
|
186
|
-
if (doTransition && !commentText) {
|
|
187
|
-
let autoComment = null;
|
|
188
|
-
|
|
189
|
-
if (PennyfarthingStatus === 'in-progress' && storyBranch) {
|
|
190
|
-
autoComment = `**Development Started**\n\nBranch: \`${storyBranch}\`\n\nSynced from Pennyfarthing`;
|
|
191
|
-
} else if (PennyfarthingStatus === 'review' && storyPr) {
|
|
192
|
-
autoComment = `**Ready for Review**\n\nPR: ${storyPr}\n\nSynced from Pennyfarthing`;
|
|
193
|
-
} else if (PennyfarthingStatus === 'done') {
|
|
194
|
-
const today = new Date().toISOString().split('T')[0];
|
|
195
|
-
autoComment = `**Completed**\n\nSynced from Pennyfarthing on ${today}`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (autoComment) {
|
|
199
|
-
info('Adding sync comment...');
|
|
200
|
-
const result = addComment(jiraKey, autoComment);
|
|
201
|
-
if (result.success) {
|
|
202
|
-
success('Sync comment added');
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
console.log('');
|
|
208
|
-
success(`${JIRA_URL}/browse/${jiraKey}`);
|