diff --git a/src/claude/__tests__/github-orgs.test.ts b/src/claude/__tests__/github-orgs.test.ts new file mode 100644 index 00000000..aa8f9a66 --- /dev/null +++ b/src/claude/__tests__/github-orgs.test.ts @@ -0,0 +1,223 @@ +// @ts-nocheck — test file, vitest uses esbuild transform +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, 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: (...a: unknown[]) => void) => { + cb(null, 'taptap\nEpicGames\n'); + }) + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (...a: unknown[]) => void) => { + 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: (...a: unknown[]) => void) => { + cb(null, 'myorg\nmyuser\n'); + }) + .mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (...a: unknown[]) => void) => { + 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 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')); + }); + + // 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..e47f8bdf 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,68 @@ 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 [orgsResult, loginResult] = await Promise.allSettled([ + ghExec(['api', 'user/orgs', '--jq', '.[].login']), + ghExec(['api', 'user', '--jq', '.login']), + ]); + 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) { + 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();