Skip to content

Commit 1086d16

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

File tree

4 files changed

+156
-37
lines changed

4 files changed

+156
-37
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 & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,34 @@ async function executeLightCombinedQuery(
10581058
}
10591059
}
10601060

1061+
/** Cap-check, notify, and assemble a LightSearchResult from working collections. */
1062+
function finalizeSearchResult(
1063+
issues: Issue[],
1064+
prMap: Map<number, PullRequest>,
1065+
nodeIdMap: Map<number, string>,
1066+
errors: ApiError[],
1067+
issueSource: string,
1068+
prSource: string,
1069+
issueLabel: string,
1070+
prLabel: string,
1071+
): LightSearchResult {
1072+
if (issues.length >= SEARCH_RESULT_CAP) {
1073+
console.warn(`[api] ${issueLabel} capped at ${SEARCH_RESULT_CAP}`);
1074+
pushNotification(issueSource, `${issueLabel} capped at ${SEARCH_RESULT_CAP.toLocaleString("en-US")} — some items are hidden`, "warning");
1075+
issues.splice(SEARCH_RESULT_CAP);
1076+
}
1077+
1078+
if (prMap.size >= SEARCH_RESULT_CAP) {
1079+
console.warn(`[api] ${prLabel} capped at ${SEARCH_RESULT_CAP}`);
1080+
pushNotification(prSource, `${prLabel} capped at ${SEARCH_RESULT_CAP.toLocaleString("en-US")} — some items are hidden`, "warning");
1081+
}
1082+
1083+
const pullRequests = [...prMap.values()];
1084+
if (pullRequests.length >= SEARCH_RESULT_CAP) pullRequests.splice(SEARCH_RESULT_CAP);
1085+
const prNodeIds = pullRequests.map((pr) => nodeIdMap.get(pr.id)).filter((id): id is string => id != null);
1086+
return { issues, pullRequests, prNodeIds, errors };
1087+
}
1088+
10611089
/**
10621090
* Phase 1: light combined search. Fetches issues fully and PRs with minimal fields.
10631091
* Returns light PRs (enriched: false) and their GraphQL node IDs for phase 2 backfill.
@@ -1101,22 +1129,11 @@ async function graphqlLightCombinedSearch(
11011129
}
11021130
}));
11031131

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 };
1132+
return finalizeSearchResult(
1133+
issues, prMap, nodeIdMap, errors,
1134+
"search/issues", "search/prs",
1135+
"Issue search results", "PR search results",
1136+
);
11201137
}
11211138

11221139
/**
@@ -1207,21 +1224,11 @@ async function graphqlUnfilteredSearch(
12071224
}
12081225
}));
12091226

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 };
1227+
return finalizeSearchResult(
1228+
issues, prMap, nodeIdMap, errors,
1229+
"search/unfiltered-issues", "search/unfiltered-prs",
1230+
"Monitored repo issue results", "Monitored repo PR results",
1231+
);
12251232
}
12261233

12271234
// ── Two-phase: heavy backfill ─────────────────────────────────────────────────
@@ -2210,7 +2217,7 @@ export async function validateGitHubUser(
22102217
login: raw.login.toLowerCase(),
22112218
avatarUrl,
22122219
name: raw.name ?? null,
2213-
type: raw.type === "Bot" ? "bot" as const : "user" as const,
2220+
type: raw.type === "Bot" ? "bot" : "user",
22142221
};
22152222
}
22162223

tests/services/api.test.ts

Lines changed: 73 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,78 @@ 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+
const prNode = {
1911+
id: "PR_kwDOtest4501",
1912+
databaseId: 4501,
1913+
number: 1,
1914+
title: "Partial PR",
1915+
state: "open",
1916+
url: "https://github.com/org/monitored/pull/1",
1917+
createdAt: "2024-01-01T00:00:00Z",
1918+
updatedAt: "2024-01-02T00:00:00Z",
1919+
author: { login: "someone", avatarUrl: "https://avatars.githubusercontent.com/u/1" },
1920+
labels: { nodes: [] },
1921+
assignees: { nodes: [] },
1922+
repository: { nameWithOwner: "org/monitored" },
1923+
additions: 10,
1924+
deletions: 5,
1925+
changedFiles: 2,
1926+
headRefName: "fix/thing",
1927+
baseRefName: "main",
1928+
isDraft: false,
1929+
reviewDecision: null,
1930+
commits: { nodes: [{ commit: { statusCheckRollup: null } }] },
1931+
headRepository: { owner: { login: "org" } },
1932+
latestReviews: { nodes: [] },
1933+
reviewThreads: { totalCount: 0 },
1934+
};
1935+
1936+
let callCount = 0;
1937+
const octokit = makeOctokit(
1938+
async () => ({ data: {}, headers: {} }),
1939+
async () => {
1940+
callCount++;
1941+
if (callCount === 1) {
1942+
const err = Object.assign(new Error("Partial failure"), {
1943+
data: {
1944+
issues: null,
1945+
prs: { issueCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [prNode] },
1946+
rateLimit: { limit: 5000, remaining: 4998, resetAt: new Date(Date.now() + 3600000).toISOString() },
1947+
},
1948+
});
1949+
throw err;
1950+
}
1951+
return {
1952+
issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1953+
prs: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1954+
prInvolves: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1955+
prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1956+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
1957+
};
1958+
}
1959+
);
1960+
1961+
const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api");
1962+
const result = await fetchIssuesAndPullRequests(
1963+
octokit as never,
1964+
[monitoredRepo],
1965+
"octocat",
1966+
undefined,
1967+
undefined,
1968+
[{ owner: "org", name: "monitored", fullName: "org/monitored" }]
1969+
);
1970+
1971+
// PR data recovered from partial response
1972+
expect(result.pullRequests).toHaveLength(1);
1973+
expect(result.pullRequests[0].id).toBe(4501);
1974+
// Issues empty (null coalesced to empty)
1975+
expect(result.issues).toHaveLength(0);
1976+
// Error recorded
1977+
expect(result.errors.some(e => e.retryable)).toBe(true);
1978+
});
19071979
});
19081980

19091981
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)