Reduce Smoke CI REST churn by caching mention-resolution collaborator lookups#36960
Conversation
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
|
🧪 Test Quality Sentinel completed test quality analysis. |
|
✅ Design Decision Gate 🏗️ completed the design decision gate check. No ADR enforcement needed: PR #36960 does not have the implementation label (has_implementation_label=false) and has 0 new lines of code in business logic directories (default_business_additions=0, threshold=100). Neither Condition A nor Condition B is met. |
|
🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅ |
|
|
🧪 Test Quality Sentinel Report✅ Test Quality Score: 85/100 — Excellent
📊 Metrics & Test Classification (2 tests analyzed)
Test Classification Details
Language SupportTests analyzed:
|
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /tdd, /diagnose, and /improve-codebase-architecture — the caching strategy is well-targeted and the early-exit optimisation is clean. Three areas worth a follow-up pass before this pattern spreads.
📋 Key Themes & Highlights
Key Themes
userPermissionCacheuntested (/tdd): The two new tests exercisegetRecentCollaboratorscache-hit and the no-mention short-circuit, but thecheckUserPermissionfallback path — and its bot/error memoisation — has no cache-hit coverage.- Transient errors permanently cached (
/diagnose): Thecatchblock incheckUserPermissionstoresfalseunconditionally; a 503 or rate-limit response during a run silently denies a valid contributor for the entire process. - Test-only reset hook in production exports (
/improve-codebase-architecture):resetMentionResolutionCachesis wired intobeforeEachand exported alongside production functions, leaking test infrastructure through the public API. A factory or injected-cache approach would avoid this.
Positive Highlights
- ✅ Early exit for mention-free text is clean and well-tested — zero API calls for the common case
- ✅
getRepoCacheKeynormalises to lowercase for correct case-insensitive matching - ✅
permissionsForRepoinitialised withcachedPermissions || new Map()correctly preserves existing repo entries on a per-user miss - ✅ Error path is memoised (good intent — prevents retry storms on 404s); just needs the transient-vs-deterministic distinction
- ✅
resetMentionResolutionCacheswired intobeforeEachkeeps all existing tests isolated
🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 2.1M
| return isAllowed; | ||
| } catch (error) { | ||
| // User doesn't exist, not a collaborator, or API error - deny | ||
| permissionsForRepo.set(usernameKey, false); |
There was a problem hiding this comment.
[/diagnose] Any transient API failure (rate limit, 503, network blip) in this catch block is cached as a permanent false for the rest of the process — the same as a deterministic "user not found" result. A retry that would succeed is silently suppressed.
💡 Suggested fix: only cache deterministic failures
} catch (error) {
// Only cache 404 (user doesn’t exist) — deterministic, safe to memoize
const status = error?.status ?? error?.response?.status;
if (status === 404) {
permissionsForRepo.set(usernameKey, false);
userPermissionCache.set(repoCacheKey, permissionsForRepo);
}
// Transient errors (5xx, network): fall through without caching so a later
// call within the same run gets a fresh attempt.
return false;
}In a short-lived Action process the blast radius is limited to one run, but this could silently deny a valid contributor if the first lookup races against a transient outage.
|
|
||
| const { getErrorMessage } = require("./error_helpers.cjs"); | ||
| /** @type {Map<string, Map<string, boolean>>} */ | ||
| const recentCollaboratorsCache = new Map(); |
There was a problem hiding this comment.
[/improve-codebase-architecture] Two module-level Map instances give the caches process lifetime, which is correct for a single-run Action but couples the module to a global singleton. The only way to reset them is resetMentionResolutionCaches, which is a test-only hook that leaks through the public module.exports.
💡 Alternatives that avoid exporting test infrastructure
Option A — factory function (minimal change, caches scoped to the resolver instance):
function createMentionResolver() {
const collaboratorsCache = new Map();
const permissionCache = new Map();
return {
resolveMentionsLazily: (text, knownAuthors, owner, repo, github, core) =>
_resolveMentionsLazily(text, knownAuthors, owner, repo, github, core,
collaboratorsCache, permissionCache),
};
}
// Tests just call createMentionResolver() per test — no reset hook needed.Option B — injected cache bag (most explicit):
Pass { collaboratorsCache, permissionCache } as a parameter with a sensible default (module-scoped Maps for production, fresh Maps in tests). This avoids exporting resetMentionResolutionCaches entirely.
The current approach works, but the reset-hook-in-exports pattern is a signal worth addressing before it multiplies.
| await resolveMentionsLazily("Hello @maintainer1", [], "owner", "repo", mockGithub, mockCore); | ||
| await resolveMentionsLazily("Hello @maintainer1", [], "owner", "repo", mockGithub, mockCore); | ||
|
|
||
| expect(mockGithub.rest.repos.listCollaborators).toHaveBeenCalledTimes(1); |
There was a problem hiding this comment.
[/tdd] The two new tests cover getRecentCollaborators cache-hit and the no-mention early exit, but userPermissionCache — the more complex code path — has no cache-hit test. This path is reached when a mentioned user is absent from the collaborator list and falls through to checkUserPermission, which also caches bot results, permission levels, and errors.
💡 Suggested test skeleton
it("should reuse cached permission when user is not in collaborator list", async () => {
// User not in collaborators → falls through to checkUserPermission
mockGithub.rest.repos.listCollaborators.mockResolvedValue({ data: [] });
mockGithub.rest.users.getByUsername.mockResolvedValue({ data: { login: "external1", type: "User" } });
mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue(
{ data: { permission: "write" } }
);
await resolveMentionsLazily("Hello `@external1`", [], "owner", "repo", mockGithub, mockCore);
await resolveMentionsLazily("Hello `@external1`", [], "owner", "repo", mockGithub, mockCore);
// Second resolution should hit userPermissionCache, not call the API again
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenCalledTimes(1);
});Without this, the error-path memoisation (catch → false) and bot-user caching are also untested.
|
@copilot review all comments and review, lint js, typecheck, apply copilot-review |
1 similar comment
|
@copilot review all comments and review, lint js, typecheck, apply copilot-review |
Add @ts-expect-error suppression for Map.get() call after .has() check, which TypeScript doesn't recognize as a type guard. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Replace @ts-expect-error with explicit undefined check for better type safety and clearer intent, addressing code review feedback. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Revert to simpler single-line return with @ts-expect-error and improved comment explaining TypeScript's Map.has() type guard limitation. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Smoke CI is the largest recurring REST consumer (~1,465 calls/run), with repeated read-heavy calls being a primary optimization target. This change reduces avoidable mention-resolution API traffic without changing smoke coverage behavior.
What changed
actions/setup/js/resolve_mentions.cjsfor:repos.listCollaboratorsresultsrepos.getCollaboratorPermissionLevelresults per usernameWhy this reduces API footprint
Targeted test updates