diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index 3a78c9ce..4f91c73e 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -10,16 +10,28 @@ * Errors are always caught and logged — never propagated. */ -import { getProjectGitHubToken } from '../config/projects.js'; -import { findProjectByRepo } from '../config/provider.js'; import { markdownToAdf } from '../pm/jira/adf.js'; -import type { ProjectConfig } from '../types/index.js'; import { + _resetJiraBotCache, + _resetTrelloBotCache, resolveGitHubHeaders, + resolveGitHubTokenForAck, + resolveJiraBotAccountId, resolveJiraCredentials, + resolveTrelloBotMemberId, resolveTrelloCredentials, } from './platformClients.js'; +// Re-export bot-identity helpers so callers that import from acknowledgments +// continue to work without changes. +export { + resolveJiraBotAccountId, + resolveTrelloBotMemberId, + resolveGitHubTokenForAck, + _resetJiraBotCache, + _resetTrelloBotCache, +}; + // --------------------------------------------------------------------------- // Trello // --------------------------------------------------------------------------- @@ -171,107 +183,3 @@ export async function deleteJiraAck( console.warn('[Ack] Failed to delete JIRA orphan ack:', String(err)); } } - -// --------------------------------------------------------------------------- -// Bot identity resolution (cached, for self-authored comment detection) -// --------------------------------------------------------------------------- - -const IDENTITY_CACHE_TTL_MS = 60_000; // 60 seconds - -const jiraBotCache = new Map(); - -/** - * Resolve the JIRA account ID for the bot credentials linked to a project. - * Cached per-project with 60s TTL. Returns null on any failure. - */ -export async function resolveJiraBotAccountId(projectId: string): Promise { - const cached = jiraBotCache.get(projectId); - if (cached && Date.now() < cached.expiresAt) return cached.accountId; - - const creds = await resolveJiraCredentials(projectId); - if (!creds) return null; - - try { - const response = await fetch(`${creds.baseUrl}/rest/api/2/myself`, { - headers: { Authorization: `Basic ${creds.auth}`, Accept: 'application/json' }, - }); - if (!response.ok) return null; - - const data = (await response.json()) as { accountId?: string }; - if (!data.accountId) return null; - - jiraBotCache.set(projectId, { - accountId: data.accountId, - expiresAt: Date.now() + IDENTITY_CACHE_TTL_MS, - }); - return data.accountId; - } catch { - return null; - } -} - -/** @internal Visible for testing only */ -export function _resetJiraBotCache(): void { - jiraBotCache.clear(); -} - -const trelloBotCache = new Map(); - -/** - * Resolve the Trello member ID for the bot credentials linked to a project. - * Cached per-project with 60s TTL. Returns null on any failure. - */ -export async function resolveTrelloBotMemberId(projectId: string): Promise { - const cached = trelloBotCache.get(projectId); - if (cached && Date.now() < cached.expiresAt) return cached.memberId; - - const creds = await resolveTrelloCredentials(projectId); - if (!creds) return null; - - try { - const response = await fetch( - `https://api.trello.com/1/members/me?key=${creds.apiKey}&token=${creds.token}`, - { headers: { Accept: 'application/json' } }, - ); - if (!response.ok) return null; - - const data = (await response.json()) as { id?: string }; - if (!data.id) return null; - - trelloBotCache.set(projectId, { - memberId: data.id, - expiresAt: Date.now() + IDENTITY_CACHE_TTL_MS, - }); - return data.id; - } catch { - return null; - } -} - -/** @internal Visible for testing only */ -export function _resetTrelloBotCache(): void { - trelloBotCache.clear(); -} - -// --------------------------------------------------------------------------- -// Resolve GitHub token for router-side ack posting -// --------------------------------------------------------------------------- - -/** - * Resolve a GitHub token for posting ack comments from the router. - * Uses the implementer token since ack comments are "from" the bot. - */ -export async function resolveGitHubTokenForAck( - repoFullName: string, -): Promise<{ token: string; project: ProjectConfig } | null> { - const project = await findProjectByRepo(repoFullName); - if (!project) return null; - - try { - const token = await getProjectGitHubToken(project); - return { token, project }; - } catch { - console.warn('[Ack] Missing GitHub token for repo:', repoFullName); - return null; - } -} diff --git a/src/router/platformClients.ts b/src/router/platformClients.ts index bd0141ad..d032a220 100644 --- a/src/router/platformClients.ts +++ b/src/router/platformClients.ts @@ -2,12 +2,19 @@ * Shared credential resolution and platform API header helpers for router modules. * * Resolves credentials once per call and returns typed objects. + * Also provides cached bot identity lookups and JIRA cloudId resolution. * Callers use raw `fetch()` — the router Docker image does not bundle * `src/trello/client.ts` or `src/github/client.ts`. */ -import { findProjectById, getIntegrationCredential } from '../config/provider.js'; +import { getProjectGitHubToken } from '../config/projects.js'; +import { + findProjectById, + findProjectByRepo, + getIntegrationCredential, +} from '../config/provider.js'; import { getJiraConfig } from '../pm/config.js'; +import type { ProjectConfig } from '../types/index.js'; // --------------------------------------------------------------------------- // Credential resolution helpers @@ -76,3 +83,159 @@ export function resolveGitHubHeaders( ...extra, }; } + +// --------------------------------------------------------------------------- +// GitHub token resolution +// --------------------------------------------------------------------------- + +/** + * Resolve a GitHub implementer token for a given repository. + * Returns the token and resolved project, or null on failure. + */ +export async function getGitHubTokenForProject( + repoFullName: string, +): Promise<{ token: string; project: ProjectConfig } | null> { + const project = await findProjectByRepo(repoFullName); + if (!project) return null; + + try { + const token = await getProjectGitHubToken(project); + return { token, project }; + } catch { + return null; + } +} + +/** + * Resolve a GitHub implementer token for acknowledgment posting. + * Alias of getGitHubTokenForProject for backward compatibility. + */ +export async function resolveGitHubTokenForAck( + repoFullName: string, +): Promise<{ token: string; project: ProjectConfig } | null> { + return getGitHubTokenForProject(repoFullName); +} + +// --------------------------------------------------------------------------- +// Bot identity caches +// --------------------------------------------------------------------------- + +const IDENTITY_CACHE_TTL_MS = 60_000; // 60 seconds + +// Trello bot member ID cache (per project) +const trelloBotCache = new Map(); + +/** + * Resolve the Trello member ID for the bot credentials linked to a project. + * Cached per-project with 60s TTL. Returns null on any failure. + */ +export async function resolveTrelloBotMemberId(projectId: string): Promise { + const cached = trelloBotCache.get(projectId); + if (cached && Date.now() < cached.expiresAt) return cached.memberId; + + const creds = await resolveTrelloCredentials(projectId); + if (!creds) return null; + + try { + const response = await fetch( + `https://api.trello.com/1/members/me?key=${creds.apiKey}&token=${creds.token}`, + { headers: { Accept: 'application/json' } }, + ); + if (!response.ok) return null; + + const data = (await response.json()) as { id?: string }; + if (!data.id) return null; + + trelloBotCache.set(projectId, { + memberId: data.id, + expiresAt: Date.now() + IDENTITY_CACHE_TTL_MS, + }); + return data.id; + } catch { + return null; + } +} + +/** @internal Visible for testing only */ +export function _resetTrelloBotCache(): void { + trelloBotCache.clear(); +} + +// JIRA bot account ID cache (per project) +const jiraBotCache = new Map(); + +/** + * Resolve the JIRA account ID for the bot credentials linked to a project. + * Cached per-project with 60s TTL. Returns null on any failure. + */ +export async function resolveJiraBotAccountId(projectId: string): Promise { + const cached = jiraBotCache.get(projectId); + if (cached && Date.now() < cached.expiresAt) return cached.accountId; + + const creds = await resolveJiraCredentials(projectId); + if (!creds) return null; + + try { + const response = await fetch(`${creds.baseUrl}/rest/api/2/myself`, { + headers: { Authorization: `Basic ${creds.auth}`, Accept: 'application/json' }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { accountId?: string }; + if (!data.accountId) return null; + + jiraBotCache.set(projectId, { + accountId: data.accountId, + expiresAt: Date.now() + IDENTITY_CACHE_TTL_MS, + }); + return data.accountId; + } catch { + return null; + } +} + +/** @internal Visible for testing only */ +export function _resetJiraBotCache(): void { + jiraBotCache.clear(); +} + +// JIRA CloudId cache (per baseUrl) +const jiraCloudIdCache = new Map(); + +/** + * Lightweight JIRA cloudId resolver with in-memory cache. + * Keyed by baseUrl. Returns null on any failure. + */ +export async function getJiraCloudId(creds: JiraCredentials): Promise { + const cached = jiraCloudIdCache.get(creds.baseUrl); + if (cached) return cached; + + let response: Response; + try { + response = await fetch(`${creds.baseUrl}/_edge/tenant_info`, { + headers: { Authorization: `Basic ${creds.auth}` }, + }); + } catch (err) { + console.warn('[PlatformClients] Failed to fetch JIRA cloudId:', String(err)); + return null; + } + + if (!response.ok) { + console.warn('[PlatformClients] JIRA tenant_info returned', response.status); + return null; + } + + const data = (await response.json()) as { cloudId?: string }; + if (!data.cloudId) { + console.warn('[PlatformClients] JIRA tenant_info missing cloudId'); + return null; + } + + jiraCloudIdCache.set(creds.baseUrl, data.cloudId); + return data.cloudId; +} + +/** @internal Visible for testing only */ +export function _resetJiraCloudIdCache(): void { + jiraCloudIdCache.clear(); +} diff --git a/src/router/reactions.ts b/src/router/reactions.ts index 9defa981..22d7e94f 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -14,56 +14,15 @@ import { trelloClient, withTrelloCredentials } from '../trello/client.js'; import type { ProjectConfig } from '../types/index.js'; import { parseRepoFullName } from '../utils/repo.js'; import { + _resetJiraCloudIdCache, + getJiraCloudId, resolveGitHubHeaders, resolveJiraCredentials, resolveTrelloCredentials, } from './platformClients.js'; -// In-memory JIRA CloudId cache keyed by baseUrl -const jiraCloudIdCache = new Map(); - -/** - * Lightweight JIRA cloudId resolver with in-memory cache. - * Mirrors jiraClient.getCloudId() but uses standalone fetch() with explicit credentials. - */ -async function getJiraCloudId( - baseUrl: string, - email: string, - apiToken: string, -): Promise { - const cached = jiraCloudIdCache.get(baseUrl); - if (cached) return cached; - - const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); - let response: Response; - try { - response = await fetch(`${baseUrl}/_edge/tenant_info`, { - headers: { Authorization: `Basic ${auth}` }, - }); - } catch (err) { - console.warn('[Reactions] Failed to fetch JIRA cloudId:', String(err)); - return null; - } - - if (!response.ok) { - console.warn('[Reactions] JIRA tenant_info returned', response.status); - return null; - } - - const data = (await response.json()) as { cloudId?: string }; - if (!data.cloudId) { - console.warn('[Reactions] JIRA tenant_info missing cloudId'); - return null; - } - - jiraCloudIdCache.set(baseUrl, data.cloudId); - return data.cloudId; -} - -/** @internal Visible for testing only */ -export function _resetJiraCloudIdCache(): void { - jiraCloudIdCache.clear(); -} +// Re-export cache reset for test compatibility +export { _resetJiraCloudIdCache }; // --------------------------------------------------------------------------- // Platform-specific reaction senders @@ -204,7 +163,7 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise ({ getIntegrationCredential: vi.fn(), + findProjectByRepo: vi.fn(), findProjectById: vi.fn(), })); +// Mock getProjectGitHubToken +vi.mock('../../../src/config/projects.js', () => ({ + getProjectGitHubToken: vi.fn(), +})); + // Mock config cache (imported transitively) vi.mock('../../../src/config/configCache.js', () => ({ configCache: { @@ -19,20 +25,31 @@ vi.mock('../../../src/config/configCache.js', () => ({ }, })); -import { findProjectById, getIntegrationCredential } from '../../../src/config/provider.js'; +import { getProjectGitHubToken } from '../../../src/config/projects.js'; +import { + findProjectById, + findProjectByRepo, + getIntegrationCredential, +} from '../../../src/config/provider.js'; import { + _resetJiraBotCache, + _resetJiraCloudIdCache, + _resetTrelloBotCache, + getGitHubTokenForProject, + getJiraCloudId, resolveGitHubHeaders, + resolveGitHubTokenForAck, + resolveJiraBotAccountId, resolveJiraCredentials, + resolveTrelloBotMemberId, resolveTrelloCredentials, } from '../../../src/router/platformClients.js'; const mockGetIntegrationCredential = vi.mocked(getIntegrationCredential); +const mockGetProjectGitHubToken = vi.mocked(getProjectGitHubToken); +const mockFindProjectByRepo = vi.mocked(findProjectByRepo); const mockFindProjectById = vi.mocked(findProjectById); -// Mock global fetch -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - const MOCK_CREDENTIALS: Record = { 'pm/api_key': 'trello-key', 'pm/token': 'trello-token', @@ -40,6 +57,10 @@ const MOCK_CREDENTIALS: Record = { 'pm/api_token': 'jira-api-token', }; +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + const MOCK_PROJECT_WITH_JIRA = { id: 'proj1', name: 'Test', @@ -64,9 +85,24 @@ beforeEach(() => { if (value) return value; throw new Error(`Credential '${category}/${role}' not found`); }); + mockGetProjectGitHubToken.mockResolvedValue('test-github-token'); + mockFindProjectByRepo.mockResolvedValue({ + id: 'proj1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + }); mockFindProjectById.mockResolvedValue(MOCK_PROJECT_WITH_JIRA); }); +afterEach(() => { + vi.restoreAllMocks(); + _resetJiraBotCache(); + _resetTrelloBotCache(); + _resetJiraCloudIdCache(); +}); + // --------------------------------------------------------------------------- // resolveTrelloCredentials // --------------------------------------------------------------------------- @@ -165,3 +201,322 @@ describe('resolveGitHubHeaders', () => { expect(headers.Accept).toBe('text/plain'); }); }); + +// --------------------------------------------------------------------------- +// getGitHubTokenForProject +// --------------------------------------------------------------------------- + +describe('getGitHubTokenForProject', () => { + it('returns token and project when both are found', async () => { + const result = await getGitHubTokenForProject('owner/repo'); + + expect(result).not.toBeNull(); + expect(result?.token).toBe('test-github-token'); + expect(result?.project.id).toBe('proj1'); + }); + + it('returns null when project is not found', async () => { + mockFindProjectByRepo.mockResolvedValue(undefined); + + const result = await getGitHubTokenForProject('unknown/repo'); + + expect(result).toBeNull(); + }); + + it('returns null when GitHub token is missing', async () => { + mockGetProjectGitHubToken.mockRejectedValue(new Error('Missing token')); + + const result = await getGitHubTokenForProject('owner/repo'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveGitHubTokenForAck (alias for getGitHubTokenForProject) +// --------------------------------------------------------------------------- + +describe('resolveGitHubTokenForAck', () => { + it('returns token and project when both are found', async () => { + const result = await resolveGitHubTokenForAck('owner/repo'); + + expect(result).not.toBeNull(); + expect(result?.token).toBe('test-github-token'); + expect(result?.project.id).toBe('proj1'); + }); + + it('returns null when project is not found', async () => { + mockFindProjectByRepo.mockResolvedValue(undefined); + + const result = await resolveGitHubTokenForAck('unknown/repo'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveTrelloBotMemberId +// --------------------------------------------------------------------------- + +describe('resolveTrelloBotMemberId', () => { + it('returns member ID from /1/members/me', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'trello-bot-456' }), + }); + + const result = await resolveTrelloBotMemberId('proj1'); + + expect(result).toBe('trello-bot-456'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('https://api.trello.com/1/members/me'); + expect(url).toContain('key=trello-key'); + expect(url).toContain('token=trello-token'); + }); + + it('caches the result for subsequent calls', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'trello-bot-456' }), + }); + + const result1 = await resolveTrelloBotMemberId('proj1'); + const result2 = await resolveTrelloBotMemberId('proj1'); + + expect(result1).toBe('trello-bot-456'); + expect(result2).toBe('trello-bot-456'); + expect(mockFetch).toHaveBeenCalledOnce(); // Only one API call + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveTrelloBotMemberId('proj1'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await resolveTrelloBotMemberId('proj1'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await resolveTrelloBotMemberId('proj1'); + + expect(result).toBeNull(); + }); + + it('cache expires after TTL and re-fetches', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'bot-1' }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'bot-2' }) }); + + const result1 = await resolveTrelloBotMemberId('proj1'); + + // Manually manipulate cache TTL by clearing and re-calling + _resetTrelloBotCache(); + + const result2 = await resolveTrelloBotMemberId('proj1'); + + expect(result1).toBe('bot-1'); + expect(result2).toBe('bot-2'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// resolveJiraBotAccountId +// --------------------------------------------------------------------------- + +describe('resolveJiraBotAccountId', () => { + it('returns account ID from /rest/api/2/myself', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ accountId: 'jira-bot-123' }), + }); + + const result = await resolveJiraBotAccountId('proj1'); + + expect(result).toBe('jira-bot-123'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/rest/api/2/myself'); + expect(options.headers.Authorization).toMatch(/^Basic /); + }); + + it('caches the result for subsequent calls', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ accountId: 'jira-bot-123' }), + }); + + const result1 = await resolveJiraBotAccountId('proj1'); + const result2 = await resolveJiraBotAccountId('proj1'); + + expect(result1).toBe('jira-bot-123'); + expect(result2).toBe('jira-bot-123'); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveJiraBotAccountId('proj1'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null when JIRA base URL is missing', async () => { + mockFindProjectById.mockResolvedValue({ + id: 'proj1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + }); + + const result = await resolveJiraBotAccountId('proj1'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await resolveJiraBotAccountId('proj1'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no accountId', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await resolveJiraBotAccountId('proj1'); + + expect(result).toBeNull(); + }); + + it('cache can be cleared and re-fetches', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ accountId: 'acct-1' }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ accountId: 'acct-2' }) }); + + const result1 = await resolveJiraBotAccountId('proj1'); + _resetJiraBotCache(); + const result2 = await resolveJiraBotAccountId('proj1'); + + expect(result1).toBe('acct-1'); + expect(result2).toBe('acct-2'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// getJiraCloudId +// --------------------------------------------------------------------------- + +describe('getJiraCloudId', () => { + const mockCreds = { + email: 'bot@example.com', + apiToken: 'jira-api-token', + baseUrl: 'https://test.atlassian.net', + auth: Buffer.from('bot@example.com:jira-api-token').toString('base64'), + }; + + it('returns cloudId from tenant_info endpoint', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ cloudId: 'cloud-abc-123' }), + }); + + const result = await getJiraCloudId(mockCreds); + + expect(result).toBe('cloud-abc-123'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/_edge/tenant_info'); + expect(options.headers.Authorization).toMatch(/^Basic /); + }); + + it('caches the cloudId for subsequent calls with same baseUrl', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ cloudId: 'cloud-abc-123' }), + }); + + const result1 = await getJiraCloudId(mockCreds); + const result2 = await getJiraCloudId(mockCreds); + + expect(result1).toBe('cloud-abc-123'); + expect(result2).toBe('cloud-abc-123'); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + + it('returns null on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await getJiraCloudId(mockCreds); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch JIRA cloudId'), + expect.any(String), + ); + }); + + it('returns null on HTTP error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); + + const result = await getJiraCloudId(mockCreds); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('JIRA tenant_info returned'), + 403, + ); + }); + + it('returns null when cloudId is missing from response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await getJiraCloudId(mockCreds); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('JIRA tenant_info missing cloudId'), + ); + }); + + it('cache can be cleared and re-fetches', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ cloudId: 'cloud-1' }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ cloudId: 'cloud-2' }) }); + + const result1 = await getJiraCloudId(mockCreds); + _resetJiraCloudIdCache(); + const result2 = await getJiraCloudId(mockCreds); + + expect(result1).toBe('cloud-1'); + expect(result2).toBe('cloud-2'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +});