Skip to content
Merged
145 changes: 145 additions & 0 deletions apps/desktop/src/main/github-issue/__tests__/myIssuesService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,151 @@ describe('MyIssuesService.list', () => {
// 身份查到了就如实回传,只是这一次没并进内容。
expect(result.githubEnhancement).toEqual({ login: 'octocat', source: 'ghost' });
expect(result.items).toEqual([]);
// 没有兜底通道可用 ⇒ 这一路算「配了却没用上」,UI 要能据此说明。
expect(result.githubEnhancementFailed).toBe(true);
});

describe('主通道搜不到时的兜底', () => {
/**
* 现实成因(实测):插件 PAT 是 fine-grained token,`get_current_user` 正常、搜本仓
* 却被 GitHub 以 422 拒绝(未显式授权的仓库即使公开也搜不到)。上一版就此整路放弃,
* 而本机 gh CLI 明明有权限 —— 用户于是在页面上看到「还没有提交过 Issue」,
* 而他 GitHub 名下有 34 条。
*/
it('主通道失败 → 换兜底通道,拿到的内容照常并入,且不算失败', async () => {
const searchAuthoredIssues = vi.fn(async () => {
throw new Error('HTTP 422 Validation Failed');
});
const searchAuthoredIssuesFallback = vi.fn(async () => ({
issues: [remoteIssue({ number: 34 })],
totalCount: 1,
}));
const service = new MyIssuesService(
makeDeps({
resolveGithubEnhancement: async () => GHOST_VIEWER,
searchAuthoredIssues,
searchAuthoredIssuesFallback,
}),
);

const result = await service.list();
expect(searchAuthoredIssuesFallback).toHaveBeenCalledWith('octocat');
expect(result.items.map((i) => i.number)).toEqual([34]);
expect(result.items[0]!.sources).toEqual(['github-account']);
// 回退成功 = 用户拿到了数据,没有可见损失,不该提示。
expect(result.githubEnhancementFailed).toBe(false);
expect(result.degraded).toBeNull();
});

it('兜底通道不可用(没装 / 没登录 gh)→ 标记失败,主列表照常', async () => {
const service = new MyIssuesService(
makeDeps({
readLedger: () => [ledgerRecord()],
resolveGithubEnhancement: async () => GHOST_VIEWER,
searchAuthoredIssues: async () => {
throw new Error('HTTP 422');
},
searchAuthoredIssuesFallback: async () => null,
}),
);

const result = await service.list();
expect(result.githubEnhancementFailed).toBe(true);
// 账本那一半照常出 —— 增强失败绝不拖累主列表。
expect(result.items.map((i) => i.number)).toEqual([1001]);
expect(result.degraded).toBeNull();
});

it('兜底通道自己也抛错 → 标记失败,不把整页打挂', async () => {
const service = new MyIssuesService(
makeDeps({
resolveGithubEnhancement: async () => GHOST_VIEWER,
searchAuthoredIssues: async () => {
throw new Error('HTTP 422');
},
searchAuthoredIssuesFallback: async () => {
throw new Error('gh exploded');
},
}),
);

await expect(service.list()).resolves.toMatchObject({
githubEnhancementFailed: true,
degraded: null,
items: [],
});
});

it('gh-cli 主通道失败时不调兜底 —— 它自己就是兜底,没有下一条可换', async () => {
const searchAuthoredIssuesFallback = vi.fn(async () => ({
issues: [remoteIssue({ number: 99 })],
totalCount: 1,
}));
const service = new MyIssuesService(
makeDeps({
resolveGithubEnhancement: async () => ({ source: 'gh-cli', login: 'octocat', token: 't' }),
searchAuthoredIssues: async () => {
throw new Error('network down');
},
searchAuthoredIssuesFallback,
}),
);

const result = await service.list();
expect(searchAuthoredIssuesFallback).not.toHaveBeenCalled();
expect(result.githubEnhancementFailed).toBe(true);
});

it('主通道成功时不碰兜底通道', async () => {
const searchAuthoredIssuesFallback = vi.fn(async () => null);
const service = new MyIssuesService(
makeDeps({
resolveGithubEnhancement: async () => GHOST_VIEWER,
searchAuthoredIssues: async () => ({ issues: [remoteIssue()], totalCount: 1 }),
searchAuthoredIssuesFallback,
}),
);

const result = await service.list();
expect(searchAuthoredIssuesFallback).not.toHaveBeenCalled();
expect(result.githubEnhancementFailed).toBe(false);
});

it('没配增强时既不搜也不算失败 —— 没配是正常状态', async () => {
const searchAuthoredIssuesFallback = vi.fn(async () => null);
const service = new MyIssuesService(
makeDeps({ resolveGithubEnhancement: async () => null, searchAuthoredIssuesFallback }),
);

const result = await service.list();
expect(searchAuthoredIssuesFallback).not.toHaveBeenCalled();
expect(result.githubEnhancement).toBeNull();
expect(result.githubEnhancementFailed).toBe(false);
});

it('兜底也算在同一次总 deadline 内,不给增强第二份预算', async () => {
// 两段各起计时器的写法会让页面最坏等两倍时长(#1103 review 里出现过)。
const service = new MyIssuesService(
makeDeps({
readLedger: () => [ledgerRecord()],
enhancementTimeoutMs: 40,
resolveGithubEnhancement: async () => GHOST_VIEWER,
searchAuthoredIssues: async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
throw new Error('HTTP 422');
},
// 主通道已用掉 30ms,兜底再要 60ms —— 合计必须被 40ms 的总预算切断。
searchAuthoredIssuesFallback: async () => {
await new Promise((resolve) => setTimeout(resolve, 60));
return { issues: [remoteIssue({ number: 34 })], totalCount: 1 };
},
}),
);

const result = await service.list();
expect(result.items.map((i) => i.number)).toEqual([1001]);
expect(result.githubEnhancementFailed).toBe(true);
});
});

it('任一路远端总数多于返回条数时标 truncated', async () => {
Expand Down
56 changes: 49 additions & 7 deletions apps/desktop/src/main/github-issue/myIssuesRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,11 @@ const GHOST_IDENTITY_TIMEOUT_MS = 5_000;
/**
* 增强搜索的插件通道超时。service 层另有一道整体超时兜着,但这里也必须传 ——
* 那道只是放弃等待,这道才真正让插件调用自己了结(通道默认 330s)。
*
* 刻意不占满 service 那 8s 预算:插件搜不到时还要走 gh CLI 兜底,两段合计必须留在
* 同一次总 deadline 内。权限类失败是立即 422,所以正常情况下兜底有近 8s 可用。
*/
const GHOST_SEARCH_TIMEOUT_MS = 6_000;
const GHOST_SEARCH_TIMEOUT_MS = 4_000;

let serviceInstance: MyIssuesService | null = null;

Expand All @@ -70,6 +73,7 @@ export function getMyIssuesService(): MyIssuesService {
fetchPlatformIssues: fetchPlatformIssues,
resolveGithubEnhancement: resolveGithubEnhancement,
searchAuthoredIssues: searchAuthoredIssues,
searchAuthoredIssuesFallback: searchAuthoredIssuesFallback,
readScope: activeOwnerScopeKey,
});
}
Expand Down Expand Up @@ -119,6 +123,13 @@ function mapPlatformFailure(err: unknown): MyIssuesDegradedReason {
return 'fetch-failed';
}

/**
* 身份解析:插件优先,本机 gh CLI 兜底。
*
* 这里锁定的只是**身份来源**,不代表数据也只能从那条通道取 —— 两者曾被混为一谈:
* 插件报出身份后 gh CLI 就再也不会被尝试,于是 PAT 搜不动本仓时整路放弃。取数的回退
* 在 service 层(searchAuthoredIssuesFallback),与身份来源解耦。
*/
async function resolveGithubEnhancement(): Promise<GithubEnhancementViewer | null> {
const ghostDeps = getSharedGithubUserSubmitterDeps();
// workdir 传 null:/issues 是全局页面,没有会话工作目录上下文。
Expand Down Expand Up @@ -159,16 +170,33 @@ async function readGhostViewerLogin(
}
}

async function searchAuthoredIssues(
viewer: GithubEnhancementViewer,
login: string,
): Promise<RemoteIssuePage> {
/**
* 两条通道共用同一份查询参数 —— 各写一份迟早会漂移(而且 login 的校验漏在哪条上,
* 那条就能把 login 里的空格 / 冒号当查询限定符送出去)。
*/
function authoredSearchParams(login: string): {
q: string;
sort: string;
order: 'desc';
per_page: number;
} {
if (!GITHUB_LOGIN_RE.test(login)) {
throw new Error(`refusing to search with a malformed GitHub login: ${login}`);
}
const { owner, repo } = MY_ISSUES_REPOSITORY;
const q = `repo:${owner}/${repo} is:issue author:${login}`;
const params = { q, sort: 'created', order: 'desc' as const, per_page: SEARCH_PAGE_SIZE };
return {
q: `repo:${owner}/${repo} is:issue author:${login}`,
sort: 'created',
order: 'desc',
per_page: SEARCH_PAGE_SIZE,
};
}

async function searchAuthoredIssues(
viewer: GithubEnhancementViewer,
login: string,
): Promise<RemoteIssuePage> {
const params = authoredSearchParams(login);

if (viewer.source === 'ghost') {
const operation = await callCindyGithubOperation(
Expand All @@ -184,6 +212,20 @@ async function searchAuthoredIssues(
return parseIssuePage(await repoScopedClient(requireToken(viewer)).searchIssuesAndPRs(params));
}

/**
* 兜底通道:本机 `gh auth token`。插件 PAT 搜不动本仓时(fine-grained token 对未显式
* 授权的仓库返回 422,即使仓库公开)由它接手 —— gh 的 OAuth token 权限完整。
*
* 返回 null = 没装 / 没登录 gh,没有兜底可用。ghCliTokenSource 会探测 homebrew 等绝对
* 路径,所以 GUI 启动的正式版(PATH 精简)同样能找到 gh。
*/
async function searchAuthoredIssuesFallback(login: string): Promise<RemoteIssuePage | null> {
const token = await getSharedGhCliTokenSource().readToken();
if (!token) return null;
const page = await repoScopedClient(token).searchIssuesAndPRs(authoredSearchParams(login));
return parseIssuePage(page);
}

function requireToken(viewer: GithubEnhancementViewer): string {
if (!viewer.token) throw new Error('gh-cli viewer is missing its token');
return viewer.token;
Expand Down
71 changes: 63 additions & 8 deletions apps/desktop/src/main/github-issue/myIssuesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ export interface MyIssuesServiceDeps {
viewer: GithubEnhancementViewer,
login: string,
) => Promise<RemoteIssuePage>;
/**
* 主通道搜索失败时的**兜底通道**(本机 gh CLI)。返回 null = 没有兜底可用。
*
* 为什么必须有:身份能报出来 ≠ 这一路能查到数据。插件 PAT 若是 fine-grained
* token,`get_current_user` 正常、搜本仓却被 GitHub 以 422 拒绝(未显式授权的仓库
* 即使公开也搜不到)—— 上一版就此整路放弃,而本机 gh CLI 明明有权限。
*
* 分开注入而不是让 searchAuthoredIssues 内部消化:runtime 是真实接线、不进单测,
* 「主通道失败必须换通道再试」这条不变量只有放这一层才钉得住。
*/
searchAuthoredIssuesFallback?: (login: string) => Promise<RemoteIssuePage | null>;
/**
* 当前账号作用域标识(data owner + session generation)。**这是安全边界**:
* issue 列表含标题、编号与 GitHub 用户名,属于账号私有数据。服务是进程级单例,
Expand Down Expand Up @@ -224,6 +235,7 @@ export class MyIssuesService {
githubEnhancement: enhancement.viewer
? { login: enhancement.viewer.login, source: enhancement.viewer.source }
: null,
githubEnhancementFailed: enhancement.failed,
degraded: platform.degraded,
truncated: platform.truncated || enhancement.truncated,
};
Expand Down Expand Up @@ -291,13 +303,16 @@ export class MyIssuesService {
* 整页遮住。超时、失败、没配置三种情况对用户是同一个结果 ——「这次没有增强」,
* 主列表照常出。
*
* 注:runtime 侧另给插件调用传了各自的 timeoutMs(身份 5s / 搜索 6s),那是让**通道
* 自己了结**,与这里的页面等待上限目的不同,不能互相替代。
* 注:runtime 侧另给插件调用传了各自的 timeoutMs(身份 5s / 搜索 4s),那是让**通道
* 自己了结**,与这里的页面等待上限目的不同,不能互相替代。搜索那档留 4s 而不是占满,
* 是为了给下面的兜底通道留出预算 —— 两段合计仍在这一次总 deadline 内。
*/
private async loadGithubEnhancement(): Promise<{
viewer: GithubEnhancementViewer | null;
issues: RemoteIssue[];
truncated: boolean;
/** 配置了却没能用上(主通道失败且兜底也没救回来)。没配 / 回退成功都是 false。 */
failed: boolean;
}> {
// 总超时触发时也要能回传已经解析成功的身份:header 照常显示并入了谁名下的 issue,
// 只是这一次没并进内容。所以把它记在闭包外。
Expand All @@ -306,20 +321,60 @@ export class MyIssuesService {
return await this.withDeadline(async () => {
resolved = await this.deps.resolveGithubEnhancement();
const viewer = resolved;
if (!viewer) return { viewer: null, issues: [], truncated: false };
// 没配增强是**正常状态**,不是失败。
if (!viewer) return { viewer: null, issues: [], truncated: false, failed: false };
try {
const page = await this.deps.searchAuthoredIssues(viewer, viewer.login);
return { viewer, issues: page.issues, truncated: isTruncated(page) };
return { viewer, issues: page.issues, truncated: isTruncated(page), failed: false };
} catch (err) {
// 搜索失败(非超时)不算列表降级 —— 主路径是平台通道。
log.debug('github enhancement search failed', { error: errorText(err) });
return { viewer, issues: [], truncated: false };
// 提到 warn:身份能报出来却搜不到是异常,而这条路的失败对用户是静默的 ——
// 记 debug 等于线上不可诊断(排查这个 bug 时日志里就只有平台通道的 404)。
log.warn('github enhancement search failed; trying the fallback channel', {
source: viewer.source,
error: errorText(err),
Comment thread
dashhuang marked this conversation as resolved.
Outdated
});
return await this.searchViaFallback(viewer);
Comment thread
dashhuang marked this conversation as resolved.
}
}, this.deps.enhancementTimeoutMs ?? DEFAULT_ENHANCEMENT_TIMEOUT_MS, 'enhancement');
} catch (err) {
// 没有 GitHub 身份是正常状态;解析失败与总超时同样只是「这次没有增强」。
log.debug('github enhancement unavailable', { error: errorText(err) });
return { viewer: resolved, issues: [], truncated: false };
// 身份已经解析出来却走到这里 = 配了但这次用不上(搜索连兜底一起超时),
// 要让 UI 有机会说明;身份都没拿到时无从区分「没配」与「配了失效」,保持静默。
return { viewer: resolved, issues: [], truncated: false, failed: resolved !== null };
}
}

/**
* 主通道搜不到时换本机 gh CLI 再试一次。
*
* 只对 `ghost` 主通道有意义 —— `gh-cli` 自己就是兜底,失败了没有下一条通道可换。
* 兜底不可用(没装 / 没登录 gh)或它也失败 ⇒ `failed: true`,让 UI 说明这一路
* 配了却没用上;回退成功 ⇒ `failed: false`,用户已经拿到数据,没有可见损失就不提示。
*/
private async searchViaFallback(viewer: GithubEnhancementViewer): Promise<{
viewer: GithubEnhancementViewer;
issues: RemoteIssue[];
truncated: boolean;
failed: boolean;
}> {
const fallback = this.deps.searchAuthoredIssuesFallback;
if (!fallback || viewer.source !== 'ghost') {
return { viewer, issues: [], truncated: false, failed: true };
}
try {
const page = await fallback(viewer.login);
if (!page) {
log.warn('no fallback channel available for the github enhancement');
return { viewer, issues: [], truncated: false, failed: true };
}
log.info('github enhancement recovered through the fallback channel', {
count: page.issues.length,
});
return { viewer, issues: page.issues, truncated: isTruncated(page), failed: false };
} catch (err) {
log.warn('github enhancement fallback search failed too', { error: errorText(err) });
return { viewer, issues: [], truncated: false, failed: true };
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import type { MyIssuesResult } from '../../../shared/myIssues';
import { handleMyIssuesList } from '../my-issues';

function emptyResult(): MyIssuesResult {
return { items: [], githubEnhancement: null, degraded: null, truncated: false };
return {
items: [],
githubEnhancement: null,
githubEnhancementFailed: false,
degraded: null,
truncated: false,
};
}

describe('handleMyIssuesList', () => {
Expand Down Expand Up @@ -61,6 +67,7 @@ describe('handleMyIssuesList', () => {
error: 'unexpected',
items: [],
githubEnhancement: null,
githubEnhancementFailed: false,
degraded: null,
truncated: false,
});
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/maker-ipc/my-issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type MyIssuesListResponse =
error: MyIssuesErrorCode;
items: [];
githubEnhancement: null;
githubEnhancementFailed: false;
degraded: null;
truncated: false;
};
Expand Down Expand Up @@ -62,6 +63,7 @@ export async function handleMyIssuesList(
error: stale ? 'stale-account-scope' : 'unexpected',
items: [],
githubEnhancement: null,
githubEnhancementFailed: false,
degraded: null,
truncated: false,
};
Expand Down
Loading