Skip to content

Commit edfbca3

Browse files
committed
fix: addresses PR review findings across sort, perf, and tests
1 parent f04eb24 commit edfbca3

File tree

4 files changed

+100
-11
lines changed

4 files changed

+100
-11
lines changed

src/app/components/onboarding/RepoSelector.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,15 @@ export default function RepoSelector(props: RepoSelectorProps) {
153153
// After initial load (all orgs resolved), sorting stays active during retries
154154
// because loadedCount is not reset by retryOrg.
155155
if (loadedCount() < props.selectedOrgs.length) return states;
156+
const maxPushedAt = new Map(
157+
states.map((s) => [
158+
s.org,
159+
s.repos.reduce((max, r) => r.pushedAt && r.pushedAt > max ? r.pushedAt : max, ""),
160+
])
161+
);
156162
return [...states].sort((a, b) => {
157-
const aMax = a.repos.reduce((max, r) => r.pushedAt && r.pushedAt > max ? r.pushedAt : max, "");
158-
const bMax = b.repos.reduce((max, r) => r.pushedAt && r.pushedAt > max ? r.pushedAt : max, "");
163+
const aMax = maxPushedAt.get(a.org) ?? "";
164+
const bMax = maxPushedAt.get(b.org) ?? "";
159165
return aMax > bMax ? -1 : aMax < bMax ? 1 : 0;
160166
});
161167
});
@@ -272,7 +278,7 @@ export default function RepoSelector(props: RepoSelectorProps) {
272278
{/* Per-org repo lists */}
273279
<For each={sortedOrgStates()}>
274280
{(state) => {
275-
const visible = () => filteredReposForOrg(state);
281+
const visible = createMemo(() => filteredReposForOrg(state));
276282

277283
return (
278284
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">

src/app/services/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,14 +1037,14 @@ export async function fetchWorkflowRuns(
10371037
runs,
10381038
latestAt: runs.reduce((max, r) => r.updated_at > max ? r.updated_at : max, ""),
10391039
}));
1040-
workflowEntries.sort((a, b) => b.latestAt > a.latestAt ? -1 : b.latestAt < a.latestAt ? 1 : 0);
1040+
workflowEntries.sort((a, b) => a.latestAt > b.latestAt ? -1 : a.latestAt < b.latestAt ? 1 : 0);
10411041
const topWorkflows = workflowEntries
10421042
.slice(0, maxWorkflows);
10431043

10441044
// Take most recent M runs per workflow
10451045
for (const { runs: workflowRuns } of topWorkflows) {
10461046
const sorted = workflowRuns.sort(
1047-
(a, b) => b.created_at > a.created_at ? -1 : b.created_at < a.created_at ? 1 : 0
1047+
(a, b) => a.created_at > b.created_at ? -1 : a.created_at < b.created_at ? 1 : 0
10481048
);
10491049
for (const run of sorted.slice(0, maxRuns)) {
10501050
allRuns.push({

tests/components/onboarding/RepoSelector.test.tsx

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
2-
import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library";
2+
import { render, screen, waitFor } from "@solidjs/testing-library";
33
import userEvent from "@testing-library/user-event";
44
import type { RepoRef, RepoEntry } from "../../../src/app/services/api";
55

@@ -38,6 +38,7 @@ const otherorgRepos: RepoEntry[] = [
3838
describe("RepoSelector", () => {
3939
beforeEach(() => {
4040
vi.clearAllMocks();
41+
vi.restoreAllMocks();
4142
});
4243

4344
it("shows loading while fetching repos", async () => {
@@ -100,6 +101,7 @@ describe("RepoSelector", () => {
100101
});
101102

102103
it("filters repos by text input", async () => {
104+
const user = userEvent.setup();
103105
vi.mocked(api.fetchRepos).mockResolvedValue(myorgRepos);
104106

105107
render(() => (
@@ -111,7 +113,7 @@ describe("RepoSelector", () => {
111113
});
112114

113115
const filterInput = screen.getByPlaceholderText(/Filter repos/i);
114-
fireEvent.input(filterInput, { target: { value: "repo-a" } });
116+
await user.type(filterInput, "repo-a");
115117

116118
await waitFor(() => {
117119
screen.getByText("repo-a");
@@ -132,9 +134,8 @@ describe("RepoSelector", () => {
132134
screen.getByText("repo-a");
133135
});
134136

135-
// "Select All" button in the org header (there may be multiple — use the first one)
137+
// With a single org: [global Select All, per-org Select All] — click the per-org (last) one
136138
const selectAllBtns = screen.getAllByText("Select All");
137-
// The per-org one is inside the org group; for a single org there's only one
138139
await user.click(selectAllBtns[selectAllBtns.length - 1]);
139140

140141
expect(onChange).toHaveBeenCalled();
@@ -215,13 +216,14 @@ describe("RepoSelector", () => {
215216
});
216217

217218
it("shows relative time next to each repo", async () => {
219+
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-24T12:00:00Z").getTime());
218220
vi.mocked(api.fetchRepos).mockResolvedValue(myorgRepos);
219221
render(() => (
220222
<RepoSelector selectedOrgs={["myorg"]} selected={[]} onChange={vi.fn()} />
221223
));
222224
await waitFor(() => {
223-
const labels = screen.getAllByText(/ago|yesterday|just now|last/i);
224-
expect(labels.length).toBeGreaterThanOrEqual(2);
225+
screen.getByText("4 days ago");
226+
screen.getByText("2 days ago");
225227
});
226228
});
227229

@@ -261,4 +263,53 @@ describe("RepoSelector", () => {
261263
});
262264
expect(screen.queryByText(/ago|yesterday|just now|last/i)).toBeNull();
263265
});
266+
267+
it("global Select All strips pushedAt from onChange payload", async () => {
268+
const user = userEvent.setup();
269+
vi.mocked(api.fetchRepos).mockImplementation((_client, org) => {
270+
if (org === "myorg") return Promise.resolve(myorgRepos);
271+
return Promise.resolve(otherorgRepos);
272+
});
273+
const onChange = vi.fn();
274+
render(() => (
275+
<RepoSelector selectedOrgs={["myorg", "otherog"]} selected={[]} onChange={onChange} />
276+
));
277+
await waitFor(() => {
278+
screen.getByText("repo-a");
279+
screen.getByText("repo-c");
280+
});
281+
// The first "Select All" button is the global one in the header
282+
const selectAllBtns = screen.getAllByText("Select All");
283+
await user.click(selectAllBtns[0]);
284+
expect(onChange).toHaveBeenCalled();
285+
const result = onChange.mock.calls[0][0] as RepoRef[];
286+
expect(result.length).toBe(3);
287+
for (const r of result) {
288+
expect(r).not.toHaveProperty("pushedAt");
289+
}
290+
});
291+
292+
it("preserves org order when all repos have null pushedAt", async () => {
293+
const nullOrg1: RepoEntry[] = [
294+
{ owner: "stale-org", name: "null-repo-1", fullName: "stale-org/null-repo-1", pushedAt: null },
295+
];
296+
const nullOrg2: RepoEntry[] = [
297+
{ owner: "active-org", name: "null-repo-2", fullName: "active-org/null-repo-2", pushedAt: null },
298+
];
299+
vi.mocked(api.fetchRepos).mockImplementation((_client, org) => {
300+
if (org === "stale-org") return Promise.resolve(nullOrg1);
301+
return Promise.resolve(nullOrg2);
302+
});
303+
render(() => (
304+
<RepoSelector selectedOrgs={["stale-org", "active-org"]} selected={[]} onChange={vi.fn()} />
305+
));
306+
await waitFor(() => {
307+
screen.getByText("null-repo-1");
308+
screen.getByText("null-repo-2");
309+
});
310+
const orgHeaders = screen.getAllByText(/^(active-org|stale-org)$/);
311+
// Both have null pushedAt → comparator returns 0 → original order preserved
312+
expect(orgHeaders[0].textContent).toBe("stale-org");
313+
expect(orgHeaders[1].textContent).toBe("active-org");
314+
});
264315
});

tests/services/api.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,38 @@ describe("fetchWorkflowRuns", () => {
720720
fetchWorkflowRuns(null, [testRepo], 5, 3)
721721
).rejects.toThrow("No GitHub client available");
722722
});
723+
724+
it("returns runs sorted newest-first within each workflow", async () => {
725+
const octokit = makeOctokitForRuns();
726+
727+
const { workflowRuns } = await fetchWorkflowRuns(
728+
octokit as unknown as ReturnType<typeof import("../../src/app/services/github").getClient>,
729+
[testRepo],
730+
5,
731+
10
732+
);
733+
734+
// Workflow 1001 has 3 runs: 9002 (10:00), 9001 (09:00), 9003 (14:15:00 prev day)
735+
const w1001Runs = workflowRuns.filter((r) => r.workflowId === 1001);
736+
for (let i = 1; i < w1001Runs.length; i++) {
737+
expect(w1001Runs[i - 1].createdAt >= w1001Runs[i].createdAt).toBe(true);
738+
}
739+
});
740+
741+
it("selects workflows with most recent activity first", async () => {
742+
const octokit = makeOctokitForRuns();
743+
744+
const { workflowRuns } = await fetchWorkflowRuns(
745+
octokit as unknown as ReturnType<typeof import("../../src/app/services/github").getClient>,
746+
[testRepo],
747+
5,
748+
10
749+
);
750+
751+
// First run in results should be from the workflow with the most recent updatedAt
752+
// Workflow 1001 latestAt=2024-01-15T10:05:00Z > Workflow 1002 latestAt=2024-01-15T09:25:00Z
753+
expect(workflowRuns[0].workflowId).toBe(1001);
754+
});
723755
});
724756

725757
// ── searchAllPages pagination ─────────────────────────────────────────────────

0 commit comments

Comments
 (0)