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