Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
64 changes: 62 additions & 2 deletions src/app/components/onboarding/RepoSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -103,9 +104,18 @@ function OrgContent(props: OrgContentProps) {
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="min-w-0 truncate text-sm font-medium text-base-content">
<span
class="min-w-0 truncate text-sm font-medium"
classList={{
"text-base-content": !repo().isFork,
"text-base-content/60": Boolean(repo().isFork),
}}
>
{repo().name}
</span>
<Show when={repo().isFork}>
<span class="badge badge-xs badge-ghost shrink-0">Fork</span>
</Show>
<Show when={repo().pushedAt}>
<span class="ml-auto shrink-0 text-xs text-base-content/60">
{relativeTime(repo().pushedAt!)}
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<string>();
const liveRepos = new Set<string>();
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 };
}
Expand Down
29 changes: 24 additions & 5 deletions src/app/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -36,6 +38,8 @@ interface RawRepo {
name: string;
full_name: string;
pushed_at: string | null;
fork?: boolean;
archived?: boolean;
}

interface RawWorkflowRun {
Expand Down Expand Up @@ -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`, {
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<GraphQLIssueSearchResponse, GraphQLIssueNode>(
octokit, ISSUES_SEARCH_QUERY, issueQ, `upstream-issues:${login}`, errors,
Expand Down
3 changes: 3 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Loading
Loading