Skip to content

Commit 4ec4875

Browse files
committed
feat: add kind field to Issue type, fetch PRs alongside issues in GitHub provider
- Added 'kind: issue | pr' to Issue interface - GitHub provider now fetches both issues and PRs in list() - GitHub get() tries issue first, falls back to PR - MERGED state maps to 'closed' - Radicle provider updated with kind parameter - All 88 issue tests passing
1 parent ace0982 commit 4ec4875

File tree

10 files changed

+79
-31
lines changed

10 files changed

+79
-31
lines changed

packages/server/src/__integration__/phase4.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function cleanup(dir: string) {
3232

3333
function makeIssue(overrides: Partial<Issue> = {}): Issue {
3434
return {
35+
kind: 'issue',
3536
id: '1',
3637
projectId: 'proj1',
3738
orgId: 'org1',

packages/server/src/__integration__/phase5.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
1616
issueCounter++
1717
return {
1818
id: String(issueCounter),
19+
kind: 'issue',
1920
projectId: 'proj1',
2021
orgId: 'org1',
2122
remote: 'github',

packages/server/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,8 @@ function parseGitRemotes(
285285
const url = urlMatch[1].trim()
286286

287287
// GitHub: git@github.com:owner/repo.git or https://github.com/owner/repo.git
288-
const ghSsh = url.match(/github\.com[:/]([^/]+\/[^/.]+?)(?:\.git)?$/)
289-
const ghHttps = url.match(/github\.com\/([^/]+\/[^/.]+?)(?:\.git)?$/)
288+
const ghSsh = url.match(/github\.com[:/]([^/]+\/[^/.]+?)(?:\.git)?\/?$/)
289+
const ghHttps = url.match(/github\.com\/([^/]+\/[^/.]+?)(?:\.git)?\/?$/)
290290
if (ghSsh || ghHttps) {
291291
const repo = (ghSsh || ghHttps)![1]
292292
remotes.push({ name: remoteName, provider: 'github', repo })

packages/server/src/issues/cache.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ function tmpDir(): string {
1212
const sampleIssues: Issue[] = [
1313
{
1414
id: '1',
15+
kind: 'issue',
1516
projectId: 'proj1',
1617
orgId: 'org1',
1718
remote: 'origin',

packages/server/src/issues/github.test.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const sampleComment = {
3737
describe('GitHubIssueProvider', () => {
3838
describe('list', () => {
3939
it('lists issues via gh issue list', async () => {
40-
const execFn = mockExec({ 'issue list': JSON.stringify([sampleGhIssue]) })
40+
const execFn = mockExec({ 'issue list': JSON.stringify([sampleGhIssue]), 'pr list': JSON.stringify([]) })
4141
const provider = createGitHubIssueProvider({
4242
repo: 'owner/repo',
4343
remote: 'origin',
@@ -50,8 +50,24 @@ describe('GitHubIssueProvider', () => {
5050
expect(execFn).toHaveBeenCalled()
5151
})
5252

53+
it('includes PRs in list results', async () => {
54+
const samplePr = { ...sampleGhIssue, number: 99, title: 'Fix PR' }
55+
const execFn = mockExec({ 'issue list': JSON.stringify([sampleGhIssue]), 'pr list': JSON.stringify([samplePr]) })
56+
const provider = createGitHubIssueProvider({
57+
repo: 'owner/repo',
58+
remote: 'origin',
59+
orgId: 'org1',
60+
projectId: 'proj1',
61+
execFn
62+
})
63+
const issues = await provider.list('')
64+
expect(issues).toHaveLength(2)
65+
expect(issues[0].kind).toBe('issue')
66+
expect(issues[1].kind).toBe('pr')
67+
})
68+
5369
it('passes state filter to gh CLI', async () => {
54-
const execFn = mockExec({ 'issue list': JSON.stringify([]) })
70+
const execFn = mockExec({ 'issue list': JSON.stringify([]), 'pr list': JSON.stringify([]) })
5571
const provider = createGitHubIssueProvider({
5672
repo: 'owner/repo',
5773
remote: 'origin',
@@ -64,7 +80,7 @@ describe('GitHubIssueProvider', () => {
6480
})
6581

6682
it('passes label filter to gh CLI', async () => {
67-
const execFn = mockExec({ 'issue list': JSON.stringify([]) })
83+
const execFn = mockExec({ 'issue list': JSON.stringify([]), 'pr list': JSON.stringify([]) })
6884
const provider = createGitHubIssueProvider({
6985
repo: 'owner/repo',
7086
remote: 'origin',
@@ -77,7 +93,7 @@ describe('GitHubIssueProvider', () => {
7793
})
7894

7995
it('passes assignee filter to gh CLI', async () => {
80-
const execFn = mockExec({ 'issue list': JSON.stringify([]) })
96+
const execFn = mockExec({ 'issue list': JSON.stringify([]), 'pr list': JSON.stringify([]) })
8197
const provider = createGitHubIssueProvider({
8298
repo: 'owner/repo',
8399
remote: 'origin',
@@ -90,7 +106,7 @@ describe('GitHubIssueProvider', () => {
90106
})
91107

92108
it('parses gh CLI JSON output into Issue objects', async () => {
93-
const execFn = mockExec({ 'issue list': JSON.stringify([sampleGhIssue]) })
109+
const execFn = mockExec({ 'issue list': JSON.stringify([sampleGhIssue]), 'pr list': JSON.stringify([]) })
94110
const provider = createGitHubIssueProvider({
95111
repo: 'owner/repo',
96112
remote: 'origin',
@@ -101,10 +117,11 @@ describe('GitHubIssueProvider', () => {
101117
const issues = await provider.list('')
102118
expect(issues[0].id).toBe('42')
103119
expect(issues[0].title).toBe('Fix bug')
120+
expect(issues[0].kind).toBe('issue')
104121
})
105122

106123
it('maps all unified issue model fields', async () => {
107-
const execFn = mockExec({ 'issue list': JSON.stringify([sampleGhIssue]) })
124+
const execFn = mockExec({ 'issue list': JSON.stringify([sampleGhIssue]), 'pr list': JSON.stringify([]) })
108125
const provider = createGitHubIssueProvider({
109126
repo: 'owner/repo',
110127
remote: 'origin',

packages/server/src/issues/github.ts

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ export function createGitHubIssueProvider(config: GitHubProviderConfig): IssuePr
2222
return stdout
2323
}
2424

25-
function mapIssue(raw: Record<string, unknown>): Issue {
25+
function mapIssue(raw: Record<string, unknown>, kind: 'issue' | 'pr' = 'issue'): Issue {
2626
return {
2727
id: String(raw.number),
28+
kind,
2829
projectId: config.projectId,
2930
orgId: config.orgId,
3031
remote: config.remote,
3132
provider: 'github',
3233
title: String(raw.title ?? ''),
3334
body: String(raw.body ?? ''),
34-
state: raw.state === 'CLOSED' ? 'closed' : 'open',
35+
state: raw.state === 'CLOSED' || raw.state === 'MERGED' ? 'closed' : 'open',
3536
labels: Array.isArray(raw.labels)
3637
? (raw.labels as Array<{ name: string }>).map((l) => (typeof l === 'string' ? l : l.name))
3738
: [],
@@ -66,38 +67,60 @@ export function createGitHubIssueProvider(config: GitHubProviderConfig): IssuePr
6667

6768
return {
6869
async list(_repoPath: string, filter?: IssueFilter): Promise<Issue[]> {
69-
const args = [
70+
// Fetch issues
71+
const issueArgs = [
7072
'issue',
7173
'list',
7274
'-R',
7375
config.repo,
7476
'--json',
7577
'number,title,body,state,labels,assignees,author,createdAt,updatedAt,url,comments'
7678
]
77-
if (filter?.state) args.push('--state', filter.state === 'closed' ? 'closed' : 'open')
78-
if (filter?.label) args.push('--label', filter.label)
79-
if (filter?.assignee) args.push('--assignee', filter.assignee)
80-
if (filter?.q) args.push('--search', filter.q)
81-
if (filter?.limit) args.push('--limit', String(filter.limit))
79+
if (filter?.state) issueArgs.push('--state', filter.state === 'closed' ? 'closed' : 'open')
80+
if (filter?.label) issueArgs.push('--label', filter.label)
81+
if (filter?.assignee) issueArgs.push('--assignee', filter.assignee)
82+
if (filter?.q) issueArgs.push('--search', filter.q)
83+
if (filter?.limit) issueArgs.push('--limit', String(filter.limit))
84+
85+
const issueOutput = await gh(issueArgs)
86+
const issues = (JSON.parse(issueOutput) as Array<Record<string, unknown>>).map((i) => mapIssue(i, 'issue'))
87+
88+
// Fetch PRs
89+
const prArgs = [
90+
'pr',
91+
'list',
92+
'-R',
93+
config.repo,
94+
'--json',
95+
'number,title,body,state,labels,assignees,author,createdAt,updatedAt,url,comments'
96+
]
97+
if (filter?.state) prArgs.push('--state', filter.state === 'closed' ? 'closed' : 'open')
98+
if (filter?.label) prArgs.push('--label', filter.label)
99+
if (filter?.assignee) prArgs.push('--assignee', filter.assignee)
100+
if (filter?.q) prArgs.push('--search', filter.q)
101+
if (filter?.limit) prArgs.push('--limit', String(filter.limit))
82102

83-
const output = await gh(args)
84-
const items = JSON.parse(output) as Array<Record<string, unknown>>
85-
return items.map((i) => mapIssue(i))
103+
const prOutput = await gh(prArgs)
104+
const prs = (JSON.parse(prOutput) as Array<Record<string, unknown>>).map((i) => mapIssue(i, 'pr'))
105+
106+
return [...issues, ...prs]
86107
},
87108

88109
async get(_repoPath: string, issueId: string): Promise<Issue | undefined> {
110+
const fields = 'number,title,body,state,labels,assignees,author,createdAt,updatedAt,url,comments'
111+
// Try as issue first
112+
try {
113+
const output = await gh(['issue', 'view', issueId, '-R', config.repo, '--json', fields])
114+
const raw = JSON.parse(output) as Record<string, unknown>
115+
return mapIssue(raw, 'issue')
116+
} catch {
117+
// Fall through to try as PR
118+
}
119+
// Try as PR
89120
try {
90-
const output = await gh([
91-
'issue',
92-
'view',
93-
issueId,
94-
'-R',
95-
config.repo,
96-
'--json',
97-
'number,title,body,state,labels,assignees,author,createdAt,updatedAt,url,comments'
98-
])
121+
const output = await gh(['pr', 'view', issueId, '-R', config.repo, '--json', fields])
99122
const raw = JSON.parse(output) as Record<string, unknown>
100-
return mapIssue(raw)
123+
return mapIssue(raw, 'pr')
101124
} catch {
102125
return undefined
103126
}
@@ -115,7 +138,7 @@ export function createGitHubIssueProvider(config: GitHubProviderConfig): IssuePr
115138

116139
const output = await gh(args)
117140
const raw = JSON.parse(output) as Record<string, unknown>
118-
return mapIssue(raw)
141+
return mapIssue(raw, 'issue')
119142
},
120143

121144
async update(

packages/server/src/issues/issues.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { EventBus, BusEvent, BusHandler, Unsubscribe } from '@sovereign/cor
66

77
const sampleIssue: Issue = {
88
id: '1',
9+
kind: 'issue',
910
projectId: 'proj1',
1011
orgId: 'org1',
1112
remote: 'origin',

packages/server/src/issues/radicle.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ export function createRadicleIssueProvider(config: RadicleProviderConfig): Issue
4242
}
4343
}
4444

45-
function mapIssue(raw: Record<string, unknown>): Issue {
45+
function mapIssue(raw: Record<string, unknown>, kind: 'issue' | 'pr' = 'issue'): Issue {
4646
return {
4747
id: String(raw.id ?? ''),
48+
kind,
4849
projectId: config.projectId,
4950
orgId: config.orgId,
5051
remote: config.remote,
@@ -137,6 +138,7 @@ export function createRadicleIssueProvider(config: RadicleProviderConfig): Issue
137138
return (
138139
issue ?? {
139140
id,
141+
kind: 'issue',
140142
projectId: config.projectId,
141143
orgId: config.orgId,
142144
remote: config.remote,

packages/server/src/issues/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
export interface Issue {
44
id: string
5+
kind: 'issue' | 'pr'
56
projectId: string
67
orgId: string
78
remote: string

packages/server/src/planning/planning.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ function makeBus() {
1414

1515
function makeIssue(overrides: Partial<Issue> & { id: string }): Issue {
1616
return {
17+
kind: 'issue',
1718
projectId: 'proj',
1819
orgId: 'org1',
1920
remote: 'github',

0 commit comments

Comments
 (0)