specrails-hub 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.
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import fs from 'fs'
3
+ import { execSync } from 'child_process'
4
+
5
+ // Mock child_process execSync to avoid real CLI calls
6
+ vi.mock('child_process', async () => {
7
+ const actual = await vi.importActual<typeof import('child_process')>('child_process')
8
+ return {
9
+ ...actual,
10
+ execSync: vi.fn(),
11
+ }
12
+ })
13
+
14
+ import { getConfig, fetchIssues } from './config'
15
+
16
+ const mockExecSync = execSync as ReturnType<typeof vi.fn>
17
+
18
+ // Spy references — typed as any to avoid overloaded-signature inference conflicts
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ let existsSyncSpy: any
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ let readdirSyncSpy: any
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ let readFileSyncSpy: any
25
+
26
+ describe('getConfig', () => {
27
+ beforeEach(() => {
28
+ vi.resetAllMocks()
29
+ mockExecSync.mockReturnValue(Buffer.from(''))
30
+ existsSyncSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false)
31
+ readdirSyncSpy = vi.spyOn(fs, 'readdirSync').mockReturnValue([])
32
+ readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue('')
33
+ })
34
+
35
+ afterEach(() => {
36
+ existsSyncSpy.mockRestore()
37
+ readdirSyncSpy.mockRestore()
38
+ readFileSyncSpy.mockRestore()
39
+ })
40
+
41
+ it('returns config structure with all required fields', () => {
42
+ const config = getConfig('/some/project/specrails/web-manager')
43
+
44
+ expect(config).toHaveProperty('project')
45
+ expect(config).toHaveProperty('issueTracker')
46
+ expect(config).toHaveProperty('commands')
47
+ expect(config.issueTracker).toHaveProperty('github')
48
+ expect(config.issueTracker).toHaveProperty('jira')
49
+ expect(config.issueTracker).toHaveProperty('active')
50
+ expect(config.issueTracker).toHaveProperty('labelFilter')
51
+ })
52
+
53
+ it('detects gh as available when which gh succeeds', () => {
54
+ mockExecSync.mockImplementation((cmd: string) => {
55
+ if (cmd === 'which gh') return Buffer.from('/usr/bin/gh')
56
+ if (cmd === 'gh auth status') return Buffer.from('Logged in to github.com')
57
+ return Buffer.from('')
58
+ })
59
+
60
+ const config = getConfig('/some/project/specrails/web-manager')
61
+
62
+ expect(config.issueTracker.github.available).toBe(true)
63
+ expect(config.issueTracker.github.authenticated).toBe(true)
64
+ })
65
+
66
+ it('reports gh as unavailable when which gh fails', () => {
67
+ mockExecSync.mockImplementation((cmd: string) => {
68
+ if (cmd === 'which gh') throw new Error('not found')
69
+ return Buffer.from('')
70
+ })
71
+
72
+ const config = getConfig('/some/project/specrails/web-manager')
73
+
74
+ expect(config.issueTracker.github.available).toBe(false)
75
+ expect(config.issueTracker.github.authenticated).toBe(false)
76
+ })
77
+
78
+ it('scans command files from .claude/commands/sr/ directory', () => {
79
+ mockExecSync.mockReturnValue(Buffer.from(''))
80
+ existsSyncSpy.mockReturnValue(true)
81
+ readdirSyncSpy.mockReturnValue(['implement.md', 'batch-implement.md'] as unknown as fs.Dirent[])
82
+ readFileSyncSpy.mockImplementation((filePath: unknown) => {
83
+ if (String(filePath).includes('implement.md') && !String(filePath).includes('batch')) {
84
+ return `---\nname: Implement\ndescription: Implement a feature from an issue\n---\n# Content`
85
+ }
86
+ if (String(filePath).includes('batch-implement.md')) {
87
+ return `---\nname: Batch Implement\ndescription: Implement multiple features\n---\n# Content`
88
+ }
89
+ return ''
90
+ })
91
+
92
+ const config = getConfig('/some/project/specrails/web-manager')
93
+
94
+ expect(config.commands).toHaveLength(2)
95
+ expect(config.commands[0].name).toBe('Implement')
96
+ expect(config.commands[0].description).toBe('Implement a feature from an issue')
97
+ expect(config.commands[1].name).toBe('Batch Implement')
98
+ })
99
+
100
+ it('falls back to filename-derived name when frontmatter is missing', () => {
101
+ mockExecSync.mockReturnValue(Buffer.from(''))
102
+ existsSyncSpy.mockReturnValue(true)
103
+ readdirSyncSpy.mockReturnValue(['health-check.md'] as unknown as fs.Dirent[])
104
+ readFileSyncSpy.mockReturnValue('# No frontmatter here\nJust content')
105
+
106
+ const config = getConfig('/some/project/specrails/web-manager')
107
+
108
+ expect(config.commands).toHaveLength(1)
109
+ expect(config.commands[0].id).toBe('health-check')
110
+ expect(config.commands[0].name).toBe('health-check')
111
+ })
112
+
113
+ it('extracts repo name from git remote HTTPS URL', () => {
114
+ mockExecSync.mockImplementation((cmd: string) => {
115
+ if (cmd === 'git remote get-url origin') return Buffer.from('https://github.com/owner/myrepo.git')
116
+ return Buffer.from('')
117
+ })
118
+
119
+ const config = getConfig('/some/project/specrails/web-manager')
120
+
121
+ expect(config.project.repo).toBe('owner/myrepo')
122
+ })
123
+
124
+ it('extracts repo name from git remote SSH URL', () => {
125
+ mockExecSync.mockImplementation((cmd: string) => {
126
+ if (cmd === 'git remote get-url origin') return Buffer.from('git@github.com:owner/myrepo.git')
127
+ return Buffer.from('')
128
+ })
129
+
130
+ const config = getConfig('/some/project/specrails/web-manager')
131
+
132
+ expect(config.project.repo).toBe('owner/myrepo')
133
+ })
134
+
135
+ it('returns null repo when git remote is not github', () => {
136
+ mockExecSync.mockImplementation((cmd: string) => {
137
+ if (cmd === 'git remote get-url origin') return Buffer.from('https://gitlab.com/owner/repo.git')
138
+ return Buffer.from('')
139
+ })
140
+
141
+ const config = getConfig('/some/project/specrails/web-manager')
142
+
143
+ expect(config.project.repo).toBe(null)
144
+ })
145
+
146
+ it('auto-detects github as active when authenticated', () => {
147
+ mockExecSync.mockImplementation((cmd: string) => {
148
+ if (cmd === 'which gh') return Buffer.from('/usr/bin/gh')
149
+ if (cmd === 'gh auth status') return Buffer.from('Logged in')
150
+ if (cmd === 'which jira') throw new Error('not found')
151
+ return Buffer.from('')
152
+ })
153
+
154
+ const config = getConfig('/some/project/specrails/web-manager')
155
+
156
+ expect(config.issueTracker.active).toBe('github')
157
+ })
158
+ })
159
+
160
+ describe('fetchIssues', () => {
161
+ beforeEach(() => {
162
+ vi.resetAllMocks()
163
+ mockExecSync.mockReturnValue(Buffer.from(''))
164
+ })
165
+
166
+ it('returns structured issues from gh issue list output', () => {
167
+ const mockOutput = JSON.stringify([
168
+ { number: 42, title: 'Fix the bug', labels: [{ name: 'bug' }], body: 'Description', url: 'https://github.com/...' },
169
+ { number: 43, title: 'Add feature', labels: [], body: '', url: 'https://github.com/...' },
170
+ ])
171
+ mockExecSync.mockReturnValue(Buffer.from(mockOutput))
172
+
173
+ const issues = fetchIssues('github', {})
174
+
175
+ expect(issues).toHaveLength(2)
176
+ expect(issues[0].number).toBe(42)
177
+ expect(issues[0].title).toBe('Fix the bug')
178
+ expect(issues[0].labels).toEqual(['bug'])
179
+ })
180
+
181
+ it('returns empty array when gh command fails', () => {
182
+ mockExecSync.mockImplementation(() => { throw new Error('gh not found') })
183
+
184
+ const issues = fetchIssues('github', {})
185
+
186
+ expect(issues).toEqual([])
187
+ })
188
+
189
+ it('returns empty array for unsupported tracker', () => {
190
+ const issues = fetchIssues('github', {})
191
+ expect(Array.isArray(issues)).toBe(true)
192
+ })
193
+ })
@@ -0,0 +1,321 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { execSync } from 'child_process'
4
+ import type { PhaseDefinition } from './types'
5
+
6
+ export interface CommandInfo {
7
+ id: string
8
+ name: string
9
+ description: string
10
+ slug: string
11
+ phases: PhaseDefinition[]
12
+ }
13
+
14
+ export interface IssueTrackerInfo {
15
+ available: boolean
16
+ authenticated: boolean
17
+ repo?: string
18
+ }
19
+
20
+ export interface ProjectConfig {
21
+ project: {
22
+ name: string
23
+ repo: string | null
24
+ }
25
+ issueTracker: {
26
+ github: IssueTrackerInfo
27
+ jira: IssueTrackerInfo
28
+ active: 'github' | 'jira' | null
29
+ labelFilter: string
30
+ }
31
+ commands: CommandInfo[]
32
+ }
33
+
34
+ function runCommand(cmd: string, cwd?: string): string | null {
35
+ try {
36
+ return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, cwd }).toString().trim()
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ function detectGithub(): IssueTrackerInfo {
43
+ const ghPath = runCommand('which gh')
44
+ if (!ghPath) return { available: false, authenticated: false }
45
+
46
+ const authOutput = runCommand('gh auth status')
47
+ const authenticated = authOutput !== null
48
+
49
+ return { available: true, authenticated }
50
+ }
51
+
52
+ function detectJira(): IssueTrackerInfo {
53
+ const jiraPath = runCommand('which jira')
54
+ if (!jiraPath) return { available: false, authenticated: false }
55
+
56
+ // jira CLI availability means it is configured (auth is implicit via jira config)
57
+ return { available: true, authenticated: true }
58
+ }
59
+
60
+ function getGitRepoName(projectRoot?: string): string | null {
61
+ const output = runCommand('git remote get-url origin', projectRoot)
62
+ if (!output) return null
63
+
64
+ // Parse both HTTPS and SSH remote URLs
65
+ // https://github.com/owner/repo.git → owner/repo
66
+ // git@github.com:owner/repo.git → owner/repo
67
+ const httpsMatch = output.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/)
68
+ if (httpsMatch) return httpsMatch[1]
69
+
70
+ const sshMatch = output.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/)
71
+ if (sshMatch) return sshMatch[1]
72
+
73
+ return null
74
+ }
75
+
76
+ interface ParsedFrontmatter {
77
+ scalars: Record<string, string>
78
+ phases: PhaseDefinition[]
79
+ }
80
+
81
+ function parseFrontmatter(content: string): ParsedFrontmatter {
82
+ const result: ParsedFrontmatter = { scalars: {}, phases: [] }
83
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
84
+ if (!match) return result
85
+
86
+ const lines = match[1].split('\n')
87
+ let i = 0
88
+
89
+ while (i < lines.length) {
90
+ const line = lines[i]
91
+ const colonIdx = line.indexOf(':')
92
+ if (colonIdx === -1) { i++; continue }
93
+
94
+ const key = line.slice(0, colonIdx).trim()
95
+ const rawValue = line.slice(colonIdx + 1).trim()
96
+
97
+ if (!key) { i++; continue }
98
+
99
+ // Array field: value is empty, subsequent lines start with " - "
100
+ if (rawValue === '' && i + 1 < lines.length && lines[i + 1].startsWith(' - ')) {
101
+ i++
102
+ if (key === 'phases') {
103
+ const items: PhaseDefinition[] = []
104
+ let current: Partial<PhaseDefinition> | null = null
105
+
106
+ while (i < lines.length) {
107
+ const aLine = lines[i]
108
+ if (aLine.startsWith(' - ')) {
109
+ // New array item — flush current
110
+ if (current) items.push(current as PhaseDefinition)
111
+ current = {}
112
+ // Parse the inline key: value after " - "
113
+ const rest = aLine.slice(4)
114
+ const aColon = rest.indexOf(':')
115
+ if (aColon !== -1) {
116
+ const aKey = rest.slice(0, aColon).trim()
117
+ const aVal = rest.slice(aColon + 1).trim().replace(/^["']|["']$/g, '')
118
+ ;(current as Record<string, string>)[aKey] = aVal
119
+ }
120
+ i++
121
+ } else if (aLine.startsWith(' ') && !aLine.startsWith(' - ')) {
122
+ // Continuation key: value for current item
123
+ if (current) {
124
+ const aColon = aLine.indexOf(':')
125
+ if (aColon !== -1) {
126
+ const aKey = aLine.slice(0, aColon).trim()
127
+ const aVal = aLine.slice(aColon + 1).trim().replace(/^["']|["']$/g, '')
128
+ ;(current as Record<string, string>)[aKey] = aVal
129
+ }
130
+ }
131
+ i++
132
+ } else {
133
+ break
134
+ }
135
+ }
136
+
137
+ if (current) items.push(current as PhaseDefinition)
138
+
139
+ result.phases = items.filter(
140
+ (item): item is PhaseDefinition =>
141
+ typeof item.key === 'string' &&
142
+ typeof item.label === 'string' &&
143
+ typeof item.description === 'string'
144
+ )
145
+ } else {
146
+ // Skip unknown array fields
147
+ while (i < lines.length && (lines[i].startsWith(' - ') || lines[i].startsWith(' '))) {
148
+ i++
149
+ }
150
+ }
151
+ } else {
152
+ result.scalars[key] = rawValue.replace(/^["']|["']$/g, '')
153
+ i++
154
+ }
155
+ }
156
+
157
+ return result
158
+ }
159
+
160
+ function slugify(name: string): string {
161
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
162
+ }
163
+
164
+ function scanCommands(commandsDir: string): CommandInfo[] {
165
+ if (!fs.existsSync(commandsDir)) return []
166
+
167
+ let files: string[]
168
+ try {
169
+ files = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.md'))
170
+ } catch {
171
+ return []
172
+ }
173
+
174
+ return files.map((file) => {
175
+ const slug = file.replace(/\.md$/, '')
176
+ let name = slug
177
+ let description = ''
178
+ let phases: PhaseDefinition[] = []
179
+
180
+ try {
181
+ const content = fs.readFileSync(path.join(commandsDir, file), 'utf-8')
182
+ const fm = parseFrontmatter(content)
183
+ if (fm.scalars.name) name = fm.scalars.name
184
+ if (fm.scalars.description) description = fm.scalars.description
185
+ phases = fm.phases
186
+ } catch {
187
+ // Use filename-derived name if frontmatter parsing fails
188
+ }
189
+
190
+ return {
191
+ id: slug,
192
+ name,
193
+ description,
194
+ slug,
195
+ phases,
196
+ }
197
+ })
198
+ }
199
+
200
+ function loadPersistedConfig(db: any): { active: string | null; labelFilter: string } {
201
+ try {
202
+ const activeRow = db.prepare(`SELECT value FROM queue_state WHERE key = 'config.active_tracker'`).get() as { value: string } | undefined
203
+ const labelRow = db.prepare(`SELECT value FROM queue_state WHERE key = 'config.label_filter'`).get() as { value: string } | undefined
204
+ return {
205
+ active: (activeRow?.value as 'github' | 'jira' | null) ?? null,
206
+ labelFilter: labelRow?.value ?? '',
207
+ }
208
+ } catch {
209
+ return { active: null, labelFilter: '' }
210
+ }
211
+ }
212
+
213
+ export function getConfig(cwd: string, db?: any, projectName?: string): ProjectConfig {
214
+ // Resolve project root.
215
+ // In single-project mode: manager lives at <project>/specrails/manager/,
216
+ // so we walk up two levels to find the project root.
217
+ // In hub mode: cwd is the project root directly — we detect this by checking
218
+ // if the .claude directory already lives at cwd.
219
+ let projectRoot: string
220
+ if (fs.existsSync(path.join(cwd, '.claude'))) {
221
+ // cwd is already the project root (hub mode passes project.path directly)
222
+ projectRoot = cwd
223
+ } else {
224
+ // Single-project mode: walk up two levels
225
+ projectRoot = path.resolve(cwd, '../..')
226
+ }
227
+ const commandsDir = path.join(projectRoot, '.claude', 'commands', 'sr')
228
+ const commands = scanCommands(commandsDir)
229
+
230
+ const github = detectGithub()
231
+ const jira = detectJira()
232
+ const repo = getGitRepoName(projectRoot)
233
+
234
+ const persisted = db ? loadPersistedConfig(db) : { active: null, labelFilter: '' }
235
+
236
+ // Auto-detect active tracker if not persisted
237
+ let active = persisted.active as 'github' | 'jira' | null
238
+ if (!active) {
239
+ if (github.authenticated) active = 'github'
240
+ else if (jira.authenticated) active = 'jira'
241
+ }
242
+
243
+ return {
244
+ project: {
245
+ name: projectName ?? path.basename(projectRoot),
246
+ repo: repo,
247
+ },
248
+ issueTracker: {
249
+ github,
250
+ jira,
251
+ active,
252
+ labelFilter: persisted.labelFilter,
253
+ },
254
+ commands,
255
+ }
256
+ }
257
+
258
+ export interface IssueItem {
259
+ number: number
260
+ title: string
261
+ labels: string[]
262
+ body: string
263
+ url?: string
264
+ }
265
+
266
+ export function fetchIssues(
267
+ tracker: 'github' | 'jira',
268
+ opts: { search?: string; label?: string; repo?: string | null; cwd?: string }
269
+ ): IssueItem[] {
270
+ if (tracker === 'github') {
271
+ const args = ['gh', 'issue', 'list', '--json', 'number,title,labels,body,url', '--limit', '50']
272
+ if (opts.repo) args.push('--repo', opts.repo)
273
+ if (opts.label) args.push('--label', opts.label)
274
+ if (opts.search) args.push('--search', opts.search)
275
+
276
+ const output = runCommand(args.join(' '), opts.cwd)
277
+ if (!output) return []
278
+
279
+ try {
280
+ const raw = JSON.parse(output) as Array<{
281
+ number: number
282
+ title: string
283
+ labels: Array<{ name: string }>
284
+ body: string
285
+ url: string
286
+ }>
287
+ return raw.map((item) => ({
288
+ number: item.number,
289
+ title: item.title,
290
+ labels: item.labels.map((l) => l.name),
291
+ body: item.body ?? '',
292
+ url: item.url,
293
+ }))
294
+ } catch {
295
+ return []
296
+ }
297
+ }
298
+
299
+ if (tracker === 'jira') {
300
+ const jql = opts.search ? `summary ~ "${opts.search}"` : ''
301
+ const args = ['jira', 'issue', 'list', '--plain', '--columns', 'KEY,SUMMARY,LABELS,STATUS']
302
+ if (jql) args.push('--jql', jql)
303
+
304
+ const output = runCommand(args.join(' '))
305
+ if (!output) return []
306
+
307
+ // Parse plain text output: KEY SUMMARY LABELS STATUS
308
+ const lines = output.split('\n').filter(Boolean)
309
+ return lines.slice(1).map((line, idx) => {
310
+ const parts = line.split('\t')
311
+ return {
312
+ number: idx + 1,
313
+ title: parts[1]?.trim() ?? line,
314
+ labels: parts[2] ? parts[2].split(',').map((l) => l.trim()) : [],
315
+ body: '',
316
+ }
317
+ })
318
+ }
319
+
320
+ return []
321
+ }