Skip to content

Commit 62fc767

Browse files
authored
Merge pull request #23 from fulll/feat/team-cache
Feat: cache GitHub team list (24 h) with --no-cache option
2 parents ba4a899 + 2f7b126 commit 62fc767

7 files changed

Lines changed: 399 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ src/
7171
api.ts # GitHub REST API client (search, team fetching)
7272
api-utils.ts # Shared retry (fetchWithRetry) and pagination (paginatedFetch)
7373
# helpers used exclusively by api.ts — performs network I/O
74+
cache.ts # Disk cache for the team list (getCacheDir, getCacheKey,
75+
# readCache, writeCache) — performs filesystem I/O
7476
aggregate.ts # Result grouping & filtering (applyFiltersAndExclusions)
7577
group.ts # groupByTeamPrefix — team-prefix grouping logic
7678
render.ts # Façade re-exporting sub-modules + top-level
@@ -94,7 +96,7 @@ src/
9496
## Key architectural principles
9597

9698
- **Pure functions first.** All business logic lives in pure, side-effect-free functions (`aggregate.ts`, `group.ts`, `output.ts`, `render/` sub-modules). This makes them straightforward to unit-test.
97-
- **Side effects are isolated.** API calls (`api.ts`, `api-utils.ts`), TTY interaction (`tui.ts`) and CLI parsing (`github-code-search.ts`) are the only side-effectful surfaces. `api-utils.ts` hosts shared retry/pagination helpers that perform network I/O and must not be used outside `api.ts`.
99+
- **Side effects are isolated.** API calls (`api.ts`, `api-utils.ts`), TTY interaction (`tui.ts`) and CLI parsing (`github-code-search.ts`) are the only side-effectful surfaces. `api-utils.ts` hosts shared retry/pagination helpers that perform network I/O and must not be used outside `api.ts`. `cache.ts` hosts disk-cache helpers that perform filesystem I/O and must not be used outside `api.ts`.
98100
- **`render.ts` is a façade.** It re-exports everything from `render/` and adds two top-level rendering functions. Consumers import from `render.ts`, not directly from sub-modules.
99101
- **`types.ts` is the single source of truth** for all shared interfaces. Any new shared type must go there.
100102
- **No classes** — the codebase uses plain TypeScript interfaces and functions throughout.
@@ -105,6 +107,7 @@ src/
105107
- Use `describe` / `it` / `expect` from Bun's test runner.
106108
- Only pure functions need tests; `tui.ts` and `api.ts` are not unit-tested.
107109
`api-utils.ts` is the exception: its helpers are unit-tested by mocking `globalThis.fetch`.
110+
`cache.ts` is also tested: it uses the `GITHUB_CODE_SEARCH_CACHE_DIR` env var override to redirect to a temp directory, so tests have no filesystem side effects on the real cache dir.
108111
- When adding a function to an existing module, add the corresponding test case in the existing `<module>.test.ts`.
109112
- When creating a new module that contains pure functions, create a companion `<module>.test.ts`.
110113
- Tests must be self-contained: no network calls, no filesystem side effects.

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ github-code-search upgrade
6565
| `--output-type <type>` || Output type: `repo-and-matches` (default) or `repo-only` |
6666
| `--include-archived` || Include archived repositories in results (default: false) |
6767
| `--group-by-team-prefix <pfxs>` || Comma-separated team-name prefixes to group repos by GitHub team (e.g. `squad-,chapter-`) |
68+
| `--no-cache` || Bypass the 24 h team-list cache and re-fetch teams from GitHub (only with `--group-by-team-prefix`) |
6869

6970
## Interactive mode
7071

@@ -391,6 +392,44 @@ Navigation (↑ / ↓) automatically skips section header rows.
391392
Fetching team membership requires the token to have the **`read:org`** (or
392393
`admin:org`) scope in addition to `repo` / `public_repo`.
393394

395+
## Cache
396+
397+
When `--group-by-team-prefix` is used, the tool caches the GitHub team list on
398+
disk for **24 hours** to avoid repeating dozens of API calls on every run.
399+
400+
### Cache location
401+
402+
| OS | Path |
403+
| ------- | ----------------------------------------------------------------------- |
404+
| macOS | `~/Library/Caches/github-code-search/` |
405+
| Linux | `$XDG_CACHE_HOME/github-code-search/` or `~/.cache/github-code-search/` |
406+
| Windows | `%LOCALAPPDATA%\github-code-search\` |
407+
408+
You can also override the cache directory with the `GITHUB_CODE_SEARCH_CACHE_DIR`
409+
environment variable.
410+
411+
### Bypassing the cache
412+
413+
Pass `--no-cache` to skip the cache and force a fresh fetch:
414+
415+
```bash
416+
github-code-search "useFeatureFlag" --org fulll \
417+
--group-by-team-prefix squad- --no-cache
418+
```
419+
420+
### Purging the cache
421+
422+
```bash
423+
# macOS / Linux
424+
rm -rf ~/Library/Caches/github-code-search # macOS
425+
rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/github-code-search" # Linux
426+
```
427+
428+
```powershell
429+
# Windows (PowerShell)
430+
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\github-code-search"
431+
```
432+
394433
## Known limitations
395434

396435
- The GitHub Code Search API is capped at **1,000 results** per query and **10 requests/minute** without authentication (30/min with a token).

github-code-search.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ function addSearchOptions(cmd: Command): Command {
9090
"then by the next prefix, and so on. Repos matching no prefix go into 'other'.",
9191
].join("\n"),
9292
"",
93+
)
94+
.option(
95+
"--no-cache",
96+
"Bypass the 24 h team-list cache and re-fetch teams from GitHub (only applies with --group-by-team-prefix).",
9397
);
9498
}
9599

@@ -105,6 +109,7 @@ async function searchAction(
105109
outputType: string;
106110
includeArchived: boolean;
107111
groupByTeamPrefix: string;
112+
cache: boolean;
108113
},
109114
): Promise<void> {
110115
// ─── GitHub API token ───────────────────────────────────────────────────────
@@ -144,7 +149,7 @@ async function searchAction(
144149
.map((p) => p.trim())
145150
.filter(Boolean);
146151
if (prefixes.length > 0) {
147-
const teamMap = await fetchRepoTeams(org, GITHUB_TOKEN!, prefixes);
152+
const teamMap = await fetchRepoTeams(org, GITHUB_TOKEN!, prefixes, opts.cache);
148153
// Attach team lists to each group
149154
for (const g of groups) {
150155
g.teams = teamMap.get(g.repoFullName) ?? [];

src/api.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ describe("fetchRepoTeams", () => {
322322
headers: { "content-type": "application/json" },
323323
})) as typeof fetch;
324324

325-
const result = await fetchRepoTeams("myorg", "tok", ["frontend"]);
325+
const result = await fetchRepoTeams("myorg", "tok", ["frontend"], false);
326326
expect(result.size).toBe(0);
327327
});
328328

@@ -342,7 +342,7 @@ describe("fetchRepoTeams", () => {
342342
});
343343
}) as typeof fetch;
344344

345-
const result = await fetchRepoTeams("myorg", "tok", ["frontend"]);
345+
const result = await fetchRepoTeams("myorg", "tok", ["frontend"], false);
346346
expect(result.get("myorg/my-repo")).toEqual(["frontend-web"]);
347347
});
348348

@@ -365,7 +365,7 @@ describe("fetchRepoTeams", () => {
365365
});
366366
}) as typeof fetch;
367367

368-
const result = await fetchRepoTeams("myorg", "tok", ["frontend"]);
368+
const result = await fetchRepoTeams("myorg", "tok", ["frontend"], false);
369369
const slugs = result.get("myorg/shared-ui") ?? [];
370370
expect(slugs).toContain("frontend-web");
371371
expect(slugs).toContain("frontend-mobile");
@@ -374,7 +374,7 @@ describe("fetchRepoTeams", () => {
374374
it("throws when the teams list request fails", async () => {
375375
globalThis.fetch = (async () => new Response("Forbidden", { status: 403 })) as typeof fetch;
376376

377-
await expect(fetchRepoTeams("myorg", "tok", ["frontend"])).rejects.toThrow("403");
377+
await expect(fetchRepoTeams("myorg", "tok", ["frontend"], false)).rejects.toThrow("403");
378378
});
379379

380380
it("silently skips a team's repos when its repo list request fails", async () => {
@@ -389,7 +389,7 @@ describe("fetchRepoTeams", () => {
389389
return new Response("Not Found", { status: 404 });
390390
}) as typeof fetch;
391391

392-
const result = await fetchRepoTeams("myorg", "tok", ["frontend"]);
392+
const result = await fetchRepoTeams("myorg", "tok", ["frontend"], false);
393393
expect(result.size).toBe(0);
394394
});
395395

@@ -422,7 +422,7 @@ describe("fetchRepoTeams", () => {
422422
});
423423
}) as typeof fetch;
424424

425-
await fetchRepoTeams("myorg", "tok", ["frontend"]);
425+
await fetchRepoTeams("myorg", "tok", ["frontend"], false);
426426
expect(teamPage).toBe(2);
427427
});
428428

@@ -453,7 +453,7 @@ describe("fetchRepoTeams", () => {
453453
});
454454
}) as typeof fetch;
455455

456-
const result = await fetchRepoTeams("myorg", "tok", ["frontend"]);
456+
const result = await fetchRepoTeams("myorg", "tok", ["frontend"], false);
457457
expect(result.has("myorg/repo-extra")).toBe(true);
458458
expect(result.has("myorg/repo-0")).toBe(true);
459459
});
@@ -473,7 +473,7 @@ describe("fetchRepoTeams", () => {
473473
});
474474
}) as typeof fetch;
475475

476-
const result = await fetchRepoTeams("myorg", "tok", ["FRONTEND"]);
476+
const result = await fetchRepoTeams("myorg", "tok", ["FRONTEND"], false);
477477
expect(result.has("myorg/repo-a")).toBe(true);
478478
});
479479
});

src/api.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pc from "picocolors";
22
import type { CodeMatch } from "./types.ts";
33
import { fetchWithRetry, paginatedFetch } from "./api-utils.ts";
4+
import { getCacheKey, readCache, writeCache } from "./cache.ts";
45

56
// ─── Raw GitHub API types (internal) ─────────────────────────────────────────
67

@@ -192,7 +193,20 @@ export async function fetchRepoTeams(
192193
org: string,
193194
token: string,
194195
prefixes: string[],
196+
useCache = true,
195197
): Promise<Map<string, string[]>> {
198+
// ── Cache lookup ────────────────────────────────────────────────────────────
199+
// The team list is quasi-static; cache it for 24 h to avoid dozens of API
200+
// calls on every run. Bypass with useCache = false (--no-cache flag).
201+
const cacheKey = getCacheKey(org, prefixes);
202+
if (useCache) {
203+
const cached = readCache<[string, string[]][]>(cacheKey);
204+
if (cached !== null) {
205+
process.stderr.write(pc.dim("Using cached team data (— use --no-cache to refresh)\n"));
206+
return new Map(cached);
207+
}
208+
}
209+
196210
const lowerPrefixes = prefixes.map((p) => p.toLowerCase());
197211

198212
// ── 1. List all org teams (paginated), filtering to matching prefixes per page ──
@@ -278,5 +292,11 @@ export async function fetchRepoTeams(
278292
}),
279293
);
280294

295+
// ── Persist result ──────────────────────────────────────────────────────────
296+
// Serialise the Map as an array of entries for JSON round-trip stability.
297+
if (useCache) {
298+
writeCache(cacheKey, [...repoTeams.entries()]);
299+
}
300+
281301
return repoTeams;
282302
}

0 commit comments

Comments
 (0)