diff --git a/README.md b/README.md index f10df639..32c8398a 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Per-repo opt-in to see all open issues and PRs in that repo, not just ones invol ### Upstream Repo Discovery -When you sign in, the app searches for repos you've interacted with that aren't in your selected list and offers to add them as "upstream" repos. These are included in issue/PR fetches but excluded from workflow run polling. +When you sign in, the app searches for repos you've interacted with that aren't in your selected list and offers to add them as "upstream" repos. These are included in issue/PR fetches but excluded from workflow run polling. In the org and personal repository picker, forked repositories are highlighted with a muted name and a `Fork` badge, and archived repositories are automatically excluded from both the repository list and upstream discovery. ### Hot Polling diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 20e7a01d..e969dc39 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -89,7 +89,7 @@ To replace your token later (e.g., after expiration or rotation), go to **Settin ### Repository Selection -After signing in, the onboarding wizard asks you to select repositories to track. Search by name or browse by organization. You can change your selection at any time in **Settings > Repositories**. +After signing in, the onboarding wizard asks you to select repositories to track. Search by name or browse by organization. Forked repositories are displayed with a muted name and a `Fork` badge so you can distinguish them from source repositories at a glance. Archived repositories are automatically excluded from the list (and removed from your selection if a previously tracked repository is archived on GitHub). You can change your selection at any time in **Settings > Repositories**. ### Organization Access diff --git a/src/app/components/onboarding/RepoSelector.tsx b/src/app/components/onboarding/RepoSelector.tsx index f65588ba..42e99fce 100644 --- a/src/app/components/onboarding/RepoSelector.tsx +++ b/src/app/components/onboarding/RepoSelector.tsx @@ -8,6 +8,7 @@ import { For, } from "solid-js"; import { fetchOrgs, fetchRepos, discoverUpstreamRepos, OrgEntry, RepoRef, RepoEntry } from "../../services/api"; +import { REPO_CAP } from "../../../shared/types"; import { getClient } from "../../services/github"; import { user } from "../../stores/auth"; import type { TrackedUser } from "../../stores/config"; @@ -103,9 +104,18 @@ function OrgContent(props: OrgContentProps) { />
- + {repo().name} + + Fork + {relativeTime(repo().pushedAt!)} @@ -279,10 +289,12 @@ export default function RepoSelector(props: RepoSelectorProps) { allOrgFullNames.add(repo.fullName); } }); + const ownedOrgs = new Set(orgs.map((o) => o.toLowerCase())); + ownedOrgs.add(currentUser.login.toLowerCase()); void discoverUpstreamRepos(discoveryClient, currentUser.login, allOrgFullNames, props.trackedUsers) .then((repos) => { if (version !== effectVersion) return; - setDiscoveredRepos(repos); + setDiscoveredRepos(repos.filter((r) => !ownedOrgs.has(r.owner.toLowerCase()))); setDiscoveryCapped(repos.length >= 100); }) .catch(() => { @@ -391,6 +403,54 @@ export default function RepoSelector(props: RepoSelectorProps) { return sorted; }); + // Prune selected repos that belong to a settled, error-free, non-capped org + // but are no longer present in that org's fetched repo list (e.g. archived + // or deleted). Skip orgs that hit REPO_CAP (1000) so repos beyond the top + // 1000 most-recently-pushed are not falsely pruned due to truncation. + createEffect(() => { + const states = orgStates(); + if (states.length === 0 || states.some((s) => s.loading)) return; + + const selectedOrgSet = new Set(props.selectedOrgs.map((o) => o.toLowerCase())); + if ( + states.length !== props.selectedOrgs.length || + states.some((s) => !selectedOrgSet.has(s.org.toLowerCase())) + ) { + return; + } + + const successfulOrgs = new Set(); + const liveRepos = new Set(); + for (const s of states) { + if (s.error === null && s.repos.length < REPO_CAP) { + successfulOrgs.add(s.org.toLowerCase()); + for (const r of s.repos) { + liveRepos.add(r.fullName.toLowerCase()); + } + } + } + + if (successfulOrgs.size === 0) return; + + const current = untrack(() => props.selected); + const pruned = current.filter( + (r) => !successfulOrgs.has(r.owner.toLowerCase()) || liveRepos.has(r.fullName.toLowerCase()) + ); + if (pruned.length === current.length) return; + + untrack(() => { + const removed = current.filter( + (r) => successfulOrgs.has(r.owner.toLowerCase()) && !liveRepos.has(r.fullName.toLowerCase()) + ); + props.onChange(pruned); + for (const r of removed) { + if (monitoredSet().has(r.fullName)) { + props.onMonitorToggle?.(r, false); + } + } + }); + }); + function toRepoRef(entry: RepoEntry): RepoRef { return { owner: entry.owner, name: entry.name, fullName: entry.fullName }; } diff --git a/src/app/services/api.ts b/src/app/services/api.ts index b6def37b..ea2f285c 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -4,9 +4,11 @@ import { pushNotification } from "../lib/errors"; import type { ApiCallSource } from "./api-usage"; import type { TrackedUser } from "../stores/config"; import { VALID_REPO_NAME, VALID_TRACKED_LOGIN, SEARCH_RESULT_CAP } from "../../shared/validation"; +import { REPO_CAP } from "../../shared/types"; import type { Issue, IssueState, PullRequest, PullRequestState, WorkflowRun, RepoRef, RepoEntry, OrgEntry, CheckStatus, ApiError } from "../../shared/types"; // ── Re-exports from shared/types (backward compat for existing importers) ───── +export { REPO_CAP }; export type { Issue, IssueState, PullRequest, PullRequestState, WorkflowRun, RepoRef, RepoEntry, OrgEntry, CheckStatus, ApiError, RateLimitInfo, DashboardSummary } from "../../shared/types"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -36,6 +38,8 @@ interface RawRepo { name: string; full_name: string; pushed_at: string | null; + fork?: boolean; + archived?: boolean; } interface RawWorkflowRun { @@ -1723,7 +1727,6 @@ export async function fetchRepos( if (!octokit) throw new Error("No GitHub client available"); const repos: RepoEntry[] = []; - const REPO_CAP = 1000; if (type === "org") { for await (const response of octokit.paginate.iterator(`GET /orgs/{org}/repos`, { @@ -1733,7 +1736,15 @@ export async function fetchRepos( direction: "desc" as const, })) { for (const repo of response.data as RawRepo[]) { - repos.push({ owner: repo.owner.login, name: repo.name, fullName: repo.full_name, pushedAt: repo.pushed_at ?? null }); + if (repo.archived) continue; + repos.push({ + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + pushedAt: repo.pushed_at ?? null, + isFork: Boolean(repo.fork), + }); + if (repos.length >= REPO_CAP) break; } if (repos.length >= REPO_CAP) break; } @@ -1745,7 +1756,15 @@ export async function fetchRepos( direction: "desc" as const, })) { for (const repo of response.data as RawRepo[]) { - repos.push({ owner: repo.owner.login, name: repo.name, fullName: repo.full_name, pushedAt: repo.pushed_at ?? null }); + if (repo.archived) continue; + repos.push({ + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + pushedAt: repo.pushed_at ?? null, + isFork: Boolean(repo.fork), + }); + if (repos.length >= REPO_CAP) break; } if (repos.length >= REPO_CAP) break; } @@ -2076,8 +2095,8 @@ export async function discoverUpstreamRepos( } function discoverForUser(login: string) { - const issueQ = `is:issue is:open involves:${login}`; - const prQ = `is:pr is:open involves:${login}`; + const issueQ = `is:issue is:open archived:false involves:${login}`; + const prQ = `is:pr is:open archived:false involves:${login}`; return Promise.allSettled([ paginateGraphQLSearch( octokit, ISSUES_SEARCH_QUERY, issueQ, `upstream-issues:${login}`, errors, diff --git a/src/shared/types.ts b/src/shared/types.ts index 5c5ce85a..ff135deb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -15,8 +15,11 @@ export interface RepoRef { export interface RepoEntry extends RepoRef { pushedAt: string | null; + isFork?: boolean; } +export const REPO_CAP = 1000; + export type IssueState = "OPEN" | "CLOSED"; export type PullRequestState = "OPEN" | "CLOSED" | "MERGED"; diff --git a/tests/components/onboarding/RepoSelector.test.tsx b/tests/components/onboarding/RepoSelector.test.tsx index f9efd875..8ec0c795 100644 --- a/tests/components/onboarding/RepoSelector.test.tsx +++ b/tests/components/onboarding/RepoSelector.test.tsx @@ -409,6 +409,197 @@ describe("RepoSelector", () => { const perOrgDeselectAll = deselectAllBtns[deselectAllBtns.length - 1] as HTMLButtonElement; expect(perOrgDeselectAll.disabled).toBe(true); }); + + it("renders Fork badge and muted repo name styling for fork repos, and normal styling for non-forks", async () => { + const reposWithFork: RepoEntry[] = [ + { owner: "myorg", name: "source-repo", fullName: "myorg/source-repo", pushedAt: "2026-03-20T10:00:00Z", isFork: false }, + { owner: "myorg", name: "forked-repo", fullName: "myorg/forked-repo", pushedAt: "2026-03-21T10:00:00Z", isFork: true }, + { owner: "myorg", name: "omitted-fork-repo", fullName: "myorg/omitted-fork-repo", pushedAt: "2026-03-22T10:00:00Z" }, + ]; + vi.mocked(api.fetchRepos).mockResolvedValue(reposWithFork); + + render(() => ( + + )); + + await waitFor(() => { + screen.getByText("source-repo"); + screen.getByText("forked-repo"); + screen.getByText("omitted-fork-repo"); + }); + + const sourceSpan = screen.getByText("source-repo"); + const forkedSpan = screen.getByText("forked-repo"); + const omittedSpan = screen.getByText("omitted-fork-repo"); + + expect(sourceSpan.classList.contains("text-base-content")).toBe(true); + expect(sourceSpan.classList.contains("text-base-content/60")).toBe(false); + + expect(forkedSpan.classList.contains("text-base-content/60")).toBe(true); + expect(forkedSpan.classList.contains("text-base-content")).toBe(false); + + expect(omittedSpan.classList.contains("text-base-content")).toBe(true); + expect(omittedSpan.classList.contains("text-base-content/60")).toBe(false); + + const forkBadges = screen.getAllByText("Fork"); + expect(forkBadges).toHaveLength(1); + expect(forkBadges[0].classList.contains("badge-ghost")).toBe(true); + expect(forkedSpan.parentElement?.contains(forkBadges[0])).toBe(true); + }); + + it("strips isFork and pushedAt when toggling a fork repo or clicking Select All", async () => { + const user = userEvent.setup(); + const reposWithFork: RepoEntry[] = [ + { owner: "myorg", name: "forked-repo", fullName: "myorg/forked-repo", pushedAt: "2026-03-21T10:00:00Z", isFork: true }, + { owner: "myorg", name: "source-repo", fullName: "myorg/source-repo", pushedAt: "2026-03-20T10:00:00Z", isFork: false }, + ]; + vi.mocked(api.fetchRepos).mockResolvedValue(reposWithFork); + const onChange = vi.fn(); + + render(() => ( + + )); + + await waitFor(() => { + screen.getByText("forked-repo"); + }); + + const checkboxes = screen.getAllByRole("checkbox"); + const forkCheckbox = checkboxes.find((cb) => + cb.closest("label")?.textContent?.includes("forked-repo") + ); + await user.click(forkCheckbox!); + + expect(onChange).toHaveBeenCalledTimes(1); + const toggledResult = onChange.mock.calls[0][0] as RepoRef[]; + expect(toggledResult).toEqual([ + { owner: "myorg", name: "forked-repo", fullName: "myorg/forked-repo" }, + ]); + expect(toggledResult[0]).not.toHaveProperty("isFork"); + expect(toggledResult[0]).not.toHaveProperty("pushedAt"); + + onChange.mockClear(); + const selectAllBtns = screen.getAllByText("Select All"); + await user.click(selectAllBtns[0]); + + expect(onChange).toHaveBeenCalledTimes(1); + const selectAllResult = onChange.mock.calls[0][0] as RepoRef[]; + expect(selectAllResult).toHaveLength(2); + for (const r of selectAllResult) { + expect(r).not.toHaveProperty("isFork"); + expect(r).not.toHaveProperty("pushedAt"); + } + }); + + it("auto-prunes archived/removed selected repos on load and unmonitors pruned monitored repos", async () => { + vi.mocked(api.fetchRepos).mockResolvedValue(myorgRepos); + const onChange = vi.fn(); + const onMonitorToggle = vi.fn(); + const archivedRepo: RepoRef = { + owner: "myorg", + name: "archived-repo", + fullName: "myorg/archived-repo", + }; + const liveRepo: RepoRef = { + owner: "myorg", + name: "repo-a", + fullName: "myorg/repo-a", + }; + + render(() => ( + + )); + + await waitFor(() => { + screen.getByText("repo-a"); + expect(onChange).toHaveBeenCalledWith([liveRepo]); + }); + + expect(onMonitorToggle).toHaveBeenCalledWith(archivedRepo, false); + }); + + it("does not prune pre-selected repos for a failed org until Retry succeeds", async () => { + const user = userEvent.setup(); + vi.mocked(api.fetchOrgs).mockResolvedValue([ + { login: "myorg", avatarUrl: "", type: "org" }, + { login: "beta-org", avatarUrl: "", type: "org" }, + ]); + vi.mocked(api.fetchRepos).mockImplementation((_client, org) => { + if (org === "myorg") return Promise.resolve(myorgRepos); + return Promise.reject(new Error("beta-org network failure")); + }); + + const onChange = vi.fn(); + const liveMyorgRepo: RepoRef = { owner: "myorg", name: "repo-a", fullName: "myorg/repo-a" }; + const staleBetaRepo: RepoRef = { owner: "beta-org", name: "archived-beta", fullName: "beta-org/archived-beta" }; + + render(() => ( + + )); + + await waitFor(() => { + screen.getByText("repo-a"); + screen.getByText(/beta-org network failure/i); + screen.getByText("Retry"); + }); + + // Neither myorg/repo-a (present) nor beta-org/archived-beta (org failed) should be pruned + expect(onChange).not.toHaveBeenCalled(); + + // Now make Retry succeed for beta-org without archived-beta in the returned list + const betaLiveRepos: RepoEntry[] = [ + { owner: "beta-org", name: "live-beta", fullName: "beta-org/live-beta", pushedAt: "2026-03-20T10:00:00Z" }, + ]; + vi.mocked(api.fetchRepos).mockResolvedValueOnce(betaLiveRepos); + + await user.click(screen.getByText("Retry")); + + await waitFor(() => { + screen.getByText("live-beta"); + expect(onChange).toHaveBeenCalledWith([liveMyorgRepo]); + }); + }); + + it("does not prune pre-selected repos when an org hits REPO_CAP (1000 repos)", async () => { + const cappedRepos: RepoEntry[] = Array.from({ length: 1000 }, (_, i) => ({ + owner: "myorg", + name: `repo-${i}`, + fullName: `myorg/repo-${i}`, + pushedAt: "2026-03-20T10:00:00Z", + })); + vi.mocked(api.fetchRepos).mockResolvedValue(cappedRepos); + + const onChange = vi.fn(); + const olderSelectedRepo: RepoRef = { + owner: "myorg", + name: "older-repo-1001", + fullName: "myorg/older-repo-1001", + }; + + render(() => ( + + )); + + await waitFor(() => { + screen.getByText("repo-0"); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); }); describe("RepoSelector — upstream discovery", () => { @@ -728,6 +919,35 @@ describe("RepoSelector — upstream discovery", () => { screen.getByText(/repository not found/i); }); }); + + it("filters out discovered repos owned by selectedOrgs or currentUser.login while keeping external upstream repos", async () => { + const discovered: RepoRef[] = [ + { owner: "myorg", name: "archived-repo", fullName: "myorg/archived-repo" }, + { owner: "MyOrg", name: "case-variant-repo", fullName: "MyOrg/case-variant-repo" }, + { owner: "testuser", name: "archived-personal", fullName: "testuser/archived-personal" }, + { owner: "external-org", name: "genuine-upstream", fullName: "external-org/genuine-upstream" }, + ]; + vi.mocked(api.discoverUpstreamRepos).mockResolvedValue(discovered); + + render(() => ( + + )); + + await waitFor(() => { + screen.getByText("external-org/genuine-upstream"); + }); + + expect(screen.queryByText("myorg/archived-repo")).toBeNull(); + expect(screen.queryByText("MyOrg/case-variant-repo")).toBeNull(); + expect(screen.queryByText("testuser/archived-personal")).toBeNull(); + }); }); // ── Monitor toggle (C4) ──────────────────────────────────────────────────────── diff --git a/tests/fixtures/github-repos.json b/tests/fixtures/github-repos.json index 403100ee..6772c538 100644 --- a/tests/fixtures/github-repos.json +++ b/tests/fixtures/github-repos.json @@ -10,6 +10,8 @@ "type": "User" }, "private": false, + "fork": false, + "archived": false, "description": "This your first repo!", "default_branch": "main", "updated_at": "2011-01-26T19:14:43Z", @@ -26,6 +28,8 @@ "type": "User" }, "private": false, + "fork": true, + "archived": false, "description": "This repo is for demonstration purposes only.", "default_branch": "main", "updated_at": "2014-02-05T14:20:19Z", @@ -42,9 +46,29 @@ "type": "Organization" }, "private": true, + "fork": false, + "archived": false, "description": "Internal API service", "default_branch": "main", "updated_at": "2024-01-15T10:00:00Z", "pushed_at": "2024-01-15T09:55:00Z" + }, + { + "id": 9876544, + "name": "legacy-service", + "full_name": "acme-corp/legacy-service", + "owner": { + "login": "acme-corp", + "id": 12345, + "avatar_url": "https://avatars.githubusercontent.com/u/12345?v=4", + "type": "Organization" + }, + "private": true, + "fork": false, + "archived": true, + "description": "Archived legacy service", + "default_branch": "main", + "updated_at": "2022-05-10T12:00:00Z", + "pushed_at": "2022-05-10T11:55:00Z" } ] diff --git a/tests/services/api.test.ts b/tests/services/api.test.ts index cbdfd562..96a36eb7 100644 --- a/tests/services/api.test.ts +++ b/tests/services/api.test.ts @@ -7,6 +7,7 @@ import { validateGitHubUser, fetchPREnrichment, fetchHotPRStatus, + discoverUpstreamRepos, type RepoRef, } from "../../src/app/services/api"; import { clearCache } from "../../src/app/stores/cache"; @@ -227,11 +228,123 @@ describe("fetchRepos", () => { ); }); + it("maps isFork boolean from raw fork field", async () => { + const octokit = makeBasicOctokit(); + const result = await fetchRepos(octokit as never, "octocat", "user"); + + const helloWorld = result.find((r) => r.fullName === "octocat/Hello-World"); + const spoonKnife = result.find((r) => r.fullName === "octocat/Spoon-Knife"); + + expect(helloWorld?.isFork).toBe(false); + expect(spoonKnife?.isFork).toBe(true); + }); + + it("excludes archived repositories for both org and user calls", async () => { + const octokit = makeBasicOctokit(); + + const orgResult = await fetchRepos(octokit as never, "acme-corp", "org"); + expect(orgResult.map((r) => r.fullName)).not.toContain("acme-corp/legacy-service"); + expect(orgResult.map((r) => r.fullName)).toContain("acme-corp/acme-api"); + + const userResult = await fetchRepos(octokit as never, "octocat", "user"); + expect(userResult.map((r) => r.fullName)).not.toContain("acme-corp/legacy-service"); + expect(userResult.map((r) => r.fullName)).toContain("octocat/Hello-World"); + }); + + it("does not count archived repositories toward the 1000-repo REPO_CAP", async () => { + vi.mocked(pushNotification).mockClear(); + + async function* mockPaginator() { + yield { + data: Array.from({ length: 100 }, (_, i) => ({ + owner: { login: "org" }, + name: `archived-${i}`, + full_name: `org/archived-${i}`, + pushed_at: "2026-01-01T00:00:00Z", + fork: false, + archived: true, + })), + }; + yield { + data: Array.from({ length: 50 }, (_, i) => ({ + owner: { login: "org" }, + name: `active-${i}`, + full_name: `org/active-${i}`, + pushed_at: "2026-01-02T00:00:00Z", + fork: i % 2 === 0, + archived: false, + })), + }; + } + + const octokit = makeBasicOctokit(); + octokit.paginate.iterator.mockImplementation((() => mockPaginator()) as never); + + const result = await fetchRepos(octokit as never, "org", "org"); + + expect(result).toHaveLength(50); + expect(result.every((r) => r.name.startsWith("active-"))).toBe(true); + expect(result[0].isFork).toBe(true); + expect(result[1].isFork).toBe(false); + expect(pushNotification).not.toHaveBeenCalled(); + }); + // VALID_REPO_NAME is not exported. It is exercised indirectly via buildRepoQualifiers // (called inside fetchIssuesAndPullRequests). Invalid repo names are silently // filtered from the GraphQL query qualifiers. No additional test added here. }); +// ── discoverUpstreamRepos ───────────────────────────────────────────────────── + +describe("discoverUpstreamRepos", () => { + it("includes archived:false in both issue and PR GraphQL search queries", async () => { + const queries: string[] = []; + const octokit = makeOctokit( + async () => ({ data: {}, headers: {} }), + async (_query: string, variables: unknown) => { + const vars = variables as { q?: string }; + if (vars.q) queries.push(vars.q); + return { + search: { + issueCount: 1, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + databaseId: 101, + id: "I_kwDO101", + number: 1, + title: "Upstream issue", + state: "OPEN", + url: "https://github.com/upstream/repo/issues/1", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + author: { login: "octocat", avatarUrl: "https://avatars.githubusercontent.com/u/1" }, + labels: { nodes: [] }, + assignees: { nodes: [] }, + repository: { nameWithOwner: "upstream/repo" }, + comments: { totalCount: 0 }, + }, + ], + }, + rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }; + } + ); + + const result = await discoverUpstreamRepos( + octokit as never, + "octocat", + new Set(["octocat/Hello-World"]) + ); + + expect(queries).toContain("is:issue is:open archived:false involves:octocat"); + expect(queries).toContain("is:pr is:open archived:false involves:octocat"); + expect(result).toEqual([ + { owner: "upstream", name: "repo", fullName: "upstream/repo" }, + ]); + }); +}); + // ── fetchWorkflowRuns (single endpoint per repo) ────────────────────────────