Skip to content

Design: make gh errors human-first, actionable, and machine-readable #14038

Description

@maxbeizer

Describe the feature or problem you’d like to solve

gh frequently exposes implementation details from the API client instead of explaining the user’s failed goal:

GraphQL: Could not resolve to a PullRequest with the number of 45.

The problem is larger than the GraphQL: prefix. Today, errors may:

  • Lead with GraphQL, HTTP, mutation, or field-path details irrelevant to most users.
  • Flatten several validation failures into one sentence.
  • Suggest remediation that is wrong for the active credential type.
  • Report complete failure after a resource was successfully created.
  • Treat warnings as command failures.
  • Encourage unsafe retries when a remote side effect may already have happened.
  • Force scripts and agents to parse unstable human prose.

This behavior is inconsistent. gh already has better patterns for some DNS, authentication, SAML, and OAuth-scope failures. The goal should be to generalize those patterns rather than fix errors command by command.

Existing reports

Area Reports
Raw API terminology #1686, #2756
Multi-cause and input validation #2129, #2691, #3224, #12889
Permission and scope diagnostics #6274, #8326, #8434, #8885, #9380, #12585
Failed scope-message migration #12596, reverted by #12915
Partial success reported as failure #13804, #13899
Transient state conflicts #8092
Warning treated as failure #11992
Recovery behavior #10889
GraphQL client architecture #5163
Agent-friendly interfaces #12522

#12596 and its revert in #12915 illustrate why changing strings alone is insufficient. The improved message depended on parsing server prose, broke an internal string comparison, and recommended gh auth refresh for credentials that could not necessarily be refreshed.

Proposed solution

Introduce a shared diagnostic model with:

  1. Typed transport facts from the API layer.
  2. Semantic translation at the command or domain layer.
  3. Central human and JSON renderers.
  4. Explicit command outcomes and exit statuses.
  5. A sanitized fallback for errors not yet translated.

Design principles

An excellent gh diagnostic should:

  • Describe the user’s failed goal, not the API transport.
  • State what succeeded, what failed, and what remains uncertain.
  • Offer a safe, credential-aware next step when one is known.
  • Preserve missing-versus-inaccessible ambiguity where required.
  • Keep stdout usable as the successful result stream.
  • Provide stable structured data without freezing human prose.
  • Remain useful without color or a TTY.
  • Reveal technical details through GH_DEBUG, not by default.
  • Never suppress independent failures unless a typed relationship proves they are cascading.

Human diagnostic grammar

Centrally rendered diagnostics should use consistent roles:

error: <what gh could not accomplish>
  <flag or argument>: <specific cause>
note: <relevant context>
help: <safe recovery step>

The headline severity is one of error, warning, or notice. note: and help: are optional.

Human messages should be neutral and non-blaming. TTY detection may change color and layout, but not meaning.

Request IDs should appear in structured output whenever available. Human output should show them only when contacting support or diagnosing a service failure is relevant.

gh api is an intentional exception to transport-neutral output. Its users selected an API-level command, so HTTP and GraphQL details can remain relevant. Extensions should continue to own their stderr.

Example diagnostics

These are illustrative. A translator should only assert facts the client can establish.

1. Missing or inaccessible resource

Before:

$ gh pr checkout 45
GraphQL: Could not resolve to a PullRequest with the number of 45.

After:

$ gh pr checkout 45
error: could not find pull request 45 in cli/cli
note: the pull request may not exist or your account may not have access
help: check the repository or run `gh auth status`
2. Multiple invalid inputs

Before:

pull request create failed: GraphQL: Head sha can't be blank, Base sha can't
be blank, No commits between main and feature, Head ref must be a branch

After:

$ gh pr create --base upstream/main --head origin/feature
error: could not create the pull request because 2 branch references are invalid
  --base: expected a branch name instead of a remote-qualified reference
  --head: branch "feature" is not available in the selected head repository
help: use `--base main` and push the head branch before retrying

Independent problems remain visible. A command-level summary avoids arbitrarily declaring one of them the primary cause.

3. Credential-aware permission remediation

For a refreshable token:

error: could not add the issue to project "Roadmap"
note: the active token is missing the "project" scope
help: run `gh auth refresh -h github.com -s project`

For GH_TOKEN:

error: could not add the issue to project "Roadmap"
note: the token supplied through GH_TOKEN lacks project access
help: replace GH_TOKEN with a token that has permission to update the project

A runnable command should only be emitted when it is known to apply. GitHub.com, GHES, Actions tokens, GitHub App tokens, environment tokens, and credentials managed by gh can require different recovery steps.

4. Partial success

If the issue was created but its type could not be set:

stdout

https://github.com/OWNER/REPO/issues/123

stderr

error: issue #123 was created, but its type could not be set
note: your account does not have permission to set issue types in OWNER/REPO
help: update the issue after obtaining the required repository permission

The process exits 3. The successful resource remains on stdout, and structured stderr repeats a minimal reference to it.

This avoids recovery instructions that would create a duplicate issue.

5. Internal service failure

Before:

GraphQL: Something went wrong while executing your query. Please include
`ABC1:1234` when reporting this issue.

After:

error: GitHub could not complete the request
note: request reference ABC1:1234
help: try again; if the problem continues, include the request reference when contacting support

Transport type, API path, and redacted raw facts remain available under GH_DEBUG.

6. Indeterminate remote outcome

A network timeout does not always mean that a mutation failed. The server may have completed it before the connection was lost.

error: gh lost the connection before it could confirm whether release v1.0.0 was created
help: run `gh release view v1.0.0` before retrying

The process exits 5. This is distinct from both failure and partial success.

Non-fatal diagnostics

Warnings and notices do not imply failure:

warning: Projects (classic) information is unavailable
note: the remaining issue details were retrieved successfully

The result remains on stdout and the process exits 0.

Structured diagnostics

Add a global format independent of command-specific --json output:

gh pr create --error-format=json
GH_ERROR_FORMAT=json gh pr create

The envelope is written to stderr. Normal command results remain on stdout.

One envelope is emitted per invocation rather than compiler-style diagnostic streaming:

{
  "schemaVersion": 1,
  "outcome": "partial_success",
  "command": ["issue", "create"],
  "diagnostics": [
    {
      "severity": "error",
      "category": "authorization",
      "message": "Issue #123 was created, but its type could not be set.",
      "context": {
        "action": "set",
        "resource": "issue_type"
      },
      "causes": [
        {
          "message": "The active account cannot set issue types in OWNER/REPO."
        }
      ],
      "remediation": [
        {
          "kind": "instruction",
          "description": "Update the issue after obtaining the required repository permission.",
          "applicability": "manual"
        }
      ],
      "support": {
        "requestId": "ABC1:1234"
      }
    }
  ],
  "result": {
    "resources": [
      {
        "type": "issue",
        "identifier": "123",
        "url": "https://github.com/OWNER/REPO/issues/123"
      }
    ]
  },
  "rendered": "error: issue #123 was created, but its type could not be set\n..."
}
Stability

Stable in schema version 1:

  • Field meanings and enum values.
  • Outcome and category semantics.
  • Typed remediation and resource-reference shapes.

Explicitly unstable:

  • message
  • description
  • rendered

These remain human prose and must not be parsed by automation.

Outcomes and exit statuses
Outcome Exit status
Success, including warnings or notices 0
Failure 1
Cancelled 2, unchanged
Partial success 3, new
Authentication failure 4, unchanged
Indeterminate remote outcome 5, new
Pending 8, unchanged

The diagnostic outcome for an authentication error remains failure; exit 4 preserves the existing shell-level distinction.

Recovery categories

Version 1 uses a deliberately broad closed set:

  • usage
  • authentication
  • authorization
  • validation
  • not_found
  • conflict
  • rate_limit
  • network
  • service
  • unknown

These describe recovery classes rather than GraphQL or HTTP implementation details. A granular stable error-code registry can be considered separately.

Remediation actions

Remediation entries have a typed kind such as:

  • command
  • documentation
  • instruction

They also declare applicability:

  • exact: safe and directly runnable.
  • conditional: applies if a stated condition is true.
  • manual: requires user judgment or external changes.

Commands should be represented as argument arrays rather than shell strings.

Proposed Go architecture

Suggested package layout:

api/
  client.go                         structured transport facts only

internal/diagnostics/
  diagnostic.go                     model and enums
  report.go                         invocation outcome and resources
  collector.go                      warnings, notices, and results
  render_human.go
  render_json.go

internal/diagnostics/translate/
  api.go
  auth.go
  network.go
  fallback.go

internal/ghcmd/
  cmd.go                            assemble, render, and choose exit status

Core interfaces would be approximately:

type Sink interface {
	Add(Diagnostic)
}

type Translator interface {
	Translate(error, Environment) (Diagnostic, bool)
}

type Renderer interface {
	Render(io.Writer, Report) error
}

A diagnostic error returned by a command should preserve its underlying cause through Unwrap, while carrying semantic context and an optional partial result.

cmdutil.Factory should expose an injected diagnostics.Sink. Migrated commands use it instead of immediately printing warnings. This allows internal/ghcmd to combine non-fatal diagnostics with a returned error and render exactly one structured envelope.

Error flow
  1. api/ preserves structured HTTP, GraphQL, header, and request metadata without choosing human prose.
  2. A command adds user-level action, resource, flag, and result context when it has it.
  3. Generic translators handle authentication, authorization, network, service, and typed API failures.
  4. Root merges collected warnings with the returned diagnostic error.
  5. The selected renderer writes one coherent report to stderr.
  6. An untranslated error goes through a sanitized fallback rather than fmt.Fprintln(err).
  7. GH_DEBUG appends a redacted technical block containing transport facts.

Translators must not classify errors by matching rendered Error() strings.

Migration plan

Phase 1: establish the contract
  • Add diagnostic models, collector, renderers, and environment resolution.
  • Add --error-format=json and GH_ERROR_FORMAT.
  • Add exit 3 and exit 5.
  • Document stdout, stderr, prose-stability, and schema guarantees.
  • Add renderer and schema contract tests.
Phase 2: central generic translation
  • Move DNS, TLS, authentication, SAML, scope, rate-limit, and service handling into typed translators.
  • Add the sanitized fallback.
  • Preserve technical defaults for gh api.
  • Ensure remediation is host- and credential-aware.
Phase 3: migrate representative workflows

Start with the scenarios represented by existing reports:

  • Missing or inaccessible pull requests.
  • pr create multi-cause validation.
  • Project scope and permission failures.
  • issue create deferred-update partial success.
  • Merge conflicts and service failures.
  • Mutation timeouts with indeterminate outcomes.

Remove brittle string comparisons encountered in those paths before changing their rendered text.

Phase 4: broader migration and API metadata

Migrate remaining commands incrementally.

Where the API does not expose enough structured information, pursue targeted metadata improvements such as:

  • Required permissions or scopes.
  • Request IDs.
  • Documentation URLs.
  • Typed retryability or service-state information.

These improvements should not block the client-side foundation.

Acceptance criteria

  • Domain commands do not lead with GraphQL: or HTTP <status>: when a typed translation is available.
  • Untranslated domain-command errors pass through a sanitized fallback.
  • gh api may retain relevant transport detail.
  • Human diagnostics use error:, warning:, or notice: with optional note: and help:.
  • stdout remains reserved for command results.
  • TTY and non-TTY output have the same semantics.
  • JSON mode emits one versioned, ANSI-free envelope on stderr.
  • Successful commands can emit structured warnings while exiting 0.
  • Partial success preserves the created resource, reports the failed action, and exits 3.
  • Unconfirmed remote mutations use indeterminate, recommend checking before retrying, and exit 5.
  • Remediation commands are emitted only when known to apply to the host and credential source.
  • Missing and inaccessible resources remain intentionally ambiguous.
  • Request IDs are machine-readable and shown to humans only when support-relevant.
  • Debug output is redacted and contains no credentials.
  • Suppression requires typed evidence that an error is cascading.
  • Renderer, translator, schema, stream, and exit-status behavior have contract tests.
  • Human prose is documented as unstable; no legacy-text mode is introduced.

Inspiration, not emulation

The closest precedents are other networked operational CLIs:

  • kubectl translates API errors into domain language and preserves multiple validation causes.
  • Stripe CLI adapts recovery guidance to the active credential and profile.
  • AWS CLI and Heroku expose structured failure and support-correlation information.

Elm and Rust provide useful diagnostic principles:

  • Elm treats an error as an interaction with a person rather than an exception dump.
  • Rust separates diagnosis, context, and help; suppresses proven cascades; and classifies suggestion applicability.

gh is not a compiler. It performs authenticated, remote, stateful operations. Inputs may already have produced side effects, and network failures can leave outcomes unknown. This proposal does not copy source spans, file locations, compile-pass batching, type-system tutorials, or compiler-style NDJSON.

Out of scope

  • Changing automatic retry behavior.
  • Comprehensive client-side validation; gh pr create: improve validation and error messaging for invalid ref inputs #12889 already covers much of that work.
  • A granular stable error-code registry and documentation index.
  • Treating human stderr as a compatibility contract.
  • Reformatting third-party extension errors.
  • Hiding transport details from gh api.
  • Error telemetry without a separate privacy review.

Additional context

The repository already has strong local examples to build on: DNS translation, credential-aware 401 recovery, SAML authorization links, and some scope suggestions. This proposal is intended to make those patterns coherent across gh, not replace them with compiler-specific UX.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions