🐛 fix(vscode): GitHub 请求和 gh auth token 加超时,设置页连不上 GitHub 时不再显示成未登录
This commit is contained in:
1 parent
9c7c9d8636
commit
a8d922f3ba
16 files changed
+267
-44
No files matched your search
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ForgeContext } from '../forge/context'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ForgeApiError } from '../forge/types'
|
||||||
|
|
||||||
|
vi.mock('vscode', () => ({ StatusBarAlignment: {}, ThemeColor: class {}, window: {} }))
|
||||||
|
vi.mock('../forge/context', () => ({ resolveForge: vi.fn() }))
|
||||||
|
vi.mock('../logging/logger', () => ({ logger: { add: vi.fn() } }))
|
||||||
|
|
||||||
|
function forge(getCurrentUser: () => Promise<unknown>): ForgeContext {
|
||||||
|
return { workspaceRoot: '/repo', kind: 'github', host: 'github.com', owner: 'o', repo: 'r', provider: { getCurrentUser } } as never
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('lookupLogin', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { clearIdentityCache } = await import('./identity.js')
|
||||||
|
clearIdentityCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('解析出 login 后按 host 缓存,下次不再请求', async () => {
|
||||||
|
const { lookupLogin } = await import('./identity.js')
|
||||||
|
const getCurrentUser = vi.fn().mockResolvedValue({ login: 'cruldra' })
|
||||||
|
|
||||||
|
expect(await lookupLogin(forge(getCurrentUser))).toEqual({ ok: true, login: 'cruldra' })
|
||||||
|
expect(await lookupLogin(forge(getCurrentUser))).toEqual({ ok: true, login: 'cruldra' })
|
||||||
|
expect(getCurrentUser).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('失败时把错误交给调用方,好区分登录失效和连不上;loginFor 照旧只给 undefined', async () => {
|
||||||
|
const { loginFor, lookupLogin } = await import('./identity.js')
|
||||||
|
const err = new ForgeApiError(408, '请求超时')
|
||||||
|
|
||||||
|
expect(await lookupLogin(forge(() => Promise.reject(err)))).toEqual({ ok: false, error: err })
|
||||||
|
expect(await loginFor(forge(() => Promise.reject(err)))).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,13 +25,21 @@ let statusBar: StatusBarItem | undefined
|
|||||||
* 解析并缓存 `forge` 当前凭据对应的 login。失败(网络、凭据失效)返回 `undefined`,由调用方决定降级:门禁侧的语义是「身份未知 → 只观察」。
|
* 解析并缓存 `forge` 当前凭据对应的 login。失败(网络、凭据失效)返回 `undefined`,由调用方决定降级:门禁侧的语义是「身份未知 → 只观察」。
|
||||||
*/
|
*/
|
||||||
export async function loginFor(forge: ForgeContext): Promise<string | undefined> {
|
export async function loginFor(forge: ForgeContext): Promise<string | undefined> {
|
||||||
|
const found = await lookupLogin(forge)
|
||||||
|
return found.ok ? found.login : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同 [loginFor],失败时把错误交给调用方,好区分凭据失效(401)和网络不通、超时。
|
||||||
|
*/
|
||||||
|
export async function lookupLogin(forge: ForgeContext): Promise<{ ok: true, login: string } | { ok: false, error: unknown }> {
|
||||||
const hit = userByHost.get(forge.host)
|
const hit = userByHost.get(forge.host)
|
||||||
if (hit)
|
if (hit)
|
||||||
return hit.login
|
return { ok: true, login: hit.login }
|
||||||
try {
|
try {
|
||||||
const user = await forge.provider.getCurrentUser()
|
const user = await forge.provider.getCurrentUser()
|
||||||
userByHost.set(forge.host, user)
|
userByHost.set(forge.host, user)
|
||||||
return user.login
|
return { ok: true, login: user.login }
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
@@ -41,7 +49,7 @@ export async function loginFor(forge: ForgeContext): Promise<string | undefined>
|
|||||||
message: `解析身份失败 host=${forge.host}`,
|
message: `解析身份失败 host=${forge.host}`,
|
||||||
details: message,
|
details: message,
|
||||||
})
|
})
|
||||||
return undefined
|
return { ok: false, error: err }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ describe('resolveForge', () => {
|
|||||||
it('gitHub 仓库用 gh 的登录凭据造出 provider,不读插件里存的 token', async () => {
|
it('gitHub 仓库用 gh 的登录凭据造出 provider,不读插件里存的 token', async () => {
|
||||||
const { resolveForge } = await import('./context.js')
|
const { resolveForge } = await import('./context.js')
|
||||||
mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' })
|
mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' })
|
||||||
mocks.readGhToken.mockResolvedValue('gho_x')
|
mocks.readGhToken.mockResolvedValue({ ok: true, token: 'gho_x' })
|
||||||
|
|
||||||
const resolved = await resolveForge(ctx)
|
const resolved = await resolveForge(ctx)
|
||||||
|
|
||||||
@@ -71,7 +71,8 @@ describe('resolveForge', () => {
|
|||||||
it('gh 没登录时是 no-credentials,提示去终端运行 gh auth login', async () => {
|
it('gh 没登录时是 no-credentials,提示去终端运行 gh auth login', async () => {
|
||||||
const { resolveForge } = await import('./context.js')
|
const { resolveForge } = await import('./context.js')
|
||||||
mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' })
|
mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' })
|
||||||
mocks.readGhToken.mockResolvedValue(undefined)
|
const { GH_LOGIN_HINT } = await import('./github/ghAuth.js')
|
||||||
|
mocks.readGhToken.mockResolvedValue({ ok: false, message: GH_LOGIN_HINT })
|
||||||
|
|
||||||
const resolved = await resolveForge(ctx)
|
const resolved = await resolveForge(ctx)
|
||||||
|
|
||||||
@@ -80,6 +81,16 @@ describe('resolveForge', () => {
|
|||||||
expect(mocks.getToken).not.toHaveBeenCalled()
|
expect(mocks.getToken).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('gh 取 token 超时也算 no-credentials,但提示照 gh 给的原因说是超时', async () => {
|
||||||
|
const { resolveForge } = await import('./context.js')
|
||||||
|
mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' })
|
||||||
|
mocks.readGhToken.mockResolvedValue({ ok: false, message: '`gh auth token` 超时' })
|
||||||
|
|
||||||
|
const resolved = await resolveForge(ctx)
|
||||||
|
|
||||||
|
expect(resolved).toMatchObject({ ok: false, reason: 'no-credentials', kind: 'github', host: 'github.com', message: '`gh auth token` 超时' })
|
||||||
|
})
|
||||||
|
|
||||||
it('各种失败的提示都不带 Gitea 字样', async () => {
|
it('各种失败的提示都不带 Gitea 字样', async () => {
|
||||||
const { resolveForge } = await import('./context.js')
|
const { resolveForge } = await import('./context.js')
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { workspace } from 'vscode'
|
|||||||
import { getToken } from '../auth/secrets'
|
import { getToken } from '../auth/secrets'
|
||||||
import { detectRepo } from '../git/remote'
|
import { detectRepo } from '../git/remote'
|
||||||
import { GiteaProvider } from './gitea/provider'
|
import { GiteaProvider } from './gitea/provider'
|
||||||
import { GH_LOGIN_HINT, readGhToken } from './github/ghAuth'
|
import { readGhToken } from './github/ghAuth'
|
||||||
import { GitHubProvider } from './github/provider'
|
import { GitHubProvider } from './github/provider'
|
||||||
import { forgeKindForHost } from './types'
|
import { forgeKindForHost } from './types'
|
||||||
|
|
||||||
@@ -43,10 +43,10 @@ export async function openForge(ctx: ExtensionContext, workspaceRoot: string, re
|
|||||||
const kind = forgeKindForHost(remote.host)
|
const kind = forgeKindForHost(remote.host)
|
||||||
const repo = { workspaceRoot, kind, host: remote.host, owner: remote.owner, repo: remote.repo }
|
const repo = { workspaceRoot, kind, host: remote.host, owner: remote.owner, repo: remote.repo }
|
||||||
if (kind === 'github') {
|
if (kind === 'github') {
|
||||||
const token = await readGhToken()
|
const gh = await readGhToken()
|
||||||
if (!token)
|
if (!gh.ok)
|
||||||
return { ok: false, reason: 'no-credentials', kind, host: remote.host, message: GH_LOGIN_HINT }
|
return { ok: false, reason: 'no-credentials', kind, host: remote.host, message: gh.message }
|
||||||
return { ok: true, forge: { ...repo, provider: new GitHubProvider({ owner: remote.owner, repo: remote.repo, token }) } }
|
return { ok: true, forge: { ...repo, provider: new GitHubProvider({ owner: remote.owner, repo: remote.repo, token: gh.token }) } }
|
||||||
}
|
}
|
||||||
const token = await getToken(ctx, remote.host)
|
const token = await getToken(ctx, remote.host)
|
||||||
if (!token)
|
if (!token)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ForgeApiError } from '../types'
|
||||||
|
|
||||||
type ExecCallback = (err: Error | null, stdout: string, stderr: string) => void
|
type ExecCallback = (err: Error | null, stdout: string, stderr: string) => void
|
||||||
|
|
||||||
@@ -22,21 +23,53 @@ describe('readGhToken', () => {
|
|||||||
const { readGhToken } = await import('./ghAuth.js')
|
const { readGhToken } = await import('./ghAuth.js')
|
||||||
execResult(null, ' gho_abc\n')
|
execResult(null, ' gho_abc\n')
|
||||||
|
|
||||||
expect(await readGhToken()).toBe('gho_abc')
|
expect(await readGhToken()).toEqual({ ok: true, token: 'gho_abc' })
|
||||||
|
|
||||||
const [file, args, opts] = mocks.execFile.mock.calls[0] as [string, string[], Record<string, unknown>]
|
const [file, args, opts] = mocks.execFile.mock.calls[0] as [string, string[], Record<string, unknown>]
|
||||||
expect(file).toMatch(/(?:^|\/)gh$/)
|
expect(file).toMatch(/(?:^|\/)gh$/)
|
||||||
expect(args).toEqual(['auth', 'token', '--hostname', 'github.com'])
|
expect(args).toEqual(['auth', 'token', '--hostname', 'github.com'])
|
||||||
expect(opts.shell).toBeUndefined()
|
expect(opts.shell).toBeUndefined()
|
||||||
|
expect(opts.timeout).toBe(10_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['gh 不存在', Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }), ''],
|
['gh 不存在', Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }), ''],
|
||||||
['退出码非 0(没登录)', Object.assign(new Error('exit 1'), { code: 1 }), ''],
|
['退出码非 0(没登录)', Object.assign(new Error('exit 1'), { code: 1 }), ''],
|
||||||
['输出为空', null, ' \n'],
|
['输出为空', null, ' \n'],
|
||||||
])('%s → undefined', async (_name, err, stdout) => {
|
])('%s → 没登录', async (_name, err, stdout) => {
|
||||||
const { readGhToken } = await import('./ghAuth.js')
|
const { GH_LOGIN_HINT, readGhToken } = await import('./ghAuth.js')
|
||||||
execResult(err, stdout)
|
execResult(err, stdout)
|
||||||
expect(await readGhToken()).toBeUndefined()
|
expect(await readGhToken()).toEqual({ ok: false, message: GH_LOGIN_HINT })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('10 秒没返回被结束 → 同样取不到 token,但提示说的是超时而不是没登录', async () => {
|
||||||
|
const { GH_LOGIN_HINT, readGhToken } = await import('./ghAuth.js')
|
||||||
|
execResult(Object.assign(new Error('Command failed: gh auth token'), { killed: true, signal: 'SIGTERM', code: null }))
|
||||||
|
|
||||||
|
const result = await readGhToken()
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
const message = result.ok ? '' : result.message
|
||||||
|
expect(message).not.toBe(GH_LOGIN_HINT)
|
||||||
|
expect(message).toContain('超时')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ghLoginCheckError', () => {
|
||||||
|
it('gitHub 回 401 是登录失效,不给说明,设置页按未登录显示', async () => {
|
||||||
|
const { ghLoginCheckError } = await import('./ghAuth.js')
|
||||||
|
expect(ghLoginCheckError(new ForgeApiError(401, 'Bad credentials'))).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['请求超时', new ForgeApiError(408, '请求 api.github.com 超时(15 秒内没有响应)'), '超时'],
|
||||||
|
['连不上', new TypeError('fetch failed'), 'fetch failed'],
|
||||||
|
['GitHub 出错', new ForgeApiError(502, 'Bad Gateway'), 'Bad Gateway'],
|
||||||
|
])('%s → 无法连接 GitHub,带上原因', async (_name, err, reason) => {
|
||||||
|
const { ghLoginCheckError } = await import('./ghAuth.js')
|
||||||
|
const message = ghLoginCheckError(err)
|
||||||
|
expect(message).toContain('无法连接 GitHub')
|
||||||
|
expect(message).toContain(reason)
|
||||||
|
expect(message).not.toContain('未登录')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
import { execFile } from 'node:child_process'
|
import { execFile } from 'node:child_process'
|
||||||
import { findExecutable } from '../../shared/executable'
|
import { findExecutable } from '../../shared/executable'
|
||||||
|
import { ForgeApiError } from '../types'
|
||||||
|
|
||||||
|
/** 等 `gh auth token` 返回的最长时间;gh 卡在钥匙串等地方时不能让看板一直等下去。 */
|
||||||
|
const GH_TOKEN_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
/** 没取到 gh 登录凭据时给人看的提示。 */
|
/** 没取到 gh 登录凭据时给人看的提示。 */
|
||||||
export const GH_LOGIN_HINT = '未登录 GitHub CLI,请在终端运行 `gh auth login` 登录后重试'
|
export const GH_LOGIN_HINT = '未登录 GitHub CLI,请在终端运行 `gh auth login` 登录后重试'
|
||||||
@@ -11,16 +15,43 @@ export const GH_LOGIN_HINT = '未登录 GitHub CLI,请在终端运行 `gh auth
|
|||||||
/** gh 登录已失效(GitHub 回 401)时给人看的提示。 */
|
/** gh 登录已失效(GitHub 回 401)时给人看的提示。 */
|
||||||
export const GH_RELOGIN_HINT = 'GitHub CLI 的登录已失效,请在终端运行 `gh auth login` 重新登录后重试'
|
export const GH_RELOGIN_HINT = 'GitHub CLI 的登录已失效,请在终端运行 `gh auth login` 重新登录后重试'
|
||||||
|
|
||||||
|
/** `gh auth token` 超时时给人看的提示,和没登录分开说,免得用户白白重新登录。 */
|
||||||
|
const GH_TIMEOUT_HINT = `\`gh auth token\` 超时(${GH_TOKEN_TIMEOUT_MS / 1000} 秒内没有返回),取不到 GitHub 凭据;请在终端运行 \`gh auth status\` 看看 gh 是否卡住,然后重试`
|
||||||
|
|
||||||
/** PATH 里找不到 gh 时再试的安装位置:Homebrew(Apple 芯片、Intel)和系统包管理器。 */
|
/** PATH 里找不到 gh 时再试的安装位置:Homebrew(Apple 芯片、Intel)和系统包管理器。 */
|
||||||
const GH_FALLBACKS = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh']
|
const GH_FALLBACKS = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh']
|
||||||
|
|
||||||
/** 取 gh 在 github.com 上登录的 token;gh 没装、没登录、执行失败或输出为空都返回 `undefined`。 */
|
/** [readGhToken] 的结果:取到了给 `token`,取不到给可以直接给人看的 `message`。 */
|
||||||
export function readGhToken(): Promise<string | undefined> {
|
export type GhToken = { ok: true, token: string } | { ok: false, message: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取 gh 在 github.com 上登录的 token。
|
||||||
|
*
|
||||||
|
* gh 没装、没登录、执行失败或输出为空,`message` 是 [GH_LOGIN_HINT];超时被结束时是超时的说明。
|
||||||
|
*/
|
||||||
|
export function readGhToken(): Promise<GhToken> {
|
||||||
const gh = findExecutable('gh', GH_FALLBACKS) ?? 'gh'
|
const gh = findExecutable('gh', GH_FALLBACKS) ?? 'gh'
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
execFile(gh, ['auth', 'token', '--hostname', 'github.com'], { timeout: 10_000, encoding: 'utf8' }, (err, stdout) => {
|
execFile(gh, ['auth', 'token', '--hostname', 'github.com'], { timeout: GH_TOKEN_TIMEOUT_MS, encoding: 'utf8' }, (err, stdout) => {
|
||||||
|
// `killed` 只在超时被 execFile 结束时为真。
|
||||||
|
if (err?.killed) {
|
||||||
|
resolve({ ok: false, message: GH_TIMEOUT_HINT })
|
||||||
|
return
|
||||||
|
}
|
||||||
const token = err ? '' : String(stdout).trim()
|
const token = err ? '' : String(stdout).trim()
|
||||||
resolve(token || undefined)
|
resolve(token ? { ok: true, token } : { ok: false, message: GH_LOGIN_HINT })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置页查 gh 账号失败时显示的说明;GitHub 回 401 说明登录失效,返回 `undefined`,让设置页按未登录显示。
|
||||||
|
*
|
||||||
|
* 其它失败(超时、连不上、GitHub 自己出错)都说成连不上,这时登录可能还有效,不能叫用户重新登录。
|
||||||
|
*/
|
||||||
|
export function ghLoginCheckError(err: unknown): string | undefined {
|
||||||
|
if (err instanceof ForgeApiError && err.status === 401)
|
||||||
|
return undefined
|
||||||
|
const reason = err instanceof Error ? err.message : String(err)
|
||||||
|
return `无法连接 GitHub,暂时确认不了登录状态:${reason}`
|
||||||
|
}
|
||||||
@@ -72,6 +72,57 @@ describe('请求头', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('超时', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
const timeout = (): DOMException => new DOMException('The operation was aborted due to timeout', 'TimeoutError')
|
||||||
|
|
||||||
|
it('rEST、GraphQL 和取原文的请求都带 15 秒超时', async () => {
|
||||||
|
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout')
|
||||||
|
const fetch = stubFetch(json({ login: 'cruldra' }), json(rawIssue(5)), json({ data: {} }), new Response('原文'))
|
||||||
|
const p = provider()
|
||||||
|
|
||||||
|
await p.getCurrentUser()
|
||||||
|
await p.deleteIssue(5)
|
||||||
|
await p.getRawFile('a.ts', 'main')
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(4)
|
||||||
|
for (let i = 0; i < 4; i++)
|
||||||
|
expect(calledInit(fetch, i).signal).toBeInstanceOf(AbortSignal)
|
||||||
|
expect(timeoutSpy.mock.calls.map(c => c[0])).toEqual([15_000, 15_000, 15_000, 15_000])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('等响应超时抛中性的 ForgeApiError:不是 401,说明里有超时', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeout()))
|
||||||
|
|
||||||
|
const err = await provider().getCurrentUser().then(() => undefined, (e: unknown) => e)
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(ForgeApiError)
|
||||||
|
expect(err).toMatchObject({ status: 408 })
|
||||||
|
expect((err as Error).message).toContain('超时')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('读响应体时超时也一样', async () => {
|
||||||
|
stubFetch(new Response(new ReadableStream({ start: c => c.error(timeout()) })))
|
||||||
|
|
||||||
|
const err = await provider().getCurrentUser().then(() => undefined, (e: unknown) => e)
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(ForgeApiError)
|
||||||
|
expect(err).toMatchObject({ status: 408 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('连不上等其它网络错误原样抛出', async () => {
|
||||||
|
const cause = new TypeError('fetch failed')
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(cause))
|
||||||
|
await expect(provider().getCurrentUser()).rejects.toBe(cause)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('取原文超时照旧返回空串', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeout()))
|
||||||
|
expect(await provider().getRawFile('a.ts', 'main')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('翻页', () => {
|
describe('翻页', () => {
|
||||||
it('每页 100 条,跟着 Link 的 rel="next" 原样请求下一页', async () => {
|
it('每页 100 条,跟着 Link 的 rel="next" 原样请求下一页', async () => {
|
||||||
const next = `${BASE}/issues?state=all&per_page=100&page=2`
|
const next = `${BASE}/issues?state=all&per_page=100&page=2`
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const API_HOST = 'api.github.com'
|
|||||||
const API_VERSION = '2022-11-28'
|
const API_VERSION = '2022-11-28'
|
||||||
/** GitHub 列表接口每页上限。 */
|
/** GitHub 列表接口每页上限。 */
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100
|
||||||
|
/** 一次请求从发出到读完响应最多等多久,网络卡住时看板不能一直转圈。 */
|
||||||
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
const AVATAR_HOST = 'avatars.githubusercontent.com'
|
const AVATAR_HOST = 'avatars.githubusercontent.com'
|
||||||
/** 卡片上的头像只有十几像素,要 64 像素的图在高分屏上也够清楚。 */
|
/** 卡片上的头像只有十几像素,要 64 像素的图在高分屏上也够清楚。 */
|
||||||
const AVATAR_SIZE = 64
|
const AVATAR_SIZE = 64
|
||||||
@@ -151,6 +153,23 @@ function errorText(body: string): string {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 请求超时换成中性的 [ForgeApiError](`status` 记 408),不会被当成登录失效;其它错误原样返回。 */
|
||||||
|
function timeoutAsApiError(err: unknown): unknown {
|
||||||
|
if (err instanceof DOMException && err.name === 'TimeoutError')
|
||||||
|
return new ForgeApiError(408, `请求 ${API_HOST} 超时(${REQUEST_TIMEOUT_MS / 1000} 秒内没有响应)`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读响应体里的 JSON;读到一半超时同样换成 [ForgeApiError]。 */
|
||||||
|
async function readJson<T>(res: Response): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await res.json() as T
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
throw timeoutAsApiError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class GitHubProvider implements ForgeProvider {
|
export class GitHubProvider implements ForgeProvider {
|
||||||
readonly kind = 'github' as const
|
readonly kind = 'github' as const
|
||||||
readonly capabilities = CAPABILITIES
|
readonly capabilities = CAPABILITIES
|
||||||
@@ -178,9 +197,19 @@ export class GitHubProvider implements ForgeProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 发请求并限时 [REQUEST_TIMEOUT_MS],计时一直覆盖到读完响应体;连不上等网络错误原样抛出。 */
|
||||||
|
private async request(url: string, init: RequestInit): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
throw timeoutAsApiError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async send(url: string, init: { method?: string, body?: unknown } = {}): Promise<Response> {
|
private async send(url: string, init: { method?: string, body?: unknown } = {}): Promise<Response> {
|
||||||
const hasBody = init.body !== undefined
|
const hasBody = init.body !== undefined
|
||||||
const res = await fetch(url, {
|
const res = await this.request(url, {
|
||||||
...(init.method ? { method: init.method } : {}),
|
...(init.method ? { method: init.method } : {}),
|
||||||
headers: this.headers({ json: hasBody }),
|
headers: this.headers({ json: hasBody }),
|
||||||
...(hasBody ? { body: JSON.stringify(init.body) } : {}),
|
...(hasBody ? { body: JSON.stringify(init.body) } : {}),
|
||||||
@@ -193,7 +222,7 @@ export class GitHubProvider implements ForgeProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getJson<T>(url: string): Promise<T> {
|
private async getJson<T>(url: string): Promise<T> {
|
||||||
return await (await this.send(url)).json() as T
|
return readJson<T>(await this.send(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -209,7 +238,7 @@ export class GitHubProvider implements ForgeProvider {
|
|||||||
const out: T[] = []
|
const out: T[] = []
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const res = await this.send(url.toString())
|
const res = await this.send(url.toString())
|
||||||
const batch = await res.json() as T[]
|
const batch = await readJson<T[]>(res)
|
||||||
if (!Array.isArray(batch))
|
if (!Array.isArray(batch))
|
||||||
return out
|
return out
|
||||||
out.push(...batch)
|
out.push(...batch)
|
||||||
@@ -287,7 +316,7 @@ export class GitHubProvider implements ForgeProvider {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
// GraphQL 出错时 HTTP 状态照样是 200,错误放在 errors 里。
|
// GraphQL 出错时 HTTP 状态照样是 200,错误放在 errors 里。
|
||||||
const { errors } = await res.json() as { errors?: Array<{ type?: string, message?: string }> }
|
const { errors } = await readJson<{ errors?: Array<{ type?: string, message?: string }> }>(res)
|
||||||
if (!errors?.length)
|
if (!errors?.length)
|
||||||
return
|
return
|
||||||
if (errors.some(e => e.type === 'FORBIDDEN'))
|
if (errors.some(e => e.type === 'FORBIDDEN'))
|
||||||
@@ -388,7 +417,7 @@ export class GitHubProvider implements ForgeProvider {
|
|||||||
const url = new URL(`${this.repoUrl}/contents/${encodePath(path)}`)
|
const url = new URL(`${this.repoUrl}/contents/${encodePath(path)}`)
|
||||||
url.searchParams.set('ref', ref)
|
url.searchParams.set('ref', ref)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url.toString(), { headers: this.headers({ accept: 'application/vnd.github.raw+json' }) })
|
const res = await this.request(url.toString(), { headers: this.headers({ accept: 'application/vnd.github.raw+json' }) })
|
||||||
return res.ok ? await res.text() : ''
|
return res.ok ? await res.text() : ''
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export interface ForgeCapabilities {
|
|||||||
mergeMethods: readonly MergeMethod[]
|
mergeMethods: readonly MergeMethod[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 代码托管接口返回非 2xx 时抛出,`status` 是 HTTP 状态码。 */
|
/** 代码托管接口返回非 2xx 时抛出,`status` 是 HTTP 状态码;请求超时也用它,`status` 记 408。 */
|
||||||
export class ForgeApiError extends Error {
|
export class ForgeApiError extends Error {
|
||||||
readonly status: number
|
readonly status: number
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import * as os from 'node:os'
|
|||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
import { commands, env, RelativePattern, Uri, workspace } from 'vscode'
|
import { commands, env, RelativePattern, Uri, workspace } from 'vscode'
|
||||||
import { clearIdentityCache, initIdentity, loginFor } from '../../auth/identity'
|
import { clearIdentityCache, initIdentity, lookupLogin } from '../../auth/identity'
|
||||||
import { getToken, getYouTrackToken, setToken, setYouTrackToken } from '../../auth/secrets'
|
import { getToken, getYouTrackToken, setToken, setYouTrackToken } from '../../auth/secrets'
|
||||||
import { listClaudeProfiles, setProfilesDirOverride } from '../../cc/profiles'
|
import { listClaudeProfiles, setProfilesDirOverride } from '../../cc/profiles'
|
||||||
import { resolveForge } from '../../forge/context'
|
import { resolveForge } from '../../forge/context'
|
||||||
|
import { GH_LOGIN_HINT, ghLoginCheckError } from '../../forge/github/ghAuth'
|
||||||
import { forgeKindForHost } from '../../forge/types'
|
import { forgeKindForHost } from '../../forge/types'
|
||||||
import { detectRepo } from '../../git/remote'
|
import { detectRepo } from '../../git/remote'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
@@ -257,7 +258,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
|
|||||||
const forgeKind = forgeKindForHost(host)
|
const forgeKind = forgeKindForHost(host)
|
||||||
const tok = forgeKind === 'gitea' && host ? await getToken(panel.context, host) : undefined
|
const tok = forgeKind === 'gitea' && host ? await getToken(panel.context, host) : undefined
|
||||||
const tokenSaved = !!tok && tok.length > 0
|
const tokenSaved = !!tok && tok.length > 0
|
||||||
const ghLogin = forgeKind === 'github' ? await currentGhLogin(panel) : undefined
|
const gh = forgeKind === 'github' ? await currentGhStatus(panel) : {}
|
||||||
const ytTok = s.youtrackBaseUrl.trim()
|
const ytTok = s.youtrackBaseUrl.trim()
|
||||||
? await getYouTrackToken(panel.context, youtrackHost(s.youtrackBaseUrl.trim()))
|
? await getYouTrackToken(panel.context, youtrackHost(s.youtrackBaseUrl.trim()))
|
||||||
: undefined
|
: undefined
|
||||||
@@ -269,7 +270,8 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
|
|||||||
type: 'settings/show',
|
type: 'settings/show',
|
||||||
host,
|
host,
|
||||||
forgeKind,
|
forgeKind,
|
||||||
...(ghLogin ? { ghLogin } : {}),
|
...(gh.login ? { ghLogin: gh.login } : {}),
|
||||||
|
...(gh.error ? { ghLoginError: gh.error } : {}),
|
||||||
canCancel: true,
|
canCancel: true,
|
||||||
tokenSaved,
|
tokenSaved,
|
||||||
webhookPort: s.webhookPort,
|
webhookPort: s.webhookPort,
|
||||||
@@ -312,18 +314,22 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 现查 gh 登录的 GitHub 账号,没登录或登录失效返回 `undefined`。
|
* 现查 gh 登录的 GitHub 账号:查到了给 `login`;gh 没登录或登录已失效时什么都不给,设置页按未登录显示。
|
||||||
*
|
*
|
||||||
* 用户可能刚在终端登录或换了账号,所以先丢掉身份缓存再查,顺带刷新状态栏。
|
* gh 取 token 超时、连不上 GitHub 时给 `error`,这时登录可能还有效,不能说成没登录。用户可能刚在终端登录或换了账号,
|
||||||
|
* 所以先丢掉身份缓存再查,顺带刷新状态栏。
|
||||||
*/
|
*/
|
||||||
async function currentGhLogin(panel: KanbanWebviewPanel): Promise<string | undefined> {
|
async function currentGhStatus(panel: KanbanWebviewPanel): Promise<{ login?: string, error?: string }> {
|
||||||
const resolved = await resolveForge(panel.context)
|
const resolved = await resolveForge(panel.context)
|
||||||
if (!resolved.ok)
|
if (!resolved.ok)
|
||||||
return undefined
|
return resolved.message === GH_LOGIN_HINT ? {} : { error: resolved.message }
|
||||||
clearIdentityCache(resolved.forge.host)
|
clearIdentityCache(resolved.forge.host)
|
||||||
const login = await loginFor(resolved.forge)
|
const found = await lookupLogin(resolved.forge)
|
||||||
void initIdentity(panel.context)
|
void initIdentity(panel.context)
|
||||||
return login
|
if (found.ok)
|
||||||
|
return { login: found.login }
|
||||||
|
const error = ghLoginCheckError(found.error)
|
||||||
|
return error ? { error } : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forces the open panel (if any) into the setup-auth state. */
|
/** Forces the open panel (if any) into the setup-auth state. */
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export type ExtensionToWebview
|
|||||||
forgeKind: ForgeKind
|
forgeKind: ForgeKind
|
||||||
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */
|
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */
|
||||||
ghLogin?: string
|
ghLogin?: string
|
||||||
|
/** 查 gh 账号时连不上 GitHub、超时这类失败的说明;有它时不能按没登录显示。 */
|
||||||
|
ghLoginError?: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
canCancel?: boolean
|
canCancel?: boolean
|
||||||
tokenSaved: boolean
|
tokenSaved: boolean
|
||||||
|
|||||||
@@ -580,6 +580,7 @@ export function App() {
|
|||||||
host={settings?.host ?? ''}
|
host={settings?.host ?? ''}
|
||||||
forgeKind={settings?.forgeKind ?? 'gitea'}
|
forgeKind={settings?.forgeKind ?? 'gitea'}
|
||||||
ghLogin={settings?.ghLogin}
|
ghLogin={settings?.ghLogin}
|
||||||
|
ghLoginError={settings?.ghLoginError}
|
||||||
errorMessage={settings?.errorMessage}
|
errorMessage={settings?.errorMessage}
|
||||||
canCancel={settings?.canCancel}
|
canCancel={settings?.canCancel}
|
||||||
initialTokenSaved={settings?.tokenSaved ?? false}
|
initialTokenSaved={settings?.tokenSaved ?? false}
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ describe('设置面板的仓库连接', () => {
|
|||||||
expect(html).toContain('未登录')
|
expect(html).toContain('未登录')
|
||||||
expect(html).toContain('gh auth login')
|
expect(html).toContain('gh auth login')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('gitHub 仓库连不上 GitHub 时显示原因,不说成没登录', () => {
|
||||||
|
const html = render({ host: 'github.com', forgeKind: 'github', ghLoginError: '无法连接 GitHub,暂时确认不了登录状态:请求超时', initialTokenSaved: false })
|
||||||
|
expect(html).toContain('无法连接 GitHub')
|
||||||
|
expect(html).toContain('请求超时')
|
||||||
|
expect(html).not.toContain('未登录')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('webhook 配置说明', () => {
|
describe('webhook 配置说明', () => {
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export interface SettingsModalProps {
|
|||||||
forgeKind: ForgeKind
|
forgeKind: ForgeKind
|
||||||
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。只在 GitHub 仓库下有意义。 */
|
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。只在 GitHub 仓库下有意义。 */
|
||||||
ghLogin?: string
|
ghLogin?: string
|
||||||
|
/** 查 gh 账号时连不上 GitHub、超时这类失败的说明;有它时不能按没登录显示。 */
|
||||||
|
ghLoginError?: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
canCancel?: boolean
|
canCancel?: boolean
|
||||||
initialTokenSaved: boolean
|
initialTokenSaved: boolean
|
||||||
@@ -168,7 +170,7 @@ const FIELD_TO_GROUP: Record<ErrorKey, GroupKey> = {
|
|||||||
webhookPort: 'network',
|
webhookPort: 'network',
|
||||||
}
|
}
|
||||||
|
|
||||||
function GhLoginStatus({ login }: { login?: string }): ReactElement {
|
function GhLoginStatus({ login, error }: { login?: string, error?: string }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 text-xs">
|
<div className="mb-3 text-xs">
|
||||||
<span className="mb-1 block opacity-80">GitHub 账号</span>
|
<span className="mb-1 block opacity-80">GitHub 账号</span>
|
||||||
@@ -180,15 +182,17 @@ function GhLoginStatus({ login }: { login?: string }): ReactElement {
|
|||||||
<b>{login}</b>
|
<b>{login}</b>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
: (
|
: error
|
||||||
<span className="text-state-red">
|
? <span className="text-state-red">{error}</span>
|
||||||
未登录或登录已失效:请在终端运行
|
: (
|
||||||
{' '}
|
<span className="text-state-red">
|
||||||
<code>gh auth login</code>
|
未登录或登录已失效:请在终端运行
|
||||||
{' '}
|
{' '}
|
||||||
登录 github.com,然后回到看板点刷新
|
<code>gh auth login</code>
|
||||||
</span>
|
{' '}
|
||||||
)}
|
登录 github.com,然后回到看板点刷新
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="mt-1 block text-[10px] opacity-60">
|
<span className="mt-1 block text-[10px] opacity-60">
|
||||||
GitHub 仓库直接用 GitHub CLI(gh)的登录,插件不另存 token;换账号用
|
GitHub 仓库直接用 GitHub CLI(gh)的登录,插件不另存 token;换账号用
|
||||||
{' '}
|
{' '}
|
||||||
@@ -283,6 +287,7 @@ export function SettingsModal({
|
|||||||
host: initialHost,
|
host: initialHost,
|
||||||
forgeKind,
|
forgeKind,
|
||||||
ghLogin,
|
ghLogin,
|
||||||
|
ghLoginError,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
canCancel,
|
canCancel,
|
||||||
initialTokenSaved,
|
initialTokenSaved,
|
||||||
@@ -581,7 +586,7 @@ export function SettingsModal({
|
|||||||
{activeGroup === 'auth' && (
|
{activeGroup === 'auth' && (
|
||||||
<>
|
<>
|
||||||
{forgeKind === 'github'
|
{forgeKind === 'github'
|
||||||
? <GhLoginStatus login={ghLogin} />
|
? <GhLoginStatus login={ghLogin} error={ghLoginError} />
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<Field label="Gitea Host" error={errors.host}>
|
<Field label="Gitea Host" error={errors.host}>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface SettingsOverlayState {
|
|||||||
host: string
|
host: string
|
||||||
forgeKind: ForgeKind
|
forgeKind: ForgeKind
|
||||||
ghLogin?: string
|
ghLogin?: string
|
||||||
|
ghLoginError?: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
canCancel: boolean
|
canCancel: boolean
|
||||||
tokenSaved: boolean
|
tokenSaved: boolean
|
||||||
@@ -961,6 +962,7 @@ export function useIssues(): UseIssuesResult {
|
|||||||
host: msg.host,
|
host: msg.host,
|
||||||
forgeKind: msg.forgeKind,
|
forgeKind: msg.forgeKind,
|
||||||
ghLogin: msg.ghLogin,
|
ghLogin: msg.ghLogin,
|
||||||
|
ghLoginError: msg.ghLoginError,
|
||||||
errorMessage: msg.errorMessage,
|
errorMessage: msg.errorMessage,
|
||||||
canCancel: msg.canCancel === true,
|
canCancel: msg.canCancel === true,
|
||||||
tokenSaved: msg.tokenSaved,
|
tokenSaved: msg.tokenSaved,
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export type ExtensionToWebview
|
|||||||
forgeKind: ForgeKind
|
forgeKind: ForgeKind
|
||||||
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */
|
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */
|
||||||
ghLogin?: string
|
ghLogin?: string
|
||||||
|
/** 查 gh 账号时连不上 GitHub、超时这类失败的说明;有它时不能按没登录显示。 */
|
||||||
|
ghLoginError?: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
canCancel?: boolean
|
canCancel?: boolean
|
||||||
tokenSaved: boolean
|
tokenSaved: boolean
|
||||||
|
|||||||
Reference in new issue
Block a user