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.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/cli/dist/srm.js +895 -0
- package/client/dist/assets/index-BEc7DzgE.css +1 -0
- package/client/dist/assets/index-DoIYcnfd.js +486 -0
- package/client/dist/index.html +13 -0
- package/package.json +57 -0
- package/server/analytics.test.ts +166 -0
- package/server/analytics.ts +318 -0
- package/server/chat-manager.test.ts +216 -0
- package/server/chat-manager.ts +289 -0
- package/server/command-grid-logic.test.ts +480 -0
- package/server/command-resolver.test.ts +136 -0
- package/server/command-resolver.ts +29 -0
- package/server/config.test.ts +193 -0
- package/server/config.ts +321 -0
- package/server/db.test.ts +409 -0
- package/server/db.ts +514 -0
- package/server/hooks.test.ts +196 -0
- package/server/hooks.ts +117 -0
- package/server/hub-db.ts +141 -0
- package/server/hub-router.ts +137 -0
- package/server/index.test.ts +538 -0
- package/server/index.ts +539 -0
- package/server/project-registry.ts +130 -0
- package/server/project-router.ts +451 -0
- package/server/proposal-manager.test.ts +410 -0
- package/server/proposal-manager.ts +285 -0
- package/server/proposal-routes.test.ts +424 -0
- package/server/queue-manager.test.ts +400 -0
- package/server/queue-manager.ts +545 -0
- package/server/setup-manager.ts +526 -0
- package/server/types.ts +360 -0
|
@@ -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
|
+
})
|
package/server/config.ts
ADDED
|
@@ -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
|
+
}
|