Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,573 changes: 1,511 additions & 62 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
"chokidar": "^4.0.3",
"electron-updater": "^6.8.3",
"highlight.js": "^11.11.1",
"node-pty": "^1.0.0"
"node-pty": "^1.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
Expand Down
273 changes: 272 additions & 1 deletion src/main/__tests__/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ vi.mock('child_process', () => ({
}))

import { execFile } from 'child_process'
import { getGitHubRepo, listPullRequests, listIssues } from '../github'
import {
getGitHubRepo,
listPullRequests,
listIssues,
getPrDetail,
getCheckRunLogs
} from '../github'

const mockedExecFile = vi.mocked(execFile)

Expand Down Expand Up @@ -226,3 +232,268 @@ describe('listIssues', () => {
expect(issues).toEqual([])
})
})

// ─── getPrDetail ─────────────────────────────────────────────────────

describe('getPrDetail', () => {
it('parses full PR detail with comments, reviews, and checks', async () => {
// 1st call: getGitHubRepo
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
// 2nd call: gh pr view
mockExecFileOnce(
null,
JSON.stringify({
number: 42,
title: 'Add feature',
state: 'OPEN',
author: { login: 'alice' },
body: '## Summary\nThis adds a feature.',
headRefName: 'feat/new',
baseRefName: 'main',
additions: 100,
deletions: 20,
commits: { totalCount: 3 },
labels: [{ name: 'enhancement' }],
statusCheckRollup: [
{
name: 'build',
status: 'COMPLETED',
conclusion: 'SUCCESS',
detailsUrl: 'https://github.com/owner/repo/actions/runs/123/job/456'
},
{
name: 'test',
status: 'COMPLETED',
conclusion: 'FAILURE',
detailsUrl: 'https://github.com/owner/repo/actions/runs/123/job/789'
}
],
url: 'https://github.com/owner/repo/pull/42',
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-02T00:00:00Z',
comments: [
{ author: { login: 'bob' }, body: 'Looks good!', createdAt: '2025-01-01T12:00:00Z' }
],
reviews: [
{ author: { login: 'charlie' }, body: 'LGTM', submittedAt: '2025-01-01T13:00:00Z' },
{ author: { login: 'dave' }, body: '', createdAt: '2025-01-01T14:00:00Z' }
]
})
)

const detail = await getPrDetail('/tmp', 42)

expect(detail).not.toBeNull()
expect(detail!.number).toBe(42)
expect(detail!.title).toBe('Add feature')
expect(detail!.state).toBe('open')
expect(detail!.author).toBe('alice')
expect(detail!.body).toBe('## Summary\nThis adds a feature.')
expect(detail!.branch).toBe('feat/new')
expect(detail!.baseBranch).toBe('main')
expect(detail!.additions).toBe(100)
expect(detail!.deletions).toBe(20)
expect(detail!.commits).toBe(3)
expect(detail!.labels).toEqual(['enhancement'])

// Comments: 2 total (1 issue comment + 1 review with body, review without body is skipped)
expect(detail!.comments).toHaveLength(2)
expect(detail!.comments[0].author).toBe('bob')
expect(detail!.comments[1].author).toBe('charlie')

// Checks
expect(detail!.checks).toHaveLength(2)
expect(detail!.checks[0]).toEqual({
name: 'build',
status: 'completed',
conclusion: 'success',
url: 'https://github.com/owner/repo/actions/runs/123/job/456'
})
expect(detail!.checks[1].conclusion).toBe('failure')
})

it('returns null when not a GitHub repo', async () => {
mockExecFileOnce(null, 'https://gitlab.com/foo/bar.git\n')

const detail = await getPrDetail('/tmp', 1)

expect(detail).toBeNull()
})

it('returns null when gh command fails', async () => {
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
mockExecFileOnce(new Error('not found'), '')

const detail = await getPrDetail('/tmp', 999)

expect(detail).toBeNull()
})

it('returns null on malformed JSON', async () => {
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
mockExecFileOnce(null, 'not json{{{')

const detail = await getPrDetail('/tmp', 1)

expect(detail).toBeNull()
})

it('handles merged state', async () => {
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
mockExecFileOnce(
null,
JSON.stringify({
number: 10,
state: 'MERGED',
author: { login: 'alice' },
comments: [],
reviews: [],
statusCheckRollup: []
})
)

const detail = await getPrDetail('/tmp', 10)

expect(detail!.state).toBe('merged')
})

it('handles missing fields gracefully', async () => {
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
mockExecFileOnce(
null,
JSON.stringify({
number: 1,
state: 'OPEN',
author: null,
body: null,
comments: null,
reviews: null,
statusCheckRollup: null,
labels: null,
commits: null
})
)

const detail = await getPrDetail('/tmp', 1)

expect(detail).not.toBeNull()
expect(detail!.author).toBe('')
expect(detail!.body).toBe('')
expect(detail!.comments).toEqual([])
expect(detail!.checks).toEqual([])
expect(detail!.labels).toEqual([])
expect(detail!.commits).toBe(0)
})

it('sorts comments chronologically', async () => {
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
mockExecFileOnce(
null,
JSON.stringify({
number: 1,
state: 'OPEN',
author: { login: 'x' },
comments: [
{ author: { login: 'late' }, body: 'second', createdAt: '2025-01-02T00:00:00Z' },
{ author: { login: 'early' }, body: 'first', createdAt: '2025-01-01T00:00:00Z' }
],
reviews: [],
statusCheckRollup: []
})
)

const detail = await getPrDetail('/tmp', 1)

expect(detail!.comments[0].author).toBe('early')
expect(detail!.comments[1].author).toBe('late')
})
})

// ─── getCheckRunLogs ─────────────────────────────────────────────────

describe('getCheckRunLogs', () => {
it('fetches logs using run ID from actions URL', async () => {
// 1st call: getGitHubRepo
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
// 2nd call: gh run view --log-failed
mockExecFileOnce(null, 'Error: test failed\nassert false')

const logs = await getCheckRunLogs(
'/tmp',
'https://github.com/owner/repo/actions/runs/12345/job/67890'
)

expect(logs).toBe('Error: test failed\nassert false')
expect(mockedExecFile).toHaveBeenCalledWith(
'gh',
['run', 'view', '12345', '--repo', 'owner/repo', '--log-failed'],
expect.any(Object),
expect.any(Function)
)
})

it('falls back to --log when --log-failed fails', async () => {
// 1st call: getGitHubRepo
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
// 2nd call: gh run view --log-failed fails
mockExecFileOnce(new Error('no failed jobs'), '')
// 3rd call: gh run view --log
mockExecFileOnce(null, 'full log output here')

const logs = await getCheckRunLogs(
'/tmp',
'https://github.com/owner/repo/actions/runs/999/job/111'
)

expect(logs).toBe('full log output here')
})

it('returns error message when run ID cannot be extracted', async () => {
const logs = await getCheckRunLogs('/tmp', 'https://example.com/something-else')

expect(logs).toContain('Could not extract run ID')
})

it('returns error when not a GitHub repo', async () => {
mockExecFileOnce(null, 'https://gitlab.com/foo/bar.git\n')

const logs = await getCheckRunLogs(
'/tmp',
'https://github.com/owner/repo/actions/runs/123/job/456'
)

expect(logs).toContain('Could not determine GitHub repository')
})

it('truncates very long logs', async () => {
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
// Generate 600 lines of output
const longOutput = Array.from({ length: 600 }, (_, i) => `line ${i}`).join('\n')
mockExecFileOnce(null, longOutput)

const logs = await getCheckRunLogs(
'/tmp',
'https://github.com/owner/repo/actions/runs/123/job/456'
)

expect(logs).toContain('truncated')
// Should contain the last 500 lines
expect(logs).toContain('line 599')
expect(logs).not.toContain('line 0\n')
})

it('parses legacy /runs/ URL format', async () => {
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
mockExecFileOnce(null, 'some logs')

const logs = await getCheckRunLogs('/tmp', 'https://github.com/owner/repo/runs/54321')

expect(logs).toBe('some logs')
expect(mockedExecFile).toHaveBeenCalledWith(
'gh',
['run', 'view', '54321', '--repo', 'owner/repo', '--log-failed'],
expect.any(Object),
expect.any(Function)
)
})
})
29 changes: 28 additions & 1 deletion src/main/__tests__/worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
listBranches,
getBranchFiles,
batchGetPrStatuses,
getPrForBranch
getPrForBranch,
getCurrentBranch
} from '../worktree'

const mockedExecFile = vi.mocked(execFile)
Expand Down Expand Up @@ -414,3 +415,29 @@ describe('getPrForBranch', () => {
)
})
})

// ─── getCurrentBranch ───────────────────────────────────────────────

describe('getCurrentBranch', () => {
it('returns the current branch name', async () => {
mockExecFileOnce(null, 'feature-x\n')

const branch = await getCurrentBranch('/repo')

expect(branch).toBe('feature-x')
expect(mockedExecFile).toHaveBeenCalledWith(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
expect.any(Object),
expect.any(Function)
)
})

it('returns empty string on error', async () => {
mockExecFileOnce(new Error('not a git repo'), '')

const branch = await getCurrentBranch('/not-a-repo')

expect(branch).toBe('')
})
})
Loading
Loading