@@ -5,7 +5,13 @@ vi.mock('child_process', () => ({
55} ) )
66
77import { 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
1016const 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+ } )
0 commit comments