Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions src/claude/__tests__/github-orgs.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
});
59 changes: 54 additions & 5 deletions src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string> {
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<void> {
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<string>();

// 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<string>();
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);
}

/** 构建工作区管理系统提示词(注入实际目录路径 + 可用项目列表) */
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -124,6 +125,9 @@ async function main(): Promise<void> {
});
}

// 预取 GitHub 用户组织(用于仓库搜索,fire-and-forget)
initGitHubOrgCache().catch(() => {});

// 预热 quick-ack client(避免首次调用冷启动)
warmupQuickAck();

Expand Down
Loading