Skip to content

Commit abff219

Browse files
committed
fix: addresses PR review findings from code review
1 parent b70601c commit abff219

File tree

4 files changed

+149
-43
lines changed

4 files changed

+149
-43
lines changed

src/app/components/onboarding/OnboardingWizard.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,11 @@ export default function OnboardingWizard() {
5757

5858
function handleFinish() {
5959
const uniqueOrgs = [...new Set(selectedRepos().map((r) => r.owner))];
60-
// Prune monitoredRepos to only repos still in selectedRepos
61-
const selectedSet = new Set(selectedRepos().map((r) => r.fullName));
62-
const prunedMonitoredRepos = monitoredRepos().filter((r) => selectedSet.has(r.fullName));
6360
updateConfig({
6461
selectedOrgs: uniqueOrgs,
6562
selectedRepos: selectedRepos(),
6663
upstreamRepos: upstreamRepos(),
67-
monitoredRepos: prunedMonitoredRepos,
64+
monitoredRepos: monitoredRepos(),
6865
onboardingComplete: true,
6966
});
7067
// Flush synchronously — the debounced persistence effect won't fire before page unload

src/app/services/api.ts

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -914,8 +914,6 @@ export interface FetchIssuesAndPRsResult {
914914
interface LightSearchResult {
915915
issues: Issue[];
916916
pullRequests: PullRequest[];
917-
/** GraphQL node IDs for phase 2 backfill */
918-
prNodeIds: string[];
919917
errors: ApiError[];
920918
}
921919

@@ -1058,6 +1056,32 @@ async function executeLightCombinedQuery(
10581056
}
10591057
}
10601058

1059+
/** Cap-check, notify, and assemble a LightSearchResult from working collections. */
1060+
function finalizeSearchResult(
1061+
issues: Issue[],
1062+
prMap: Map<number, PullRequest>,
1063+
errors: ApiError[],
1064+
issueSource: string,
1065+
prSource: string,
1066+
issueLabel: string,
1067+
prLabel: string,
1068+
): LightSearchResult {
1069+
if (issues.length >= SEARCH_RESULT_CAP) {
1070+
console.warn(`[api] ${issueLabel} capped at ${SEARCH_RESULT_CAP}`);
1071+
pushNotification(issueSource, `${issueLabel} capped at ${SEARCH_RESULT_CAP.toLocaleString("en-US")} — some items are hidden`, "warning");
1072+
issues.splice(SEARCH_RESULT_CAP);
1073+
}
1074+
1075+
if (prMap.size >= SEARCH_RESULT_CAP) {
1076+
console.warn(`[api] ${prLabel} capped at ${SEARCH_RESULT_CAP}`);
1077+
pushNotification(prSource, `${prLabel} capped at ${SEARCH_RESULT_CAP.toLocaleString("en-US")} — some items are hidden`, "warning");
1078+
}
1079+
1080+
const pullRequests = [...prMap.values()];
1081+
if (pullRequests.length >= SEARCH_RESULT_CAP) pullRequests.splice(SEARCH_RESULT_CAP);
1082+
return { issues, pullRequests, errors };
1083+
}
1084+
10611085
/**
10621086
* Phase 1: light combined search. Fetches issues fully and PRs with minimal fields.
10631087
* Returns light PRs (enriched: false) and their GraphQL node IDs for phase 2 backfill.
@@ -1071,7 +1095,6 @@ async function graphqlLightCombinedSearch(
10711095
return {
10721096
issues: [],
10731097
pullRequests: [],
1074-
prNodeIds: [],
10751098
errors: [{ repo: "search", statusCode: null, message: "Invalid userLogin", retryable: false }],
10761099
};
10771100
}
@@ -1101,22 +1124,11 @@ async function graphqlLightCombinedSearch(
11011124
}
11021125
}));
11031126

1104-
if (issues.length >= SEARCH_RESULT_CAP) {
1105-
console.warn(`[api] Issue search results capped at ${SEARCH_RESULT_CAP}`);
1106-
pushNotification("search/issues", `Issue search results capped at ${SEARCH_RESULT_CAP.toLocaleString("en-US")} — some items are hidden`, "warning");
1107-
issues.splice(SEARCH_RESULT_CAP);
1108-
}
1109-
1110-
if (prMap.size >= SEARCH_RESULT_CAP) {
1111-
console.warn(`[api] PR search results capped at ${SEARCH_RESULT_CAP}`);
1112-
pushNotification("search/prs", `PR search results capped at ${SEARCH_RESULT_CAP.toLocaleString("en-US")} — some items are hidden`, "warning");
1113-
}
1114-
1115-
const pullRequests = [...prMap.values()];
1116-
if (pullRequests.length >= SEARCH_RESULT_CAP) pullRequests.splice(SEARCH_RESULT_CAP);
1117-
const prNodeIds = pullRequests.map((pr) => nodeIdMap.get(pr.id)).filter((id): id is string => id != null);
1118-
1119-
return { issues, pullRequests, prNodeIds, errors };
1127+
return finalizeSearchResult(
1128+
issues, prMap, errors,
1129+
"search/issues", "search/prs",
1130+
"Issue search results", "PR search results",
1131+
);
11201132
}
11211133

11221134
/**
@@ -1129,7 +1141,7 @@ async function graphqlUnfilteredSearch(
11291141
octokit: GitHubOctokit,
11301142
repos: RepoRef[]
11311143
): Promise<LightSearchResult> {
1132-
if (repos.length === 0) return { issues: [], pullRequests: [], prNodeIds: [], errors: [] };
1144+
if (repos.length === 0) return { issues: [], pullRequests: [], errors: [] };
11331145

11341146
const chunks = chunkArray(repos, SEARCH_REPO_BATCH_SIZE);
11351147
const issueSeen = new Set<number>();
@@ -1207,21 +1219,11 @@ async function graphqlUnfilteredSearch(
12071219
}
12081220
}));
12091221

1210-
if (issues.length >= SEARCH_RESULT_CAP) {
1211-
console.warn(`[api] Unfiltered issue results capped at ${SEARCH_RESULT_CAP}`);
1212-
pushNotification("search/unfiltered-issues", `Monitored repo issue results capped at ${SEARCH_RESULT_CAP.toLocaleString("en-US")} — some items are hidden`, "warning");
1213-
issues.splice(SEARCH_RESULT_CAP);
1214-
}
1215-
1216-
if (prMap.size >= SEARCH_RESULT_CAP) {
1217-
console.warn(`[api] Unfiltered PR results capped at ${SEARCH_RESULT_CAP}`);
1218-
pushNotification("search/unfiltered-prs", `Monitored repo PR results capped at ${SEARCH_RESULT_CAP.toLocaleString("en-US")} — some items are hidden`, "warning");
1219-
}
1220-
1221-
const pullRequests = [...prMap.values()];
1222-
if (pullRequests.length >= SEARCH_RESULT_CAP) pullRequests.splice(SEARCH_RESULT_CAP);
1223-
const prNodeIds = pullRequests.map((pr) => nodeIdMap.get(pr.id)).filter((id): id is string => id != null);
1224-
return { issues, pullRequests, prNodeIds, errors };
1222+
return finalizeSearchResult(
1223+
issues, prMap, errors,
1224+
"search/unfiltered-issues", "search/unfiltered-prs",
1225+
"Monitored repo issue results", "Monitored repo PR results",
1226+
);
12251227
}
12261228

12271229
// ── Two-phase: heavy backfill ─────────────────────────────────────────────────
@@ -1356,7 +1358,6 @@ function mergeEnrichment(
13561358
* Merges tracked user search results into the main issue/PR maps.
13571359
* Items already present get the tracked user's login appended to surfacedBy.
13581360
* New items are added with surfacedBy: [trackedLogin].
1359-
* Returns a union of all prNodeIds for backfill.
13601361
*/
13611362
function mergeTrackedUserResults(
13621363
issueMap: Map<number, Issue>,
@@ -1475,7 +1476,7 @@ export async function fetchIssuesAndPullRequests(
14751476
// Unfiltered search for monitored repos — runs in parallel with tracked searches
14761477
const unfilteredPromise = monitoredReposList.length > 0
14771478
? graphqlUnfilteredSearch(octokit, monitoredReposList)
1478-
: Promise.resolve({ issues: [], pullRequests: [], prNodeIds: [], errors: [] } as LightSearchResult);
1479+
: Promise.resolve({ issues: [], pullRequests: [], errors: [] } as LightSearchResult);
14791480

14801481
const [mainBackfill, trackedResults, unfilteredResult] = await Promise.all([
14811482
mainBackfillPromise,
@@ -2210,7 +2211,7 @@ export async function validateGitHubUser(
22102211
login: raw.login.toLowerCase(),
22112212
avatarUrl,
22122213
name: raw.name ?? null,
2213-
type: raw.type === "Bot" ? "bot" as const : "user" as const,
2214+
type: raw.type === "Bot" ? "bot" : "user",
22142215
};
22152216
}
22162217

tests/services/api.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ describe("fetchIssues", () => {
475475
const result = await fetchIssues(
476476
octokit as never,
477477
[testRepo],
478-
"bad user" // contains space — fails VALID_LOGIN
478+
"bad user" // contains space — fails VALID_TRACKED_LOGIN
479479
);
480480

481481
expect(result.issues).toEqual([]);
@@ -1904,6 +1904,71 @@ describe("fetchIssuesAndPullRequests — unfiltered search error handling", () =
19041904
expect(result.errors.length).toBeGreaterThan(0);
19051905
expect(result.errors[0]).toMatchObject({ retryable: true });
19061906
});
1907+
1908+
it("recovers PR data when issues is null but prs has data (symmetric partial case)", async () => {
1909+
const monitoredRepo = { owner: "org", name: "monitored", fullName: "org/monitored" };
1910+
// Fixture matches GraphQLLightPRNode — no heavy fields (additions, commits, etc.)
1911+
const prNode = {
1912+
id: "PR_kwDOtest4501",
1913+
databaseId: 4501,
1914+
number: 1,
1915+
title: "Partial PR",
1916+
state: "open",
1917+
isDraft: false,
1918+
url: "https://github.com/org/monitored/pull/1",
1919+
createdAt: "2024-01-01T00:00:00Z",
1920+
updatedAt: "2024-01-02T00:00:00Z",
1921+
author: { login: "someone", avatarUrl: "https://avatars.githubusercontent.com/u/1" },
1922+
repository: { nameWithOwner: "org/monitored" },
1923+
headRefName: "fix/thing",
1924+
baseRefName: "main",
1925+
reviewDecision: null,
1926+
labels: { nodes: [] },
1927+
};
1928+
1929+
let callCount = 0;
1930+
const octokit = makeOctokit(
1931+
async () => ({ data: {}, headers: {} }),
1932+
async () => {
1933+
callCount++;
1934+
if (callCount === 1) {
1935+
const err = Object.assign(new Error("Partial failure"), {
1936+
data: {
1937+
issues: null,
1938+
prs: { issueCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [prNode] },
1939+
rateLimit: { limit: 5000, remaining: 4998, resetAt: new Date(Date.now() + 3600000).toISOString() },
1940+
},
1941+
});
1942+
throw err;
1943+
}
1944+
return {
1945+
issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1946+
prs: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1947+
prInvolves: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1948+
prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1949+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
1950+
};
1951+
}
1952+
);
1953+
1954+
const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api");
1955+
const result = await fetchIssuesAndPullRequests(
1956+
octokit as never,
1957+
[monitoredRepo],
1958+
"octocat",
1959+
undefined,
1960+
undefined,
1961+
[{ owner: "org", name: "monitored", fullName: "org/monitored" }]
1962+
);
1963+
1964+
// PR data recovered from partial response
1965+
expect(result.pullRequests).toHaveLength(1);
1966+
expect(result.pullRequests[0].id).toBe(4501);
1967+
// Issues empty (null coalesced to empty)
1968+
expect(result.issues).toHaveLength(0);
1969+
// Error recorded
1970+
expect(result.errors.some(e => e.retryable)).toBe(true);
1971+
});
19071972
});
19081973

19091974
describe("fetchIssuesAndPullRequests — all monitored + tracked users skips involves: search", () => {

tests/stores/auth.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ vi.mock("../../src/app/stores/cache", () => ({
55
clearCache: vi.fn().mockResolvedValue(undefined),
66
}));
77

8+
const mockPushNotification = vi.fn();
9+
vi.mock("../../src/app/lib/errors", () => ({
10+
pushNotification: (...args: unknown[]) => mockPushNotification(...args),
11+
}));
12+
813
// Mock localStorage with full control (same pattern as config.test.ts)
914
const localStorageMock = (() => {
1015
let store: Record<string, string> = {};
@@ -68,6 +73,24 @@ describe("setAuth / token signal", () => {
6873
const fresh = await import("../../src/app/stores/auth");
6974
expect(fresh.token()).toBe("ghs_persisted");
7075
});
76+
77+
it("sets token in memory and warns when localStorage.setItem throws", () => {
78+
const origSetItem = localStorageMock.setItem;
79+
localStorageMock.setItem = () => { throw new DOMException("QuotaExceededError"); };
80+
mockPushNotification.mockClear();
81+
82+
try {
83+
mod.setAuth({ access_token: "ghs_quota" });
84+
expect(mod.token()).toBe("ghs_quota");
85+
expect(mockPushNotification).toHaveBeenCalledWith(
86+
"localStorage:auth",
87+
expect.stringContaining("storage may be full"),
88+
"warning",
89+
);
90+
} finally {
91+
localStorageMock.setItem = origSetItem;
92+
}
93+
});
7194
});
7295

7396
describe("isAuthenticated", () => {
@@ -351,6 +374,26 @@ describe("validateToken", () => {
351374
expect(mod.token()).toBe("ghs_retrynet");
352375
});
353376

377+
it("does not invalidate a new token set during the retry window (race condition guard)", async () => {
378+
vi.stubGlobal("fetch", vi.fn()
379+
// First GET /user returns 401
380+
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
381+
// Retry also returns 401 — but token was replaced mid-window
382+
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
383+
);
384+
385+
mod.setAuth({ access_token: "ghs_old" });
386+
const promise = mod.validateToken();
387+
// Simulate user re-authenticating during the 1-second retry delay
388+
mod.setAuth({ access_token: "ghs_new" });
389+
await vi.advanceTimersByTimeAsync(1000);
390+
const result = await promise;
391+
expect(result).toBe(false);
392+
// The NEW token must survive — guard prevents expireToken() from running
393+
expect(mod.token()).toBe("ghs_new");
394+
expect(localStorageMock.getItem("github-tracker:auth-token")).toBe("ghs_new");
395+
});
396+
354397
it("preserves user config and view state when token expires (not a full logout)", async () => {
355398
vi.stubGlobal("fetch", vi.fn()
356399
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })

0 commit comments

Comments
 (0)