fix(gh-project): diagnose env-shadowed gh tokens in auth errors (#1478)

* fix(gh-project): diagnose env-shadowed gh tokens in auth errors

`gh auth refresh -s project` silently no-ops when GITHUB_TOKEN/GH_TOKEN
is exported in the user's shell — gh prefers env tokens and refuses to
modify them, exiting 0. Users follow the canned remediation, see no
error, retry, and stay stuck.

Add a one-shot `gh auth status` probe (gh:diagnoseAuth IPC) that:

- Detects env-shadowed credentials and rewrites the fix to `unset
  GITHUB_TOKEN` plus a grep to find where it's exported.
- Detects missing gh install, plain missing-scope on a keyring login,
  and SAML SSO authorization.
- Surfaces a tailored multi-button error UI in ProjectViewWrapper and
  ProjectPicker instead of one canned 'Copy command'.

Co-authored-by: Orca <help@stably.ai>

* fix(gh-project): address review feedback

- Cross-platform shell guidance: PowerShell commands on Windows
  (Get-ChildItem Env:, Remove-Item Env:, [Environment]::SetEnvironmentVariable)
  via navigator.userAgent platform check.
- Use `window.api.shell.openUrl` for the docs button instead of
  `window.open`, matching SidebarToolbar's external-URL pattern.
- Tighten gh auth status parser: accept single-label hostnames and
  optional trailing colon; recover host from the inline 'Logged in to
  <host>' line so a missed section header never silently drops accounts.
- Add tests for multi-host output and host-recovery fallback.
- Drop dead command/copy locals in ProjectViewWrapper.ErrorState by
  short-circuiting the auth-error case before they're computed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-05-05 17:36:27 -07:00 committed by GitHub
parent 8565f7f186
commit e49d90bee4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 578 additions and 51 deletions

View File

@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import { parseAuthStatus } from './auth-diagnose'
describe('parseAuthStatus', () => {
it('parses an env-shadowed login alongside a keyring login (real gh output)', () => {
const text = `github.com
Logged in to github.com account nwparker (GITHUB_TOKEN)
- Active account: true
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
Logged in to github.com account nwparker (keyring)
- Active account: false
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
`
const accounts = parseAuthStatus(text)
expect(accounts).toHaveLength(2)
expect(accounts[0]).toMatchObject({
host: 'github.com',
user: 'nwparker',
active: true,
envToken: 'GITHUB_TOKEN',
source: 'env'
})
expect(accounts[0].scopes).toEqual(['gist', 'read:org', 'repo', 'workflow'])
expect(accounts[1]).toMatchObject({
active: false,
envToken: null,
source: 'keyring'
})
})
it('parses a single keyring login', () => {
const text = `github.com
Logged in to github.com account alice (keyring)
- Active account: true
- Token scopes: 'project', 'read:org', 'repo'
`
const accounts = parseAuthStatus(text)
expect(accounts).toHaveLength(1)
expect(accounts[0]).toMatchObject({
user: 'alice',
active: true,
envToken: null,
source: 'keyring'
})
expect(accounts[0].scopes).toContain('project')
})
it('detects GH_TOKEN env source', () => {
const text = `github.com
Logged in to github.com account bot (GH_TOKEN)
- Active account: true
- Token scopes: 'repo'
`
const [acc] = parseAuthStatus(text)
expect(acc.envToken).toBe('GH_TOKEN')
expect(acc.source).toBe('env')
})
it('returns empty array when nothing is logged in', () => {
expect(parseAuthStatus('You are not logged into any GitHub hosts.')).toEqual([])
})
it('parses multiple hosts in one output (github.com + GHES)', () => {
const text = `github.com
Logged in to github.com account alice (keyring)
- Active account: true
- Token scopes: 'read:org', 'repo'
ghe.acme.io
Logged in to ghe.acme.io account bob (keyring)
- Active account: true
- Token scopes: 'project', 'repo'
`
const accounts = parseAuthStatus(text)
expect(accounts.map((a) => a.host)).toEqual(['github.com', 'ghe.acme.io'])
expect(accounts.map((a) => a.user)).toEqual(['alice', 'bob'])
expect(accounts[1].scopes).toContain('project')
})
it('recovers host from the Logged-in line when the section header is missing', () => {
// gh prints a colon after the host on some versions; we tolerate it,
// but if the regex ever fails to match the header we still want
// accounts attributed to the host from the inline message.
const text = ` ✓ Logged in to github.acme.io account carol (keyring)
- Active account: true
- Token scopes: 'project'
`
const accounts = parseAuthStatus(text)
expect(accounts).toHaveLength(1)
expect(accounts[0].host).toBe('github.acme.io')
})
})

View File

@ -0,0 +1,142 @@
/**
* gh CLI auth diagnostics.
*
* Why: when project queries fail with "missing scope", the canned
* remediation `gh auth refresh -s project ...` silently no-ops if the user
* has `GITHUB_TOKEN` (or `GH_TOKEN`) exported in their shell gh prefers
* env tokens over keyring credentials and refuses to refresh env-supplied
* tokens. Users follow the instructions, see no error, retry, and stay
* stuck. This probe makes that failure mode legible in the UI.
*
* Output is parsed from `gh auth status`, which prints free-form text but
* uses stable field labels ("Token scopes:", "(GITHUB_TOKEN)", etc.).
*/
import { ghExecFileAsync } from '../git/runner'
import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github-auth-types'
// Required scopes for ProjectV2 GraphQL access in Orca. `project` is the
// scope that gates ProjectV2 reads/writes; the others are needed for the
// surrounding repo/org queries we already run.
const REQUIRED_SCOPES = ['project', 'read:org', 'repo'] as const
/**
* Parse `gh auth status` stderr/stdout. gh writes to stderr by default but
* has used stdout in some versions; we accept either. Format (per host):
*
* github.com
* Logged in to github.com account NAME (GITHUB_TOKEN)
* - Active account: true
* - Token scopes: 'gist', 'read:org', 'repo'
*/
export function parseAuthStatus(text: string): GhAuthAccount[] {
const accounts: GhAuthAccount[] = []
let currentHost: string | null = null
let current: GhAuthAccount | null = null
for (const rawLine of text.split('\n')) {
const line = rawLine.replace(/\r$/, '')
// Host header: a non-indented hostname token, with an optional
// trailing colon some gh versions emit. Permits single-label hostnames
// (internal GHES like `github` or `ghe-internal`); we also recover the
// host from the `Logged in to <host>` line below if this header was
// missed, so a parser miss never silently drops every account.
const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*)\s*:?\s*$/i)
if (hostMatch && !/^logged\b/i.test(line)) {
currentHost = hostMatch[1]
continue
}
const loggedIn = line.match(/Logged in to (\S+) account (\S+)(?:\s+\(([^)]+)\))?/i)
if (loggedIn) {
if (current) {
accounts.push(current)
}
// Prefer the host from the `Logged in to <host>` line itself — it's
// always present, whereas the section header above can be skipped
// by the regex on unfamiliar gh output.
const host = loggedIn[1] || currentHost || 'github.com'
const sourceLabel = (loggedIn[3] ?? '').trim()
// gh emits "(keyring)" for stored creds and "(GITHUB_TOKEN)" /
// "(GH_TOKEN)" when an env var is shadowing the keyring.
const envToken =
sourceLabel === 'GITHUB_TOKEN' || sourceLabel === 'GH_TOKEN' ? sourceLabel : null
current = {
host,
user: loggedIn[2],
active: false,
envToken,
source: envToken ? 'env' : 'keyring',
scopes: []
}
continue
}
if (!current) {
continue
}
const activeMatch = line.match(/Active account:\s*(true|false)/i)
if (activeMatch) {
current.active = activeMatch[1].toLowerCase() === 'true'
continue
}
const scopesMatch = line.match(/Token scopes:\s*(.+)$/i)
if (scopesMatch) {
current.scopes = scopesMatch[1]
.split(',')
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean)
}
}
if (current) {
accounts.push(current)
}
return accounts
}
export async function diagnoseGhAuth(): Promise<GhAuthDiagnostic> {
let raw = ''
let ghAvailable = true
try {
// `gh auth status` exits non-zero when no host is logged in but still
// prints the same diagnostic text we want, so capture both streams.
const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'])
raw = `${stdout}\n${stderr}`
} catch (err) {
const stderr =
err && typeof err === 'object' && 'stderr' in err
? String((err as { stderr?: unknown }).stderr ?? '')
: ''
const stdout =
err && typeof err === 'object' && 'stdout' in err
? String((err as { stdout?: unknown }).stdout ?? '')
: ''
raw = `${stdout}\n${stderr}`
if (!raw.trim()) {
const message = err instanceof Error ? err.message : String(err)
// Most likely cause: gh CLI not installed or not on PATH.
if (/ENOENT|not found|command not found/i.test(message)) {
ghAvailable = false
}
raw = message
}
}
const accounts = parseAuthStatus(raw)
const active = accounts.find((a) => a.active) ?? accounts[0] ?? null
const envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null = process.env.GH_TOKEN
? 'GH_TOKEN'
: process.env.GITHUB_TOKEN
? 'GITHUB_TOKEN'
: null
const missingScopes = active
? REQUIRED_SCOPES.filter((s) => !active.scopes.includes(s))
: [...REQUIRED_SCOPES]
// Is there a non-env (keyring) account we could fall back to by unsetting
// the env var? Only meaningful if the active account is env-shadowed.
const keyringFallback = accounts.find((a) => a.source === 'keyring') ?? null
return {
ghAvailable,
activeAccount: active,
accounts,
envTokenInProcess,
missingScopes,
requiredScopes: [...REQUIRED_SCOPES],
hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active)
}
}

View File

@ -34,6 +34,7 @@ import {
} from '../github/client'
import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details'
import { getRateLimit } from '../github/rate-limit'
import { diagnoseGhAuth } from '../github/auth-diagnose'
import type { GitHubPRFile } from '../../shared/types'
import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args'
import {
@ -406,6 +407,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
getRateLimit(args?.force ? { force: true } : undefined)
)
ipcMain.handle('gh:diagnoseAuth', () => diagnoseGhAuth())
// ── GitHub ProjectV2 view handlers ─────────────────────────────────
// Why: registered unconditionally so enabling the experimental flag at
// runtime takes effect without a restart. The renderer gates entry points.

View File

@ -132,6 +132,7 @@ import type {
ClaudeUsageSummary
} from '../shared/claude-usage-types'
import type { RateLimitState } from '../shared/rate-limit-types'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type {
SshConnectionState,
SshTarget,
@ -602,6 +603,14 @@ export type PreloadApi = {
* `force: true` to bust after a known-expensive op.
*/
rateLimit: (args?: { force?: boolean }) => Promise<GetRateLimitResult>
/**
* Probe `gh auth status` and the Electron process env to explain
* why ProjectV2 calls are failing with scope_missing. Surfaces the
* common gotcha where `GITHUB_TOKEN` is exported in the user's
* shell and silently shadows the keyring credential in that case
* `gh auth refresh` is a no-op and the UI must say so.
*/
diagnoseAuth: () => Promise<GhAuthDiagnostic>
// ── ProjectV2 (GitHub Projects) ─────────────────────────────────
listAccessibleProjects: () => Promise<ListAccessibleProjectsResult>
resolveProjectRef: (args: ResolveProjectRefArgs) => Promise<ResolveProjectRefResult>
@ -759,11 +768,7 @@ export type PreloadApi = {
sidekick: {
import: () => Promise<CustomSidekick | null>
importPetBundle: () => Promise<CustomSidekick | null>
read: (
id: string,
fileName: string,
kind?: 'image' | 'bundle'
) => Promise<ArrayBuffer | null>
read: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise<ArrayBuffer | null>
delete: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise<void>
}
browser: BrowserApi

View File

@ -28,6 +28,7 @@ import type {
} from '../shared/types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types'
import type { RateLimitState } from '../shared/rate-limit-types'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type {
AddIssueCommentBySlugArgs,
ClearProjectItemFieldArgs,
@ -669,6 +670,8 @@ const api = {
rateLimit: (args?: { force?: boolean }): Promise<GetRateLimitResult> =>
ipcRenderer.invoke('gh:rateLimit', args),
diagnoseAuth: (): Promise<GhAuthDiagnostic> => ipcRenderer.invoke('gh:diagnoseAuth'),
// ── ProjectV2 (GitHub Projects) ───────────────────────────────────
listAccessibleProjects: (): Promise<ListAccessibleProjectsResult> =>
ipcRenderer.invoke('gh:listAccessibleProjects'),

View File

@ -0,0 +1,257 @@
/**
* Inline guidance for `auth_required` / `scope_missing` errors from gh.
*
* Why: the canned remediation `gh auth refresh -s project ...` silently
* no-ops when GITHUB_TOKEN/GH_TOKEN is exported in the user's shell gh
* prefers env tokens and refuses to refresh them. Users follow the
* instructions, see no error, retry, and stay stuck. This component runs
* a one-shot diagnostic and rewrites the suggested fix to match what gh
* is actually doing: env-shadow vs. plain missing-scope vs. not-installed.
*/
import { useEffect, useState } from 'react'
import { Copy, ExternalLink } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import type { GitHubProjectViewError } from '@/../../shared/github-project-types'
import type { GhAuthDiagnostic } from '@/../../shared/github-auth-types'
type AuthErrorKind = 'auth_required' | 'scope_missing'
const REFRESH_CMD = 'gh auth refresh -s project -s read:org -s repo'
const LOGIN_CMD = 'gh auth login'
// AGENTS.md requires platform-specific shell guidance. The env-shadow
// remediation needs different commands per host shell — bash/zsh on
// macOS/Linux vs PowerShell on Windows.
const IS_WINDOWS = typeof navigator !== 'undefined' && /Win(dows|32|64)/i.test(navigator.userAgent)
function findEnvVarCommand(varName: string): { label: string; command: string } {
if (IS_WINDOWS) {
return {
label: 'Check if its set (PowerShell)',
command: `Get-ChildItem Env:${varName}`
}
}
return {
label: 'Find where its set',
command: `grep -RIn '${varName}' ~/.zshrc ~/.zshenv ~/.bashrc ~/.bash_profile ~/.profile ~/.config 2>/dev/null`
}
}
function unsetEnvVarCommand(varName: string): { label: string; command: string } {
if (IS_WINDOWS) {
// Persistent removal at the user scope; the user still needs a fresh
// shell/Orca relaunch for the change to take effect.
return {
label: 'Unset (PowerShell, persistent)',
command: `Remove-Item Env:${varName}; [Environment]::SetEnvironmentVariable('${varName}', $null, 'User')`
}
}
return { label: 'Unset for this shell', command: `unset ${varName}` }
}
function openExternal(url: string): void {
// In Electron renderers, raw `window.open` doesn't reliably route to the
// user's default browser. Use the same shell IPC the rest of the app
// uses (see SidebarToolbar.openExternalUrl).
void window.api.shell.openUrl(url)
}
async function copyToClipboard(text: string): Promise<void> {
try {
await window.api.ui.writeClipboardText(text)
toast.success('Copied to clipboard')
} catch {
toast.error('Failed to copy')
}
}
type Remediation = {
/** Short human-readable summary of why the error is happening. */
summary: string
/** Optional follow-up paragraph explaining the fix. */
detail?: string
/** Commands to surface as copyable buttons, in order. */
commands: { label: string; command: string }[]
/** Optional external doc link. */
docsUrl?: string
}
function buildRemediation(
errorMessage: string,
kind: AuthErrorKind,
diag: GhAuthDiagnostic | null
): Remediation {
// Diagnostic still loading or unavailable — fall back to the canned advice
// so the UI never gets worse than the pre-diagnosis behavior.
if (!diag) {
return {
summary: errorMessage,
commands: [
{ label: 'Copy command', command: kind === 'auth_required' ? LOGIN_CMD : REFRESH_CMD }
]
}
}
if (!diag.ghAvailable) {
return {
summary: 'GitHub CLI (`gh`) is not installed or not on PATH.',
detail:
'Orca uses `gh` to talk to GitHub Projects. Install it from cli.github.com, then sign in.',
commands: [{ label: 'Copy login command', command: LOGIN_CMD }],
docsUrl: 'https://cli.github.com/'
}
}
const active = diag.activeAccount
// Most insidious failure mode: gh is using a token from the environment,
// so `gh auth refresh` prints "GITHUB_TOKEN is being used... first clear
// the value from the environment" and exits 0 without doing anything.
if (active?.envToken) {
const varName = active.envToken
const fallback = diag.hasKeyringFallback
? ' Your keyring already has a `gh` login that will take over once the env var is gone.'
: ' After unsetting it, run `gh auth login` to sign in normally, then retry.'
return {
summary: `\`${varName}\` is set in your environment, so \`gh\` is using that token instead of your keyring login. \`gh auth refresh\` cannot modify env-supplied tokens — that's why running it didn't help.`,
detail: IS_WINDOWS
? `Find where \`${varName}\` is set (System or User environment variables, or your PowerShell profile), remove it, then restart Orca so the new environment is picked up.${fallback}`
: `Find where \`${varName}\` is exported (commonly \`~/.zshrc\`, \`~/.zshenv\`, \`~/.bashrc\`, \`~/.profile\`, or your shell's secrets manager), remove it, then restart Orca so the new environment is picked up.${fallback}`,
commands: [findEnvVarCommand(varName), unsetEnvVarCommand(varName)],
docsUrl: 'https://cli.github.com/manual/gh_help_environment'
}
}
// gh is not the problem, but the Electron process inherited GITHUB_TOKEN
// from the parent shell. Even after the user runs `gh auth refresh` in a
// separate terminal, Orca's gh subprocess sees the env var and uses it.
if (diag.envTokenInProcess && (!active || diag.missingScopes.length > 0)) {
const varName = diag.envTokenInProcess
return {
summary: `Orca inherited \`${varName}\` from your shell, and \`gh\` is using that token. \`gh auth refresh\` doesn't apply to env-supplied tokens.`,
detail: `Unset \`${varName}\` in the shell that launches Orca${
IS_WINDOWS ? ' (or in your user environment variables)' : ' (or in your shell rc file)'
}, then restart Orca.`,
commands: [findEnvVarCommand(varName), unsetEnvVarCommand(varName)],
docsUrl: 'https://cli.github.com/manual/gh_help_environment'
}
}
if (kind === 'auth_required' || !active) {
return {
summary: 'Youre not signed in to GitHub via `gh`.',
commands: [{ label: 'Copy login command', command: LOGIN_CMD }]
}
}
// Plain missing-scope case on a keyring login — refresh will work.
if (diag.missingScopes.length > 0) {
return {
summary: `Your \`gh\` token is missing the ${diag.missingScopes
.map((s) => `\`${s}\``)
.join(
', '
)} scope${diag.missingScopes.length === 1 ? '' : 's'} needed for GitHub Projects.`,
detail:
'Run the refresh command in a terminal. It will open a browser to authorize the new scopes, then come back here and reload.',
commands: [{ label: 'Copy refresh command', command: REFRESH_CMD }]
}
}
// Scopes look fine but GitHub still rejected us — likely SAML SSO not
// authorized for this org's token, or the project is in an org the token
// can't see. Surface the most likely fix.
return {
summary: errorMessage,
detail:
'Your token has the required scopes but GitHub still denied access. If the project is in an org with SAML SSO, you must authorize this token for the org under Settings → Developer settings → Personal access tokens → Configure SSO.',
commands: [{ label: 'Copy refresh command', command: REFRESH_CMD }],
docsUrl:
'https://docs.github.com/en/enterprise-cloud@latest/authentication/authenticating-with-saml-single-sign-on/authorizing-a-personal-access-token-for-use-with-saml-single-sign-on'
}
}
export function GhAuthErrorHelp({
error,
variant = 'block'
}: {
error: GitHubProjectViewError & { type: AuthErrorKind }
variant?: 'block' | 'banner'
}): React.JSX.Element {
const [diag, setDiag] = useState<GhAuthDiagnostic | null>(null)
useEffect(() => {
let cancelled = false
window.api.gh
.diagnoseAuth()
.then((d) => {
if (!cancelled) {
setDiag(d)
}
})
// Diagnostic is best-effort; never block the error UI on it.
.catch(() => {})
return () => {
cancelled = true
}
}, [])
const remedy = buildRemediation(error.message, error.type, diag)
const docsUrl = remedy.docsUrl
if (variant === 'banner') {
return (
<div className="border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<div className="font-medium">{remedy.summary}</div>
{remedy.detail ? <div className="mt-0.5 opacity-80">{remedy.detail}</div> : null}
<div className="mt-1 flex flex-wrap gap-1">
{remedy.commands.map((c) => (
<button
key={c.command}
type="button"
onClick={() => copyToClipboard(c.command)}
title={c.command}
className="inline-flex items-center gap-1 rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[11px] hover:bg-amber-500/20"
>
<Copy className="size-3" /> {c.label}
</button>
))}
{docsUrl ? (
<a
href={docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded border border-amber-500/30 px-1.5 py-0.5 text-[11px] hover:bg-amber-500/20"
>
<ExternalLink className="size-3" /> Docs
</a>
) : null}
</div>
</div>
)
}
return (
<div className="flex flex-col gap-2 text-sm">
<div className="text-foreground">{remedy.summary}</div>
{remedy.detail ? <div className="text-muted-foreground">{remedy.detail}</div> : null}
<div className="flex flex-wrap gap-2">
{remedy.commands.map((c) => (
<Button
key={c.command}
size="sm"
variant="outline"
title={c.command}
onClick={() => copyToClipboard(c.command)}
>
<Copy className="mr-1 size-3.5" /> {c.label}
</Button>
))}
{docsUrl ? (
<Button size="sm" variant="outline" onClick={() => openExternal(docsUrl)}>
<ExternalLink className="mr-1 size-3.5" /> Docs
</Button>
) : null}
</div>
</div>
)
}

View File

@ -4,8 +4,9 @@
// from `listAccessibleProjects` and is cached for 5 minutes. Paste-to-add
// accepts org/user project URLs and `owner/number` shorthand.
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertTriangle, ChevronDown, Copy, Loader, Pin, Search } from 'lucide-react'
import { AlertTriangle, ChevronDown, Loader, Pin, Search } from 'lucide-react'
import { toast } from 'sonner'
import { GhAuthErrorHelp } from '@/components/github-project/GhAuthErrorHelp'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@ -642,31 +643,18 @@ function PartialFailuresBanner({
}
function AuthErrorBanner({ error }: { error: GitHubProjectViewError }): React.JSX.Element {
const command =
error.type === 'auth_required'
? 'gh auth login'
: error.type === 'scope_missing'
? 'gh auth refresh -s project -s read:org -s repo'
: null
if (error.type === 'auth_required' || error.type === 'scope_missing') {
return (
<GhAuthErrorHelp
error={error as GitHubProjectViewError & { type: 'auth_required' | 'scope_missing' }}
variant="banner"
/>
)
}
// Non-auth errors keep the legacy single-line banner.
return (
<div className="border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<div>{error.message}</div>
{command ? (
<button
type="button"
onClick={async () => {
try {
await window.api.ui.writeClipboardText(command)
toast.success('Command copied')
} catch {
toast.error('Failed to copy')
}
}}
className="mt-1 inline-flex items-center gap-1 rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[11px] hover:bg-amber-500/20"
>
<Copy className="size-3" /> Copy command
</button>
) : null}
</div>
)
}

View File

@ -4,7 +4,6 @@
// documented in the design doc.
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
Copy,
ExternalLink,
Loader,
RefreshCw,
@ -26,6 +25,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import GitHubItemDialog, { type GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog'
import { GhAuthErrorHelp } from '@/components/github-project/GhAuthErrorHelp'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { useRepoSlugIndex } from '@/lib/repo-slug-index'
import { cn } from '@/lib/utils'
@ -900,12 +900,21 @@ function ErrorState({
totalCount?: number
onOpenInGitHub: () => void
}): React.JSX.Element {
const command =
error.type === 'auth_required'
? 'gh auth login'
: error.type === 'scope_missing'
? 'gh auth refresh -s project -s read:org -s repo'
: null
// Auth/scope errors get a richer remediation UI driven by `gh auth
// status`. Bail early so the generic `command`/`copy` block below is
// only computed for non-auth error types.
if (error.type === 'auth_required' || error.type === 'scope_missing') {
return (
<div className="flex flex-1 flex-col items-start gap-3 p-6 text-sm">
<GhAuthErrorHelp
error={error as GitHubProjectViewError & { type: 'auth_required' | 'scope_missing' }}
/>
<Button size="sm" variant="outline" onClick={onOpenInGitHub}>
<ExternalLink className="mr-1 size-3.5" /> Open in GitHub
</Button>
</div>
)
}
const copy =
error.type === 'too_large'
? `This view has ${totalCount ?? 'many'} items — too large to render in Orca. Narrow the view's filter on GitHub.`
@ -920,22 +929,6 @@ function ErrorState({
<div className="flex flex-1 flex-col items-start gap-3 p-6 text-sm">
<div className="text-muted-foreground">{copy}</div>
<div className="flex gap-2">
{command ? (
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await window.api.ui.writeClipboardText(command)
toast.success('Command copied')
} catch {
toast.error('Failed to copy')
}
}}
>
<Copy className="mr-1 size-3.5" /> Copy command
</Button>
) : null}
<Button size="sm" variant="outline" onClick={onOpenInGitHub}>
<ExternalLink className="mr-1 size-3.5" /> Open in GitHub
</Button>

View File

@ -0,0 +1,39 @@
/**
* Shared types for `gh auth status` diagnostics surfaced to the renderer.
*/
export type GhAuthAccount = {
host: string
user: string
/** True when this is the account gh would use for the next call. */
active: boolean
/**
* If gh reports the credential came from an environment variable, the
* variable's name. Null when the credential came from the keyring/file
* config. Env-token accounts can't be refreshed by `gh auth refresh`.
*/
envToken: 'GITHUB_TOKEN' | 'GH_TOKEN' | null
source: 'env' | 'keyring'
scopes: string[]
}
export type GhAuthDiagnostic = {
/** False when gh CLI is not installed / not on PATH. */
ghAvailable: boolean
activeAccount: GhAuthAccount | null
accounts: GhAuthAccount[]
/**
* Whether the Electron main process itself sees GITHUB_TOKEN/GH_TOKEN in
* its environment. Distinct from `activeAccount.envToken` because gh may
* report an env source even when the variable was set in a parent shell
* that didn't propagate to Electron, and vice versa.
*/
envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null
missingScopes: string[]
requiredScopes: string[]
/**
* True when there's a non-env keyring account on the same/another host
* that the user could fall back to by unsetting the env var.
*/
hasKeyringFallback: boolean
}