diff --git a/src/app/components/onboarding/RepoSelector.tsx b/src/app/components/onboarding/RepoSelector.tsx index f7f95637..e9bae88b 100644 --- a/src/app/components/onboarding/RepoSelector.tsx +++ b/src/app/components/onboarding/RepoSelector.tsx @@ -6,8 +6,9 @@ import { Show, Index, } from "solid-js"; -import { fetchOrgs, fetchRepos, OrgEntry, RepoRef } from "../../services/api"; +import { fetchOrgs, fetchRepos, OrgEntry, RepoRef, RepoEntry } from "../../services/api"; import { getClient } from "../../services/github"; +import { relativeTime } from "../../lib/format"; import LoadingSpinner from "../shared/LoadingSpinner"; import FilterInput from "../shared/FilterInput"; @@ -20,7 +21,7 @@ interface RepoSelectorProps { interface OrgRepoState { org: string; type: "org" | "user"; - repos: RepoRef[]; + repos: RepoEntry[]; loading: boolean; error: string | null; } @@ -146,15 +147,38 @@ export default function RepoSelector(props: RepoSelectorProps) { new Set(props.selected.map((r) => r.fullName)) ); + const sortedOrgStates = createMemo(() => { + const states = orgStates(); + // Defer sorting during initial load to prevent layout shift as orgs trickle in. + // After initial load (all orgs resolved), sorting stays active during retries + // because loadedCount is not reset by retryOrg. + if (loadedCount() < props.selectedOrgs.length) return states; + const maxPushedAt = new Map( + states.map((s) => [ + s.org, + s.repos.reduce((max, r) => r.pushedAt && r.pushedAt > max ? r.pushedAt : max, ""), + ]) + ); + return [...states].sort((a, b) => { + const aMax = maxPushedAt.get(a.org) ?? ""; + const bMax = maxPushedAt.get(b.org) ?? ""; + return aMax > bMax ? -1 : aMax < bMax ? 1 : 0; + }); + }); + + function toRepoRef(entry: RepoEntry): RepoRef { + return { owner: entry.owner, name: entry.name, fullName: entry.fullName }; + } + function isSelected(fullName: string) { return selectedSet().has(fullName); } - function toggleRepo(repo: RepoRef) { + function toggleRepo(repo: RepoEntry) { if (isSelected(repo.fullName)) { props.onChange(props.selected.filter((r) => r.fullName !== repo.fullName)); } else { - props.onChange([...props.selected, repo]); + props.onChange([...props.selected, toRepoRef(repo)]); } } @@ -162,7 +186,7 @@ export default function RepoSelector(props: RepoSelectorProps) { const q = () => filter().toLowerCase().trim(); - function filteredReposForOrg(state: OrgRepoState): RepoRef[] { + function filteredReposForOrg(state: OrgRepoState): RepoEntry[] { const query = q(); if (!query) return state.repos; return state.repos.filter( @@ -177,7 +201,7 @@ export default function RepoSelector(props: RepoSelectorProps) { function selectAllInOrg(state: OrgRepoState) { const visible = filteredReposForOrg(state); const current = new Map(props.selected.map((r) => [r.fullName, r])); - for (const repo of visible) current.set(repo.fullName, repo); + for (const repo of visible) current.set(repo.fullName, toRepoRef(repo)); props.onChange([...current.values()]); } @@ -186,18 +210,13 @@ export default function RepoSelector(props: RepoSelectorProps) { props.onChange(props.selected.filter((r) => !visible.has(r.fullName))); } - function allVisibleInOrgSelected(state: OrgRepoState): boolean { - const visible = filteredReposForOrg(state); - return visible.length > 0 && visible.every((r) => isSelected(r.fullName)); - } - // ── Global select/deselect all ──────────────────────────────────────────── function selectAll() { const current = new Map(props.selected.map((r) => [r.fullName, r])); for (const state of orgStates()) { for (const repo of filteredReposForOrg(state)) { - current.set(repo.fullName, repo); + current.set(repo.fullName, toRepoRef(repo)); } } props.onChange([...current.values()]); @@ -252,9 +271,9 @@ export default function RepoSelector(props: RepoSelectorProps) { {/* Per-org repo lists */} - + {(state) => { - const visible = () => filteredReposForOrg(state); + const visible = createMemo(() => filteredReposForOrg(state)); return (
@@ -269,8 +288,8 @@ export default function RepoSelector(props: RepoSelectorProps) { type="button" onClick={() => selectAllInOrg(state)} disabled={ - allVisibleInOrgSelected(state) || - visible().length === 0 + visible().length === 0 || + visible().every((r) => isSelected(r.fullName)) } class="text-xs text-blue-600 hover:underline disabled:cursor-not-allowed disabled:opacity-40 dark:text-blue-400" > @@ -341,9 +360,14 @@ export default function RepoSelector(props: RepoSelectorProps) { />
- + {repo().name} + + + {relativeTime(repo().pushedAt!)} + +
diff --git a/src/app/lib/format.ts b/src/app/lib/format.ts index c7ca0203..6e6243da 100644 --- a/src/app/lib/format.ts +++ b/src/app/lib/format.ts @@ -6,6 +6,7 @@ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); */ export function relativeTime(isoString: string): string { const diffMs = Date.now() - new Date(isoString).getTime(); + if (isNaN(diffMs)) return ""; const diffSec = Math.floor(diffMs / 1000); if (diffSec < 60) return rtf.format(-diffSec, "second"); diff --git a/src/app/services/api.ts b/src/app/services/api.ts index b7e451df..788b215e 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -16,6 +16,10 @@ export interface RepoRef { fullName: string; } +export interface RepoEntry extends RepoRef { + pushedAt: string | null; +} + export interface Issue { id: number; number: number; @@ -110,6 +114,7 @@ interface RawRepo { owner: { login: string }; name: string; full_name: string; + pushed_at: string | null; } interface RawPullRequest { @@ -380,32 +385,42 @@ export async function fetchRepos( octokit: ReturnType, orgOrUser: string, type: "org" | "user" -): Promise { +): Promise { if (!octokit) throw new Error("No GitHub client available"); - const route = - type === "org" - ? `GET /orgs/{org}/repos` - : `GET /user/repos`; - - const params = - type === "org" - ? { org: orgOrUser, per_page: 100 } - : { affiliation: "owner", per_page: 100 }; - - const repos: RepoRef[] = []; + const repos: RepoEntry[] = []; - for await (const response of octokit.paginate.iterator(route, params)) { - const page = response.data as RawRepo[]; + function collectRepos(page: RawRepo[], into: RepoEntry[]): void { for (const repo of page) { - repos.push({ + into.push({ owner: repo.owner.login, name: repo.name, fullName: repo.full_name, + pushedAt: repo.pushed_at ?? null, }); } } + if (type === "org") { + for await (const response of octokit.paginate.iterator(`GET /orgs/{org}/repos`, { + org: orgOrUser, + per_page: 100, + sort: "pushed" as const, + direction: "desc" as const, + })) { + collectRepos(response.data as RawRepo[], repos); + } + } else { + for await (const response of octokit.paginate.iterator(`GET /user/repos`, { + affiliation: "owner", + per_page: 100, + sort: "pushed" as const, + direction: "desc" as const, + })) { + collectRepos(response.data as RawRepo[], repos); + } + } + return repos; } @@ -1022,14 +1037,14 @@ export async function fetchWorkflowRuns( runs, latestAt: runs.reduce((max, r) => r.updated_at > max ? r.updated_at : max, ""), })); - workflowEntries.sort((a, b) => b.latestAt > a.latestAt ? -1 : b.latestAt < a.latestAt ? 1 : 0); + workflowEntries.sort((a, b) => a.latestAt > b.latestAt ? -1 : a.latestAt < b.latestAt ? 1 : 0); const topWorkflows = workflowEntries .slice(0, maxWorkflows); // Take most recent M runs per workflow for (const { runs: workflowRuns } of topWorkflows) { const sorted = workflowRuns.sort( - (a, b) => b.created_at > a.created_at ? -1 : b.created_at < a.created_at ? 1 : 0 + (a, b) => a.created_at > b.created_at ? -1 : a.created_at < b.created_at ? 1 : 0 ); for (const run of sorted.slice(0, maxRuns)) { allRuns.push({ diff --git a/tests/components/onboarding/RepoSelector.test.tsx b/tests/components/onboarding/RepoSelector.test.tsx index fcd8866b..b4f1e734 100644 --- a/tests/components/onboarding/RepoSelector.test.tsx +++ b/tests/components/onboarding/RepoSelector.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library"; +import { render, screen, waitFor } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; -import type { RepoRef } from "../../../src/app/services/api"; +import type { RepoRef, RepoEntry } from "../../../src/app/services/api"; // Mock getClient before importing component vi.mock("../../../src/app/services/github", () => ({ @@ -16,6 +16,8 @@ vi.mock("../../../src/app/services/api", async (importOriginal) => { fetchOrgs: vi.fn().mockResolvedValue([ { login: "myorg", avatarUrl: "", type: "org" }, { login: "otherog", avatarUrl: "", type: "org" }, + { login: "stale-org", avatarUrl: "", type: "org" }, + { login: "active-org", avatarUrl: "", type: "org" }, ]), fetchRepos: vi.fn(), }; @@ -24,18 +26,19 @@ vi.mock("../../../src/app/services/api", async (importOriginal) => { import * as api from "../../../src/app/services/api"; import RepoSelector from "../../../src/app/components/onboarding/RepoSelector"; -const myorgRepos: RepoRef[] = [ - { owner: "myorg", name: "repo-a", fullName: "myorg/repo-a" }, - { owner: "myorg", name: "repo-b", fullName: "myorg/repo-b" }, +const myorgRepos: RepoEntry[] = [ + { owner: "myorg", name: "repo-a", fullName: "myorg/repo-a", pushedAt: "2026-03-20T10:00:00Z" }, + { owner: "myorg", name: "repo-b", fullName: "myorg/repo-b", pushedAt: "2026-03-22T10:00:00Z" }, ]; -const otherorgRepos: RepoRef[] = [ - { owner: "otherog", name: "repo-c", fullName: "otherog/repo-c" }, +const otherorgRepos: RepoEntry[] = [ + { owner: "otherog", name: "repo-c", fullName: "otherog/repo-c", pushedAt: "2026-03-10T10:00:00Z" }, ]; describe("RepoSelector", () => { beforeEach(() => { vi.clearAllMocks(); + vi.restoreAllMocks(); }); it("shows loading while fetching repos", async () => { @@ -90,10 +93,15 @@ describe("RepoSelector", () => { }); await user.click(repoACheckbox!); - expect(onChange).toHaveBeenCalledWith([myorgRepos[0]]); + expect(onChange).toHaveBeenCalledWith([ + expect.objectContaining({ owner: "myorg", name: "repo-a", fullName: "myorg/repo-a" }), + ]); + const result = onChange.mock.calls[0][0] as RepoRef[]; + expect(result[0]).not.toHaveProperty("pushedAt"); }); it("filters repos by text input", async () => { + const user = userEvent.setup(); vi.mocked(api.fetchRepos).mockResolvedValue(myorgRepos); render(() => ( @@ -105,7 +113,7 @@ describe("RepoSelector", () => { }); const filterInput = screen.getByPlaceholderText(/Filter repos/i); - fireEvent.input(filterInput, { target: { value: "repo-a" } }); + await user.type(filterInput, "repo-a"); await waitFor(() => { screen.getByText("repo-a"); @@ -126,15 +134,17 @@ describe("RepoSelector", () => { screen.getByText("repo-a"); }); - // "Select All" button in the org header (there may be multiple — use the first one) + // With a single org: [global Select All, per-org Select All] — click the per-org (last) one const selectAllBtns = screen.getAllByText("Select All"); - // The per-org one is inside the org group; for a single org there's only one await user.click(selectAllBtns[selectAllBtns.length - 1]); expect(onChange).toHaveBeenCalled(); const result = onChange.mock.calls[0][0] as RepoRef[]; expect(result.map((r) => r.fullName)).toContain("myorg/repo-a"); expect(result.map((r) => r.fullName)).toContain("myorg/repo-b"); + for (const r of result) { + expect(r).not.toHaveProperty("pushedAt"); + } }); it("per-org Deselect All deselects all repos in that org", async () => { @@ -143,7 +153,7 @@ describe("RepoSelector", () => { const onChange = vi.fn(); render(() => ( - + ({ owner: r.owner, name: r.name, fullName: r.fullName }))} onChange={onChange} /> )); await waitFor(() => { @@ -197,11 +207,109 @@ describe("RepoSelector", () => { vi.mocked(api.fetchRepos).mockResolvedValue(myorgRepos); render(() => ( - + ({ owner: r.owner, name: r.name, fullName: r.fullName }))} onChange={vi.fn()} /> )); await waitFor(() => { screen.getByText(/2 repos selected/i); }); }); + + it("shows relative time next to each repo", async () => { + vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-24T12:00:00Z").getTime()); + vi.mocked(api.fetchRepos).mockResolvedValue(myorgRepos); + render(() => ( + + )); + await waitFor(() => { + screen.getByText("4 days ago"); + screen.getByText("2 days ago"); + }); + }); + + it("sorts org groups by most recent activity", async () => { + const staleRepos: RepoEntry[] = [ + { owner: "stale-org", name: "old-repo", fullName: "stale-org/old-repo", pushedAt: "2025-01-01T00:00:00Z" }, + ]; + const activeRepos: RepoEntry[] = [ + { owner: "active-org", name: "new-repo", fullName: "active-org/new-repo", pushedAt: "2026-03-23T00:00:00Z" }, + ]; + vi.mocked(api.fetchRepos).mockImplementation((_client, org) => { + if (org === "stale-org") return Promise.resolve(staleRepos); + return Promise.resolve(activeRepos); + }); + render(() => ( + + )); + await waitFor(() => { + screen.getByText("old-repo"); + screen.getByText("new-repo"); + }); + const orgHeaders = screen.getAllByText(/^(active-org|stale-org)$/); + expect(orgHeaders[0].textContent).toBe("active-org"); + expect(orgHeaders[1].textContent).toBe("stale-org"); + }); + + it("does not show timestamp for repos with null pushedAt", async () => { + const reposWithNull: RepoEntry[] = [ + { owner: "myorg", name: "empty-repo", fullName: "myorg/empty-repo", pushedAt: null }, + ]; + vi.mocked(api.fetchRepos).mockResolvedValue(reposWithNull); + render(() => ( + + )); + await waitFor(() => { + screen.getByText("empty-repo"); + }); + expect(screen.queryByText(/ago|yesterday|just now|last/i)).toBeNull(); + }); + + it("global Select All strips pushedAt from onChange payload", async () => { + const user = userEvent.setup(); + vi.mocked(api.fetchRepos).mockImplementation((_client, org) => { + if (org === "myorg") return Promise.resolve(myorgRepos); + return Promise.resolve(otherorgRepos); + }); + const onChange = vi.fn(); + render(() => ( + + )); + await waitFor(() => { + screen.getByText("repo-a"); + screen.getByText("repo-c"); + }); + // The first "Select All" button is the global one in the header + const selectAllBtns = screen.getAllByText("Select All"); + await user.click(selectAllBtns[0]); + expect(onChange).toHaveBeenCalled(); + const result = onChange.mock.calls[0][0] as RepoRef[]; + expect(result.length).toBe(3); + for (const r of result) { + expect(r).not.toHaveProperty("pushedAt"); + } + }); + + it("preserves org order when all repos have null pushedAt", async () => { + const nullOrg1: RepoEntry[] = [ + { owner: "stale-org", name: "null-repo-1", fullName: "stale-org/null-repo-1", pushedAt: null }, + ]; + const nullOrg2: RepoEntry[] = [ + { owner: "active-org", name: "null-repo-2", fullName: "active-org/null-repo-2", pushedAt: null }, + ]; + vi.mocked(api.fetchRepos).mockImplementation((_client, org) => { + if (org === "stale-org") return Promise.resolve(nullOrg1); + return Promise.resolve(nullOrg2); + }); + render(() => ( + + )); + await waitFor(() => { + screen.getByText("null-repo-1"); + screen.getByText("null-repo-2"); + }); + const orgHeaders = screen.getAllByText(/^(active-org|stale-org)$/); + // Both have null pushedAt → comparator returns 0 → original order preserved + expect(orgHeaders[0].textContent).toBe("stale-org"); + expect(orgHeaders[1].textContent).toBe("active-org"); + }); }); diff --git a/tests/lib/format.test.ts b/tests/lib/format.test.ts index e1afdc11..4a1c1e1f 100644 --- a/tests/lib/format.test.ts +++ b/tests/lib/format.test.ts @@ -52,6 +52,12 @@ describe("relativeTime", () => { // Intl.RelativeTimeFormat with numeric:'auto' outputs 'now' for 0 seconds expect(result).toMatch(/now/i); }); + + it("returns empty string for invalid date input", () => { + expect(relativeTime("not-a-date")).toBe(""); + expect(relativeTime("")).toBe(""); + expect(relativeTime("garbage-2026-13-99")).toBe(""); + }); }); describe("labelTextColor", () => { diff --git a/tests/services/api.test.ts b/tests/services/api.test.ts index aa710c4a..c7c22d27 100644 --- a/tests/services/api.test.ts +++ b/tests/services/api.test.ts @@ -23,7 +23,8 @@ function makeOctokit(requestImpl: (route: string, params?: unknown) => Promise { + iterator: vi.fn((route: string, params?: unknown) => { + void params; // captured for test assertions // For tests that need paginate.iterator, return a single page const data = route.includes("/orgs/") || route.includes("/user/repos") @@ -110,6 +111,33 @@ describe("fetchRepos", () => { expect(repo.name).toBeDefined(); expect(repo.fullName).toBeDefined(); } + expect(result[0].pushedAt).toBe("2011-01-26T19:06:43Z"); + }); + + it("passes sort=pushed and direction=desc to paginate.iterator", async () => { + const octokit = makeBasicOctokit(); + await fetchRepos( + octokit as unknown as ReturnType, + "acme-corp", + "org" + ); + expect(octokit.paginate.iterator).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ sort: "pushed", direction: "desc" }) + ); + }); + + it("passes sort=pushed and direction=desc for user repos", async () => { + const octokit = makeBasicOctokit(); + await fetchRepos( + octokit as unknown as ReturnType, + "octocat", + "user" + ); + expect(octokit.paginate.iterator).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ sort: "pushed", direction: "desc" }) + ); }); it("returns repos for a user account via paginate.iterator", async () => { @@ -692,6 +720,38 @@ describe("fetchWorkflowRuns", () => { fetchWorkflowRuns(null, [testRepo], 5, 3) ).rejects.toThrow("No GitHub client available"); }); + + it("returns runs sorted newest-first within each workflow", async () => { + const octokit = makeOctokitForRuns(); + + const { workflowRuns } = await fetchWorkflowRuns( + octokit as unknown as ReturnType, + [testRepo], + 5, + 10 + ); + + // Workflow 1001 has 3 runs: 9002 (10:00), 9001 (09:00), 9003 (14:15:00 prev day) + const w1001Runs = workflowRuns.filter((r) => r.workflowId === 1001); + for (let i = 1; i < w1001Runs.length; i++) { + expect(w1001Runs[i - 1].createdAt >= w1001Runs[i].createdAt).toBe(true); + } + }); + + it("selects workflows with most recent activity first", async () => { + const octokit = makeOctokitForRuns(); + + const { workflowRuns } = await fetchWorkflowRuns( + octokit as unknown as ReturnType, + [testRepo], + 5, + 10 + ); + + // First run in results should be from the workflow with the most recent updatedAt + // Workflow 1001 latestAt=2024-01-15T10:05:00Z > Workflow 1002 latestAt=2024-01-15T09:25:00Z + expect(workflowRuns[0].workflowId).toBe(1001); + }); }); // ── searchAllPages pagination ─────────────────────────────────────────────────