Skip to content

Commit 23d64a7

Browse files
authored
Merge pull request #45 from kranklab/steady-cove
Add PR drawer with details, comments, builds, and log viewer
2 parents 439f981 + 55e9cc5 commit 23d64a7

13 files changed

Lines changed: 2585 additions & 99 deletions

File tree

package-lock.json

Lines changed: 1511 additions & 62 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
"chokidar": "^4.0.3",
3131
"electron-updater": "^6.8.3",
3232
"highlight.js": "^11.11.1",
33-
"node-pty": "^1.0.0"
33+
"node-pty": "^1.0.0",
34+
"react-markdown": "^10.1.0",
35+
"remark-gfm": "^4.0.1"
3436
},
3537
"devDependencies": {
3638
"@electron-toolkit/eslint-config-prettier": "^3.0.0",

src/main/__tests__/github.test.ts

Lines changed: 272 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ vi.mock('child_process', () => ({
55
}))
66

77
import { execFile } from 'child_process'
8-
import { getGitHubRepo, listPullRequests, listIssues } from '../github'
8+
import {
9+
getGitHubRepo,
10+
listPullRequests,
11+
listIssues,
12+
getPrDetail,
13+
getCheckRunLogs
14+
} from '../github'
915

1016
const mockedExecFile = vi.mocked(execFile)
1117

@@ -226,3 +232,268 @@ describe('listIssues', () => {
226232
expect(issues).toEqual([])
227233
})
228234
})
235+
236+
// ─── getPrDetail ─────────────────────────────────────────────────────
237+
238+
describe('getPrDetail', () => {
239+
it('parses full PR detail with comments, reviews, and checks', async () => {
240+
// 1st call: getGitHubRepo
241+
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
242+
// 2nd call: gh pr view
243+
mockExecFileOnce(
244+
null,
245+
JSON.stringify({
246+
number: 42,
247+
title: 'Add feature',
248+
state: 'OPEN',
249+
author: { login: 'alice' },
250+
body: '## Summary\nThis adds a feature.',
251+
headRefName: 'feat/new',
252+
baseRefName: 'main',
253+
additions: 100,
254+
deletions: 20,
255+
commits: { totalCount: 3 },
256+
labels: [{ name: 'enhancement' }],
257+
statusCheckRollup: [
258+
{
259+
name: 'build',
260+
status: 'COMPLETED',
261+
conclusion: 'SUCCESS',
262+
detailsUrl: 'https://github.com/owner/repo/actions/runs/123/job/456'
263+
},
264+
{
265+
name: 'test',
266+
status: 'COMPLETED',
267+
conclusion: 'FAILURE',
268+
detailsUrl: 'https://github.com/owner/repo/actions/runs/123/job/789'
269+
}
270+
],
271+
url: 'https://github.com/owner/repo/pull/42',
272+
createdAt: '2025-01-01T00:00:00Z',
273+
updatedAt: '2025-01-02T00:00:00Z',
274+
comments: [
275+
{ author: { login: 'bob' }, body: 'Looks good!', createdAt: '2025-01-01T12:00:00Z' }
276+
],
277+
reviews: [
278+
{ author: { login: 'charlie' }, body: 'LGTM', submittedAt: '2025-01-01T13:00:00Z' },
279+
{ author: { login: 'dave' }, body: '', createdAt: '2025-01-01T14:00:00Z' }
280+
]
281+
})
282+
)
283+
284+
const detail = await getPrDetail('/tmp', 42)
285+
286+
expect(detail).not.toBeNull()
287+
expect(detail!.number).toBe(42)
288+
expect(detail!.title).toBe('Add feature')
289+
expect(detail!.state).toBe('open')
290+
expect(detail!.author).toBe('alice')
291+
expect(detail!.body).toBe('## Summary\nThis adds a feature.')
292+
expect(detail!.branch).toBe('feat/new')
293+
expect(detail!.baseBranch).toBe('main')
294+
expect(detail!.additions).toBe(100)
295+
expect(detail!.deletions).toBe(20)
296+
expect(detail!.commits).toBe(3)
297+
expect(detail!.labels).toEqual(['enhancement'])
298+
299+
// Comments: 2 total (1 issue comment + 1 review with body, review without body is skipped)
300+
expect(detail!.comments).toHaveLength(2)
301+
expect(detail!.comments[0].author).toBe('bob')
302+
expect(detail!.comments[1].author).toBe('charlie')
303+
304+
// Checks
305+
expect(detail!.checks).toHaveLength(2)
306+
expect(detail!.checks[0]).toEqual({
307+
name: 'build',
308+
status: 'completed',
309+
conclusion: 'success',
310+
url: 'https://github.com/owner/repo/actions/runs/123/job/456'
311+
})
312+
expect(detail!.checks[1].conclusion).toBe('failure')
313+
})
314+
315+
it('returns null when not a GitHub repo', async () => {
316+
mockExecFileOnce(null, 'https://gitlab.com/foo/bar.git\n')
317+
318+
const detail = await getPrDetail('/tmp', 1)
319+
320+
expect(detail).toBeNull()
321+
})
322+
323+
it('returns null when gh command fails', async () => {
324+
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
325+
mockExecFileOnce(new Error('not found'), '')
326+
327+
const detail = await getPrDetail('/tmp', 999)
328+
329+
expect(detail).toBeNull()
330+
})
331+
332+
it('returns null on malformed JSON', async () => {
333+
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
334+
mockExecFileOnce(null, 'not json{{{')
335+
336+
const detail = await getPrDetail('/tmp', 1)
337+
338+
expect(detail).toBeNull()
339+
})
340+
341+
it('handles merged state', async () => {
342+
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
343+
mockExecFileOnce(
344+
null,
345+
JSON.stringify({
346+
number: 10,
347+
state: 'MERGED',
348+
author: { login: 'alice' },
349+
comments: [],
350+
reviews: [],
351+
statusCheckRollup: []
352+
})
353+
)
354+
355+
const detail = await getPrDetail('/tmp', 10)
356+
357+
expect(detail!.state).toBe('merged')
358+
})
359+
360+
it('handles missing fields gracefully', async () => {
361+
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
362+
mockExecFileOnce(
363+
null,
364+
JSON.stringify({
365+
number: 1,
366+
state: 'OPEN',
367+
author: null,
368+
body: null,
369+
comments: null,
370+
reviews: null,
371+
statusCheckRollup: null,
372+
labels: null,
373+
commits: null
374+
})
375+
)
376+
377+
const detail = await getPrDetail('/tmp', 1)
378+
379+
expect(detail).not.toBeNull()
380+
expect(detail!.author).toBe('')
381+
expect(detail!.body).toBe('')
382+
expect(detail!.comments).toEqual([])
383+
expect(detail!.checks).toEqual([])
384+
expect(detail!.labels).toEqual([])
385+
expect(detail!.commits).toBe(0)
386+
})
387+
388+
it('sorts comments chronologically', async () => {
389+
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
390+
mockExecFileOnce(
391+
null,
392+
JSON.stringify({
393+
number: 1,
394+
state: 'OPEN',
395+
author: { login: 'x' },
396+
comments: [
397+
{ author: { login: 'late' }, body: 'second', createdAt: '2025-01-02T00:00:00Z' },
398+
{ author: { login: 'early' }, body: 'first', createdAt: '2025-01-01T00:00:00Z' }
399+
],
400+
reviews: [],
401+
statusCheckRollup: []
402+
})
403+
)
404+
405+
const detail = await getPrDetail('/tmp', 1)
406+
407+
expect(detail!.comments[0].author).toBe('early')
408+
expect(detail!.comments[1].author).toBe('late')
409+
})
410+
})
411+
412+
// ─── getCheckRunLogs ─────────────────────────────────────────────────
413+
414+
describe('getCheckRunLogs', () => {
415+
it('fetches logs using run ID from actions URL', async () => {
416+
// 1st call: getGitHubRepo
417+
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
418+
// 2nd call: gh run view --log-failed
419+
mockExecFileOnce(null, 'Error: test failed\nassert false')
420+
421+
const logs = await getCheckRunLogs(
422+
'/tmp',
423+
'https://github.com/owner/repo/actions/runs/12345/job/67890'
424+
)
425+
426+
expect(logs).toBe('Error: test failed\nassert false')
427+
expect(mockedExecFile).toHaveBeenCalledWith(
428+
'gh',
429+
['run', 'view', '12345', '--repo', 'owner/repo', '--log-failed'],
430+
expect.any(Object),
431+
expect.any(Function)
432+
)
433+
})
434+
435+
it('falls back to --log when --log-failed fails', async () => {
436+
// 1st call: getGitHubRepo
437+
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
438+
// 2nd call: gh run view --log-failed fails
439+
mockExecFileOnce(new Error('no failed jobs'), '')
440+
// 3rd call: gh run view --log
441+
mockExecFileOnce(null, 'full log output here')
442+
443+
const logs = await getCheckRunLogs(
444+
'/tmp',
445+
'https://github.com/owner/repo/actions/runs/999/job/111'
446+
)
447+
448+
expect(logs).toBe('full log output here')
449+
})
450+
451+
it('returns error message when run ID cannot be extracted', async () => {
452+
const logs = await getCheckRunLogs('/tmp', 'https://example.com/something-else')
453+
454+
expect(logs).toContain('Could not extract run ID')
455+
})
456+
457+
it('returns error when not a GitHub repo', async () => {
458+
mockExecFileOnce(null, 'https://gitlab.com/foo/bar.git\n')
459+
460+
const logs = await getCheckRunLogs(
461+
'/tmp',
462+
'https://github.com/owner/repo/actions/runs/123/job/456'
463+
)
464+
465+
expect(logs).toContain('Could not determine GitHub repository')
466+
})
467+
468+
it('truncates very long logs', async () => {
469+
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
470+
// Generate 600 lines of output
471+
const longOutput = Array.from({ length: 600 }, (_, i) => `line ${i}`).join('\n')
472+
mockExecFileOnce(null, longOutput)
473+
474+
const logs = await getCheckRunLogs(
475+
'/tmp',
476+
'https://github.com/owner/repo/actions/runs/123/job/456'
477+
)
478+
479+
expect(logs).toContain('truncated')
480+
// Should contain the last 500 lines
481+
expect(logs).toContain('line 599')
482+
expect(logs).not.toContain('line 0\n')
483+
})
484+
485+
it('parses legacy /runs/ URL format', async () => {
486+
mockExecFileOnce(null, 'git@github.com:owner/repo.git\n')
487+
mockExecFileOnce(null, 'some logs')
488+
489+
const logs = await getCheckRunLogs('/tmp', 'https://github.com/owner/repo/runs/54321')
490+
491+
expect(logs).toBe('some logs')
492+
expect(mockedExecFile).toHaveBeenCalledWith(
493+
'gh',
494+
['run', 'view', '54321', '--repo', 'owner/repo', '--log-failed'],
495+
expect.any(Object),
496+
expect.any(Function)
497+
)
498+
})
499+
})

src/main/__tests__/worktree.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {
1515
listBranches,
1616
getBranchFiles,
1717
batchGetPrStatuses,
18-
getPrForBranch
18+
getPrForBranch,
19+
getCurrentBranch
1920
} from '../worktree'
2021

2122
const mockedExecFile = vi.mocked(execFile)
@@ -414,3 +415,29 @@ describe('getPrForBranch', () => {
414415
)
415416
})
416417
})
418+
419+
// ─── getCurrentBranch ───────────────────────────────────────────────
420+
421+
describe('getCurrentBranch', () => {
422+
it('returns the current branch name', async () => {
423+
mockExecFileOnce(null, 'feature-x\n')
424+
425+
const branch = await getCurrentBranch('/repo')
426+
427+
expect(branch).toBe('feature-x')
428+
expect(mockedExecFile).toHaveBeenCalledWith(
429+
'git',
430+
['rev-parse', '--abbrev-ref', 'HEAD'],
431+
expect.any(Object),
432+
expect.any(Function)
433+
)
434+
})
435+
436+
it('returns empty string on error', async () => {
437+
mockExecFileOnce(new Error('not a git repo'), '')
438+
439+
const branch = await getCurrentBranch('/not-a-repo')
440+
441+
expect(branch).toBe('')
442+
})
443+
})

0 commit comments

Comments
 (0)