From c5777aff17340fb95e7a95f96b5c426411b471a6 Mon Sep 17 00:00:00 2001 From: unclee Date: Tue, 7 Apr 2026 20:30:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=90=AF=E5=8A=A8=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8F=91=E7=8E=B0=20GitHub=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=BB=84=E7=BB=87=EF=BC=8C=E8=A7=A3=E5=86=B3=E5=85=A8?= =?UTF-8?q?=E6=96=B0=E7=8E=AF=E5=A2=83=E6=90=9C=E4=B8=8D=E5=88=B0=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全新环境下 .repo-cache 为空,listKnownOrgs 返回空数组, 导致全局搜索无法匹配到组织内仓库(如 taptap/sce-tools)。 现在启动时通过 gh api 自动获取用户所属组织和用户名, 与缓存目录发现的组织合并,注入系统提示词的搜索指令中。 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/claude/__tests__/github-orgs.test.ts | 208 +++++++++++++++++++++++ src/claude/executor.ts | 52 +++++- src/index.ts | 4 + 3 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 src/claude/__tests__/github-orgs.test.ts diff --git a/src/claude/__tests__/github-orgs.test.ts b/src/claude/__tests__/github-orgs.test.ts new file mode 100644 index 00000000..e7bb59c3 --- /dev/null +++ b/src/claude/__tests__/github-orgs.test.ts @@ -0,0 +1,208 @@ +// @ts-nocheck — test file, vitest uses esbuild transform +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// ============================================================ +// Mocks +// ============================================================ + +vi.mock('../../config.js', () => ({ + config: { + claude: { defaultWorkDir: '/tmp/work', timeoutSeconds: 300, model: 'claude-opus-4-6', thinking: 'adaptive', effort: 'max', maxTurns: 500, maxBudgetUsd: 50, apiBaseUrl: '' }, + repoCache: { dir: '/repos/cache' }, + workspace: { baseDir: '/tmp/workspaces' }, + feishu: { tools: { enabled: false, doc: true, wiki: true, drive: true, bitable: true } }, + cron: { enabled: false }, + }, +})); + +vi.mock('../../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../workspace/tool.js', () => ({ + createWorkspaceMcpServer: vi.fn(), +})); + +vi.mock('../../feishu/tools/index.js', () => ({ + createFeishuToolsMcpServer: vi.fn(), +})); + +vi.mock('../../workspace/isolation.js', () => ({ + isAutoWorkspacePath: vi.fn(() => false), + isServiceOwnRepo: () => false, + isInsideSourceRepo: vi.fn(() => false), +})); + +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + query: vi.fn(), +})); + +vi.mock('../../cron/tool.js', () => ({ + createCronMcpServer: vi.fn(), +})); + +vi.mock('../../cron/init.js', () => ({ + getCronScheduler: vi.fn(() => null), +})); + +vi.mock('../../memory/init.js', () => ({ + getMemoryStore: vi.fn(() => null), + getHybridSearch: vi.fn(() => null), + isMemoryEnabled: vi.fn(() => false), +})); + +vi.mock('../../memory/tools/memory-search.js', () => ({ + createMemorySearchMcpServer: vi.fn(), +})); + +vi.mock('../../feishu/client.js', () => ({ + feishuClientContext: { getStore: vi.fn(() => undefined) }, +})); + +// Mock node:child_process execFile +const mockExecFile = vi.fn(); +vi.mock('node:child_process', () => ({ + execFile: (...args: unknown[]) => mockExecFile(...args), +})); + +import { initGitHubOrgCache, listKnownOrgs, _resetGitHubOrgCache } from '../executor.js'; + +// ============================================================ +// Tests +// ============================================================ + +describe('GitHub org auto-discovery', () => { + beforeEach(() => { + _resetGitHubOrgCache(null); + mockExecFile.mockReset(); + }); + + describe('initGitHubOrgCache', () => { + it('should fetch orgs and user login via gh CLI', async () => { + // Mock two execFile calls: user/orgs and user + mockExecFile + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + cb(null, 'taptap\nEpicGames\n'); + }) + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + cb(null, 'lishuceo\n'); + }); + + await initGitHubOrgCache(); + + // Verify gh was called correctly + expect(mockExecFile).toHaveBeenCalledTimes(2); + expect(mockExecFile).toHaveBeenCalledWith( + 'gh', ['api', 'user/orgs', '--jq', '.[].login'], + expect.objectContaining({ timeout: 10_000 }), + expect.any(Function), + ); + expect(mockExecFile).toHaveBeenCalledWith( + 'gh', ['api', 'user', '--jq', '.login'], + expect.objectContaining({ timeout: 10_000 }), + expect.any(Function), + ); + + // Verify cached orgs are available via listKnownOrgs + const orgs = listKnownOrgs('/nonexistent'); + expect(orgs).toContain('github.com/taptap'); + expect(orgs).toContain('github.com/EpicGames'); + expect(orgs).toContain('github.com/lishuceo'); + }); + + it('should deduplicate when user login is also in orgs', async () => { + mockExecFile + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + cb(null, 'myorg\nmyuser\n'); + }) + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + cb(null, 'myuser\n'); + }); + + await initGitHubOrgCache(); + + const orgs = listKnownOrgs('/nonexistent'); + const myuserCount = orgs.filter(o => o === 'github.com/myuser').length; + expect(myuserCount).toBe(1); + }); + + it('should not crash when gh CLI fails', async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + cb(new Error('gh not found')); + }); + + // Should not throw + await initGitHubOrgCache(); + + // listKnownOrgs should still work (returns empty from nonexistent cache dir) + const orgs = listKnownOrgs('/nonexistent'); + expect(orgs).toEqual([]); + }); + }); + + describe('listKnownOrgs', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'org-test-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should discover orgs from cache directory structure', () => { + // Create cache structure: github.com/taptap/ + mkdirSync(join(tmpDir, 'github.com', 'taptap'), { recursive: true }); + mkdirSync(join(tmpDir, 'github.com', 'facebook'), { recursive: true }); + + const orgs = listKnownOrgs(tmpDir); + expect(orgs).toContain('github.com/taptap'); + expect(orgs).toContain('github.com/facebook'); + }); + + it('should merge GitHub API orgs with cache directory orgs', () => { + // Set up API-discovered orgs + _resetGitHubOrgCache(['github.com/api-org']); + + // Set up cache directory orgs + mkdirSync(join(tmpDir, 'github.com', 'cache-org'), { recursive: true }); + + const orgs = listKnownOrgs(tmpDir); + expect(orgs).toContain('github.com/api-org'); + expect(orgs).toContain('github.com/cache-org'); + }); + + it('should deduplicate across API and cache sources', () => { + _resetGitHubOrgCache(['github.com/taptap']); + mkdirSync(join(tmpDir, 'github.com', 'taptap'), { recursive: true }); + + const orgs = listKnownOrgs(tmpDir); + const count = orgs.filter(o => o === 'github.com/taptap').length; + expect(count).toBe(1); + }); + + it('should put API-discovered orgs before cache orgs', () => { + _resetGitHubOrgCache(['github.com/api-first']); + mkdirSync(join(tmpDir, 'github.com', 'cache-second'), { recursive: true }); + + const orgs = listKnownOrgs(tmpDir); + const apiIdx = orgs.indexOf('github.com/api-first'); + const cacheIdx = orgs.indexOf('github.com/cache-second'); + expect(apiIdx).toBeLessThan(cacheIdx); + }); + + it('should return empty array for nonexistent cache dir', () => { + const orgs = listKnownOrgs('/nonexistent/path'); + expect(orgs).toEqual([]); + }); + }); +}); diff --git a/src/claude/executor.ts b/src/claude/executor.ts index c00bc48b..b7a0d9c1 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -2,6 +2,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; import type { Query, SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import { mkdirSync, existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; +import { execFile as execFileCb } from 'node:child_process'; import { createHash } from 'node:crypto'; import { config } from '../config.js'; import { logger } from '../utils/logger.js'; @@ -219,20 +220,61 @@ function listAvailableProjects(projectsDir: string): string[] { } } -/** 从缓存目录提取常用 GitHub org(best-effort) */ -function listKnownOrgs(cacheDir: string): string[] { +/** gh CLI 自动发现的用户组织缓存 */ +let cachedGitHubOrgs: string[] | null = null; + +/** 通过 gh CLI 获取当前用户所属的 GitHub 组织 + 用户名 */ +function ghExec(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFileCb('gh', args, { timeout: 10_000 }, (err, stdout) => { + if (err) reject(err); + else resolve(stdout.trim()); + }); + }); +} + +export async function initGitHubOrgCache(): Promise { + try { + const [orgsOutput, login] = await Promise.all([ + ghExec(['api', 'user/orgs', '--jq', '.[].login']), + ghExec(['api', 'user', '--jq', '.login']), + ]); + const orgs = orgsOutput.split('\n').filter(Boolean); + if (login) orgs.push(login); + cachedGitHubOrgs = [...new Set(orgs)].map(o => `github.com/${o}`); + logger.info({ orgs: cachedGitHubOrgs }, 'GitHub org cache initialized'); + } catch (err) { + logger.warn({ err }, 'Failed to fetch GitHub orgs (gh CLI may not be configured)'); + } +} + +/** 测试辅助:重置 GitHub org 缓存 */ +export function _resetGitHubOrgCache(orgs?: string[] | null): void { + cachedGitHubOrgs = orgs ?? null; +} + +/** 从缓存目录 + GitHub API 提取已知 org(best-effort) */ +export function listKnownOrgs(cacheDir: string): string[] { + const orgSet = new Set(); + + // 1. GitHub API 发现的组织(优先) + if (cachedGitHubOrgs) { + for (const org of cachedGitHubOrgs) orgSet.add(org); + } + + // 2. 从 .repo-cache 目录结构提取 try { const hosts = readdirSync(cacheDir, { withFileTypes: true }).filter(d => d.isDirectory() && !d.name.startsWith('.')); - const orgSet = new Set(); for (const host of hosts) { if (!host.name.includes('.')) continue; const orgs = readdirSync(join(cacheDir, host.name), { withFileTypes: true }).filter(d => d.isDirectory()); for (const org of orgs) orgSet.add(`${host.name}/${org.name}`); } - return [...orgSet].slice(0, 10); } catch { - return []; + // cache dir may not exist yet } + + return [...orgSet].slice(0, 20); } /** 构建工作区管理系统提示词(注入实际目录路径 + 可用项目列表) */ diff --git a/src/index.ts b/src/index.ts index d6cc7f5d..67d9907b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { initializeMemory, closeMemory, runMemoryMaintenance } from './memory/in import { warmup as warmupQuickAck } from './utils/quick-ack.js'; import { initializeCron, closeCron, cleanCronRuns } from './cron/init.js'; import { scanAndSyncRegistry } from './workspace/registry.js'; +import { initGitHubOrgCache } from './claude/executor.js'; import { executeClaudeTask, executeDirectTask } from './feishu/event-handler.js'; import { agentRegistry } from './agent/registry.js'; import type { AgentId } from './agent/types.js'; @@ -124,6 +125,9 @@ async function main(): Promise { }); } + // 预取 GitHub 用户组织(用于仓库搜索,fire-and-forget) + initGitHubOrgCache().catch(() => {}); + // 预热 quick-ack client(避免首次调用冷启动) warmupQuickAck(); From e8a81cb7334a0f9da89ba9a65264a9e083a54157 Mon Sep 17 00:00:00 2001 From: unclee Date: Tue, 7 Apr 2026 20:34:47 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=20Promise.allSett?= =?UTF-8?q?led=20=E4=BF=9D=E7=95=99=E9=83=A8=E5=88=86=E7=BB=93=E6=9E=9C=20?= =?UTF-8?q?+=20=E4=BF=AE=E5=A4=8D=20lint=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Promise.all → Promise.allSettled,避免 orgs API 失败时丢失 user login - 移除未使用的 rmdirSync import - Function → (...a: unknown[]) => void 修复 @typescript-eslint/ban-types Co-Authored-By: Claude Opus 4.6 (1M context) --- src/claude/__tests__/github-orgs.test.ts | 29 ++++++++++++++++++------ src/claude/executor.ts | 11 +++++++-- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/claude/__tests__/github-orgs.test.ts b/src/claude/__tests__/github-orgs.test.ts index e7bb59c3..aa8f9a66 100644 --- a/src/claude/__tests__/github-orgs.test.ts +++ b/src/claude/__tests__/github-orgs.test.ts @@ -1,6 +1,6 @@ // @ts-nocheck — test file, vitest uses esbuild transform import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, mkdirSync, rmdirSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -89,10 +89,10 @@ describe('GitHub org auto-discovery', () => { it('should fetch orgs and user login via gh CLI', async () => { // Mock two execFile calls: user/orgs and user mockExecFile - .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (...a: unknown[]) => void) => { cb(null, 'taptap\nEpicGames\n'); }) - .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (...a: unknown[]) => void) => { cb(null, 'lishuceo\n'); }); @@ -120,10 +120,10 @@ describe('GitHub org auto-discovery', () => { it('should deduplicate when user login is also in orgs', async () => { mockExecFile - .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (...a: unknown[]) => void) => { cb(null, 'myorg\nmyuser\n'); }) - .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (...a: unknown[]) => void) => { cb(null, 'myuser\n'); }); @@ -134,8 +134,23 @@ describe('GitHub org auto-discovery', () => { expect(myuserCount).toBe(1); }); - it('should not crash when gh CLI fails', async () => { - mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + it('should preserve login when orgs API fails (partial failure)', async () => { + mockExecFile + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (...a: unknown[]) => void) => { + cb(new Error('403 Forbidden')); + }) + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (...a: unknown[]) => void) => { + cb(null, 'lishuceo\n'); + }); + + await initGitHubOrgCache(); + + const orgs = listKnownOrgs('/nonexistent'); + expect(orgs).toContain('github.com/lishuceo'); + }); + + it('should not crash when gh CLI fails completely', async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (...a: unknown[]) => void) => { cb(new Error('gh not found')); }); diff --git a/src/claude/executor.ts b/src/claude/executor.ts index b7a0d9c1..e47f8bdf 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -235,12 +235,19 @@ function ghExec(args: string[]): Promise { export async function initGitHubOrgCache(): Promise { try { - const [orgsOutput, login] = await Promise.all([ + const [orgsResult, loginResult] = await Promise.allSettled([ ghExec(['api', 'user/orgs', '--jq', '.[].login']), ghExec(['api', 'user', '--jq', '.login']), ]); - const orgs = orgsOutput.split('\n').filter(Boolean); + const orgs = orgsResult.status === 'fulfilled' + ? orgsResult.value.split('\n').filter(Boolean) + : []; + const login = loginResult.status === 'fulfilled' ? loginResult.value : ''; if (login) orgs.push(login); + if (orgs.length === 0) { + logger.warn('GitHub org cache: no orgs or login discovered'); + return; + } cachedGitHubOrgs = [...new Set(orgs)].map(o => `github.com/${o}`); logger.info({ orgs: cachedGitHubOrgs }, 'GitHub org cache initialized'); } catch (err) {