All files / packages/icons/.claude/skills/icons-release/scripts create-pr.js

0% Statements 0/54
0% Branches 0/42
0% Functions 0/5
0% Lines 0/47

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112                                                                                                                                                                                                                               
#!/usr/bin/env node
'use strict'
 
/**
 * create-pr
 *
 * Creates the release branch, commits the icon changes, pushes, and opens a
 * PR (release notes become the PR body). It makes NO recovery decisions: any
 * git/gh failure stops the pipeline and surfaces the error verbatim. It never
 * rebases, force-pushes, or retries.
 *
 * --branch is REQUIRED (no auto-naming) so the human always confirms it.
 *
 * Usage:
 *   node .claude/skills/icons-release/scripts/create-pr.js --release R264 --branch release/10.17.0 [--base develop] [--dry-run]
 *
 * Output: { success, step, data: { branch, base, title, prUrl } }
 */
 
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
 
const { succeed, failWith, run, log } = require('./lib/output')
const { parseArgs } = require('./lib/args')
const { loadWorkset } = require('./lib/workset')
 
const STEP = 'create-pr'
 
// Commands are [executable, ...args] and run without a shell, so user-provided
// values (branch, base) are passed to git/gh literally and never shell-parsed.
function formatCmd([file, ...args]) {
  return [file, ...args.map(a => (/^[\w./@:=-]+$/.test(a) ? a : JSON.stringify(a)))].join(' ')
}
 
function exec([file, ...args]) {
  const result = spawnSync(file, args, { encoding: 'utf8' })
  if (result.error) log(result.error.message)
  return { status: result.status === null ? 1 : result.status, stdout: (result.stdout || '').trim(), stderr: (result.stderr || '').trim() }
}
 
function runCmd(command, dryRun) {
  log(`${dryRun ? '[dry-run] ' : ''}> ${formatCmd(command)}`)
  if (dryRun) return { status: 0, stdout: '' }
  const result = exec(command)
  if (result.stdout) log(result.stdout)
  if (result.stderr) log(result.stderr)
  return result
}
 
function main() {
  const args = parseArgs(process.argv.slice(2))
  const dryRun = !!args['dry-run']
 
  if (!args.release) failWith(STEP, 'missingArg', '--release is required (e.g. --release R264)')
  if (!args.branch) {
    failWith(STEP, 'missingBranch', '--branch is required (no auto-naming; confirm with the user, e.g. release/10.17.0)')
  }
  const base = String(args.base || 'develop')
  const branch = String(args.branch)
  for (const [name, value] of [['branch', branch], ['base', base]]) {
    if (exec(['git', 'check-ref-format', '--branch', value]).status !== 0 || value.startsWith('-')) {
      failWith(STEP, 'invalidBranch', `--${name} "${value}" is not a valid git branch name.`)
    }
  }
 
  // `git add svg model/metadata` doesn't drop entries that are already staged,
  // so anything staged beforehand would ride along in the release commit.
  const staged = exec(['git', 'diff', '--cached', '--name-only'])
  if (staged.status !== 0) failWith(STEP, 'commandFailed', `"git diff --cached" exited with code ${staged.status}`)
  if (staged.stdout) {
    failWith(
      STEP,
      'stagedChanges',
      `Unrelated changes are already staged. Unstage them before creating the release PR:\n${staged.stdout}`
    )
  }
 
  const { release, location, icons } = loadWorkset(args.release)
 
  const releaseNotesPath = path.join(location, 'releasenotes.md')
  if (!fs.existsSync(releaseNotesPath)) {
    failWith(STEP, 'noReleaseNotes', `${releaseNotesPath} not found. Run copy-files first.`)
  }
 
  const title = `Release ${release} icons: ${icons.length} added`
 
  const commands = [
    ['git', 'checkout', '-b', branch],
    ['git', 'add', 'svg', 'model/metadata'],
    ['git', 'commit', '-m', title],
    ['git', 'push', '-u', 'origin', branch],
    ['gh', 'pr', 'create', '--base', base, '--head', branch, '--title', title, '--body-file', releaseNotesPath]
  ]
 
  let prUrl = null
  for (const command of commands) {
    const result = runCmd(command, dryRun)
    if (result.status !== 0) {
      failWith(STEP, 'commandFailed', `"${formatCmd(command)}" exited with code ${result.status}`)
    }
    if (command[0] === 'gh' && result.stdout) {
      const match = result.stdout.match(/https?:\/\/\S+/)
      prUrl = match ? match[0] : result.stdout
    }
  }
 
  succeed(STEP, { release, branch, base, title, prUrl, dryRun })
}
 
run(STEP, main)