ghPrStatus.ts 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import { execFileNoThrow } from './execFileNoThrow.js'
  2. import { getBranch, getDefaultBranch, getIsGit } from './git.js'
  3. import { jsonParse } from './slowOperations.js'
  4. export type PrReviewState =
  5. | 'approved'
  6. | 'pending'
  7. | 'changes_requested'
  8. | 'draft'
  9. | 'merged'
  10. | 'closed'
  11. export type PrStatus = {
  12. number: number
  13. url: string
  14. reviewState: PrReviewState
  15. }
  16. const GH_TIMEOUT_MS = 5000
  17. /**
  18. * Derive review state from GitHub API values.
  19. * Draft PRs always show as 'draft' regardless of reviewDecision.
  20. * reviewDecision can be: APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, or empty string.
  21. */
  22. export function deriveReviewState(
  23. isDraft: boolean,
  24. reviewDecision: string,
  25. ): PrReviewState {
  26. if (isDraft) return 'draft'
  27. switch (reviewDecision) {
  28. case 'APPROVED':
  29. return 'approved'
  30. case 'CHANGES_REQUESTED':
  31. return 'changes_requested'
  32. default:
  33. return 'pending'
  34. }
  35. }
  36. /**
  37. * Fetch PR status for the current branch using `gh pr view`.
  38. * Returns null on any failure (gh not installed, no PR, not in git repo, etc).
  39. * Also returns null if the PR's head branch is the default branch (e.g., main/master).
  40. */
  41. export async function fetchPrStatus(): Promise<PrStatus | null> {
  42. const isGit = await getIsGit()
  43. if (!isGit) return null
  44. // Skip on the default branch — `gh pr view` returns the most recently
  45. // merged PR there, which is misleading.
  46. const [branch, defaultBranch] = await Promise.all([
  47. getBranch(),
  48. getDefaultBranch(),
  49. ])
  50. if (branch === defaultBranch) return null
  51. const { stdout, code } = await execFileNoThrow(
  52. 'gh',
  53. [
  54. 'pr',
  55. 'view',
  56. '--json',
  57. 'number,url,reviewDecision,isDraft,headRefName,state',
  58. ],
  59. { timeout: GH_TIMEOUT_MS, preserveOutputOnError: false },
  60. )
  61. if (code !== 0 || !stdout.trim()) return null
  62. try {
  63. const data = jsonParse(stdout) as {
  64. number: number
  65. url: string
  66. reviewDecision: string
  67. isDraft: boolean
  68. headRefName: string
  69. state: string
  70. }
  71. // Don't show PR status for PRs from the default branch (e.g., main, master)
  72. // This can happen when someone opens a PR from main to another branch
  73. if (
  74. data.headRefName === defaultBranch ||
  75. data.headRefName === 'main' ||
  76. data.headRefName === 'master'
  77. ) {
  78. return null
  79. }
  80. // Don't show PR status for merged or closed PRs — `gh pr view` returns
  81. // the most recently associated PR for a branch, which may be merged/closed.
  82. // The status line should only display open PRs.
  83. if (data.state === 'MERGED' || data.state === 'CLOSED') {
  84. return null
  85. }
  86. return {
  87. number: data.number,
  88. url: data.url,
  89. reviewState: deriveReviewState(data.isDraft, data.reviewDecision),
  90. }
  91. } catch {
  92. return null
  93. }
  94. }