Skip to content

Commit eb7d3f6

Browse files
fix: cache GitHub team members every 30 min (#38)
1 parent 1f58a98 commit eb7d3f6

File tree

3 files changed

+114
-2
lines changed

3 files changed

+114
-2
lines changed

.changeset/cache-team-members.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"layne": patch
3+
---
4+
5+
Cache GitHub team member lookups for 30 minutes to avoid redundant API calls on every exception-approve command.

src/__tests__/github.test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22

33
// Build a mock Octokit instance with spies for the methods we use.
44
const mockChecksCreate = vi.fn().mockResolvedValue({ data: { id: 42 } });
@@ -11,6 +11,7 @@ const mockListPRsForCommit = vi.fn().mockResolvedValue({ data: [] });
1111
const mockCompareCommits = vi.fn().mockResolvedValue({ data: { merge_base_commit: { sha: 'merge-base-sha' } } });
1212
const mockPullsGet = vi.fn().mockResolvedValue({ data: { number: 7, head: { sha: 'abc', ref: 'feat' }, base: { sha: 'base', ref: 'main' }, labels: [] } });
1313
const mockIssuesCreateComment = vi.fn().mockResolvedValue({});
14+
const mockListMembersInOrg = vi.fn().mockResolvedValue({ data: [{ login: 'alice' }, { login: 'bob' }] });
1415

1516
const mockOctokit = {
1617
checks: {
@@ -31,6 +32,9 @@ const mockOctokit = {
3132
listPullRequestsAssociatedWithCommit: mockListPRsForCommit,
3233
compareCommits: mockCompareCommits,
3334
},
35+
teams: {
36+
listMembersInOrg: mockListMembersInOrg,
37+
},
3438
};
3539

3640
vi.mock('../auth.js', () => ({
@@ -41,6 +45,7 @@ const {
4145
createCheckRun, startCheckRun, completeCheckRun,
4246
skipCheckRun, findPullRequestBySha, getMergeBaseSha,
4347
ensureLabelsExist, setLabels, getPullRequest, createPrComment,
48+
getTeamMembers, clearTeamMemberCache,
4449
} = await import('../github.js');
4550

4651
const BASE = {
@@ -368,3 +373,81 @@ describe('createPrComment()', () => {
368373
}));
369374
});
370375
});
376+
377+
describe('getTeamMembers()', () => {
378+
beforeEach(() => {
379+
vi.clearAllMocks();
380+
clearTeamMemberCache();
381+
});
382+
383+
afterEach(() => {
384+
vi.useRealTimers();
385+
});
386+
387+
it('returns empty array when no team slugs are provided', async () => {
388+
const result = await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: [] });
389+
expect(result).toEqual([]);
390+
expect(mockListMembersInOrg).not.toHaveBeenCalled();
391+
});
392+
393+
it('calls the API and returns members for each slug', async () => {
394+
const result = await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: ['security-team'] });
395+
396+
expect(mockListMembersInOrg).toHaveBeenCalledOnce();
397+
expect(mockListMembersInOrg).toHaveBeenCalledWith({ org: 'org', team_slug: 'security-team' });
398+
expect(result).toEqual(['alice', 'bob']);
399+
});
400+
401+
it('uses the cached result on a second call within TTL', async () => {
402+
await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: ['security-team'] });
403+
await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: ['security-team'] });
404+
405+
expect(mockListMembersInOrg).toHaveBeenCalledOnce();
406+
});
407+
408+
it('re-fetches after the TTL expires', async () => {
409+
vi.useFakeTimers();
410+
411+
await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: ['security-team'] });
412+
vi.advanceTimersByTime(31 * 60 * 1000); // past 30-minute TTL
413+
await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: ['security-team'] });
414+
415+
expect(mockListMembersInOrg).toHaveBeenCalledTimes(2);
416+
});
417+
418+
it('caches slugs independently so shared teams are not double-fetched', async () => {
419+
mockListMembersInOrg
420+
.mockResolvedValueOnce({ data: [{ login: 'alice' }] })
421+
.mockResolvedValueOnce({ data: [{ login: 'carol' }] });
422+
423+
await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: ['team-a', 'team-b'] });
424+
await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: ['team-a'] });
425+
426+
// team-a and team-b fetched once each on first call; second call hits cache
427+
expect(mockListMembersInOrg).toHaveBeenCalledTimes(2);
428+
});
429+
430+
it('scopes the cache by installationId', async () => {
431+
await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: ['security-team'] });
432+
await getTeamMembers({ installationId: 2, org: 'org', teamSlugs: ['security-team'] });
433+
434+
expect(mockListMembersInOrg).toHaveBeenCalledTimes(2);
435+
});
436+
437+
it('parses org/slug format and uses the explicit org', async () => {
438+
await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: ['other-org/security-team'] });
439+
440+
expect(mockListMembersInOrg).toHaveBeenCalledWith({ org: 'other-org', team_slug: 'security-team' });
441+
});
442+
443+
it('logs and continues when the API call fails for a slug', async () => {
444+
mockListMembersInOrg.mockRejectedValueOnce(new Error('API error'));
445+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
446+
447+
const result = await getTeamMembers({ installationId: 1, org: 'org', teamSlugs: ['bad-team'] });
448+
449+
expect(result).toEqual([]);
450+
expect(error).toHaveBeenCalledWith(expect.stringContaining('[github]'));
451+
error.mockRestore();
452+
});
453+
});

src/github.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ import type { Annotation } from './types.js';
44

55
const CHECK_NAME = 'Layne Security Scan';
66

7+
const TEAM_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
8+
9+
interface TeamCacheEntry {
10+
members: string[];
11+
expiresAt: number;
12+
}
13+
14+
const teamMemberCache = new Map<string, TeamCacheEntry>();
15+
16+
/** Clears the in-process team member cache. Exposed for testing. */
17+
export function clearTeamMemberCache(): void {
18+
teamMemberCache.clear();
19+
}
20+
721
/**
822
* Creates a Check Run in "queued" state immediately after receiving the webhook.
923
* Returns the Check Run ID, which the worker uses to update it later.
@@ -309,12 +323,22 @@ export async function getTeamMembers({ installationId, org, teamSlugs }: {
309323
teamSlug = slug;
310324
}
311325

326+
const cacheKey = `${installationId}:${teamOrg}/${teamSlug}`;
327+
const cached = teamMemberCache.get(cacheKey);
328+
if (cached && cached.expiresAt > Date.now()) {
329+
debug('github', `team cache hit for ${teamOrg}/${teamSlug}: ${cached.members.length} member(s)`);
330+
cached.members.forEach(m => members.add(m));
331+
continue;
332+
}
333+
312334
try {
313335
const { data } = await octokit.teams.listMembersInOrg({
314336
org: teamOrg,
315337
team_slug: teamSlug,
316338
});
317-
data.forEach(m => members.add(m.login));
339+
const slugMembers = data.map(m => m.login);
340+
slugMembers.forEach(m => members.add(m));
341+
teamMemberCache.set(cacheKey, { members: slugMembers, expiresAt: Date.now() + TEAM_CACHE_TTL_MS });
318342
debug('github', `resolved team ${teamOrg}/${teamSlug}: ${data.length} member(s)`);
319343
} catch (err) {
320344
console.error(`[github] Failed to list members for team ${teamOrg}/${teamSlug}: ${(err as Error).message}`);

0 commit comments

Comments
 (0)