OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / commands / issue.js

191 lines · javascript ref:
/**
 * pgh issue — repo and org issue tracker.
 *
 * Repo scope (positional):
 *   pgh issue <repo>                           list
 *   pgh issue <repo> <id>                      show
 *   pgh issue <repo> --create "title" [--body "..."]
 *   pgh issue <repo> <id> --close|--reopen|--delete
 *   pgh issue <repo> <id> --comment "text"
 *   pgh issue <repo> <id> --edit-title "..."
 *   pgh issue <repo> <id> --edit-body "..."
 *   pgh issue <repo> --counts
 *
 * Org scope (--org flag):
 *   pgh issue --org <name>                     list
 *   pgh issue --org <name> <id>                show
 *   pgh issue --org <name> --create "title" [--body "..."]
 *   pgh issue --org <name> <id> --close|--reopen|--delete
 *   pgh issue --org <name> <id> --comment "text"
 *   pgh issue --org <name> <id> --edit-title "..."
 *   pgh issue --org <name> <id> --edit-body "..."
 *   pgh issue --org <name> --counts
 *
 * Notes:
 *   --delete is a soft delete: sets status='deleted' on the issue. The
 *   record stays in the autobase view (hard-delete would require new
 *   apply ops). Default `list` excludes deleted; pass --status deleted
 *   to see them.
 */

export const usage = `pgh issue — repo and org issue tracker

Repo scope (positional):
  pgh issue <repo>                         List issues
  pgh issue <repo> <id>                    Show one issue
  pgh issue <repo> --create "title" [--body "..."]
  pgh issue <repo> <id> --close|--reopen|--delete
  pgh issue <repo> <id> --comment "text"
  pgh issue <repo> <id> --edit-title "..." [--edit-body "..."]
  pgh issue <repo> --counts
  pgh issue <repo> --status open|closed|deleted|all

Org scope (--org flag, same actions):
  pgh issue --org <name>                   List issues
  pgh issue --org <name> --create "title" [--body "..."]
  pgh issue --org <name> <id> --close|--comment "text"|...

Note: the action is a flag (--create), not a subcommand — the first
positional is the <repo> (or use --org <name>).`

// Action verbs people reach for by habit (git-issue style). They are flags
// here, not subcommands — if one shows up in the <repo> slot the user almost
// certainly mistyped, so surface usage instead of listing a repo by that name.
const MISTAKEN_VERBS = new Set([
  'create', 'close', 'reopen', 'delete', 'comment', 'edit', 'view', 'show'
])

function flagValue (args, name) {
  const idx = args.indexOf(name)
  if (idx === -1) return undefined
  return args[idx + 1]
}

function flagPresent (args, name) {
  return args.includes(name)
}

function parseScope (args) {
  // --org <name> wins over a positional repo arg.
  const orgIdx = args.indexOf('--org')
  if (orgIdx !== -1) {
    const orgName = args[orgIdx + 1]
    if (!orgName || orgName.startsWith('--')) {
      throw new Error('--org requires an org name')
    }
    // Strip --org <name> from args so positional id parsing still works.
    const rest = [...args.slice(0, orgIdx), ...args.slice(orgIdx + 2)]
    return { scope: { org: orgName }, rest }
  }
  const repo = args[0]
  if (!repo || repo.startsWith('--')) {
    throw new Error(
      'usage: pgh issue <repo> [...] | pgh issue --org <name> [...]'
    )
  }
  if (MISTAKEN_VERBS.has(repo)) {
    throw new Error(`'${repo}' is an action, not a repo.\n` + usage)
  }
  return { scope: { repo }, rest: args.slice(1) }
}

function parseId (rest) {
  // First positional arg in rest (if any) is the issue id. Allow either
  // a numeric string or a hash-prefixed form like "#5".
  const raw = rest[0]
  if (raw === undefined || raw.startsWith('--')) return null
  const cleaned = raw.startsWith('#') ? raw.slice(1) : raw
  const n = Number(cleaned)
  if (!Number.isInteger(n) || n <= 0) {
    throw new Error(`expected positive integer issue id, got '${raw}'`)
  }
  return n
}

export async function run (client, args) {
  let scope, rest
  try {
    ({ scope, rest } = parseScope(args))
  } catch (err) {
    throw err
  }

  // --counts is scope-only; no id required.
  if (flagPresent(rest, '--counts')) {
    return client.call('issue-counts', { ...scope })
  }

  // --create needs a title; no id; body is optional.
  if (flagPresent(rest, '--create')) {
    const title = flagValue(rest, '--create')
    if (!title || title.startsWith('--')) {
      throw new Error('--create requires a title')
    }
    const body = flagValue(rest, '--body')
    return client.call('create-issue', { ...scope, title, body })
  }

  const id = parseId(rest)

  // Mutating actions all need an id.
  const needId = (flag) => {
    if (id === null) throw new Error(`${flag} requires an issue id`)
  }

  if (flagPresent(rest, '--close')) {
    needId('--close')
    return client.call('update-issue', { ...scope, id, status: 'closed' })
  }
  if (flagPresent(rest, '--reopen')) {
    needId('--reopen')
    return client.call('update-issue', { ...scope, id, status: 'open' })
  }
  if (flagPresent(rest, '--delete')) {
    needId('--delete')
    return client.call('update-issue', { ...scope, id, status: 'deleted' })
  }

  if (flagPresent(rest, '--comment')) {
    needId('--comment')
    const text = flagValue(rest, '--comment')
    if (!text) throw new Error('--comment requires text')
    // The handler expects { body }, the v1 surface used { text }; accept
    // either at the wire so existing scripts keep working.
    return client.call('comment-issue', { ...scope, id, body: text })
  }

  // --edit-title and --edit-body can be combined in a single update.
  const newTitle = flagValue(rest, '--edit-title')
  const newBody = flagValue(rest, '--edit-body')
  if (newTitle !== undefined || newBody !== undefined) {
    needId('--edit-title/--edit-body')
    const params = { ...scope, id }
    if (newTitle !== undefined) {
      if (!newTitle || newTitle.startsWith('--')) {
        throw new Error('--edit-title requires a non-empty value')
      }
      params.title = newTitle
    }
    if (newBody !== undefined) {
      params.body = newBody
    }
    return client.call('update-issue', params)
  }

  // No flags + id ⇒ show.
  if (id !== null) return client.call('get-issue', { ...scope, id })

  // No flags, no id ⇒ list. By default hide soft-deleted issues; pass
  // --status deleted (or --status all) to override. The handler accepts
  // a single status string filter — for everything-except-deleted we
  // request all and filter client-side.
  const status = flagValue(rest, '--status')
  const params = { ...scope }
  if (status) params.status = status
  const result = await client.call('list-issues', params)
  if (!status) {
    result.issues = (result.issues || []).filter(i => i.status !== 'deleted')
  }
  return result
}