Skip to content

Commit 2220936

Browse files
authored
feat: adds upstream repo discovery and multi-user tracking (#36)
* feat: adds upstream repo discovery and multi-user tracking * fix: addresses review findings from Phase 4 swarm * fix: lowercases userLogin fallback in surfacedBy filter * fix: adds createRoot wrapper, avatar CDN check, PR tab tests * perf(api): parallelizes main-user backfill with tracked-user searches * feat(onboarding): sorts org groups by personal-first then alphabetical Replaces activity-based org sorting in RepoSelector with personal org first (type user), then remaining orgs alphabetically. Upstream section remains structurally last. Repos within orgs retain recency order from fetchRepos. * fix: addresses PR review findings across 22 issues - refactor(api): extracts executeLightCombinedQuery shared helper, eliminating ~80 lines of duplication between graphqlLightCombinedSearch and graphqlGlobalUserSearch - fix(api): removes superfluous !userLogin from early exit guard - fix(api): guards onLightData to only fire when results exist - perf(api): swaps PR_SEARCH_QUERY to LIGHT_PR_SEARCH_QUERY in discoverUpstreamRepos for lower GraphQL point cost - fix(api): unifies VALID_LOGIN with VALID_TRACKED_LOGIN regex - refactor(dashboard): extracts buildSurfacedByUsers helper, removes duplicated surfacedByBadge logic from IssuesTab and PullRequestsTab - fix(dashboard): guards allUsers memo against empty login - fix(dashboard): adds stale user-filter guard for removed tracked users - fix(settings): renumbers section comments after Tracked Users insertion - refactor(tabs): imports TrackedUser type instead of inline declaration - perf(onboarding): uses Set for manualUpstreamRepos dedup - test: adds ConfigSchema, TrackedUsersSection, RepoSelector, and api-users coverage for identified gaps * fix(onboarding): validates upstream repo existence on manual add - adds GET /repos/{owner}/{repo} check on manual upstream entry - 404 shows "Repository not found", disables input during check - enables "Finish Setup" when upstream repos selected (no org repos) - updates button repo count to include upstream repos * feat(dashboard): adds "Involved" role badge for upstream items - adds "involved" fallback role when upstream item has no specific author/assignee/reviewer match (ghost badge-sm styling) - settings repo count now includes upstream repos with breakdown * fix(onboarding): runs upstream discovery even with zero org repos * fix(onboarding): prevents discovery re-trigger on repo select * fix: corrects tracked-user scoping, config wipe, upstream discovery * feat(labels): expands GitHub emoji shortcodes to Unicode
1 parent f9ee41b commit 2220936

30 files changed

+3597
-291
lines changed

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
1414
interface ActionsTabProps {
1515
workflowRuns: WorkflowRun[];
1616
loading?: boolean;
17+
hasUpstreamRepos?: boolean;
1718
}
1819

1920
interface WorkflowGroup {
@@ -319,6 +320,13 @@ export default function ActionsTab(props: ActionsTabProps) {
319320
}}
320321
</For>
321322
</Show>
323+
324+
{/* Upstream repos exclusion note */}
325+
<Show when={props.hasUpstreamRepos}>
326+
<p class="text-xs text-base-content/40 text-center py-2">
327+
Workflow runs are not tracked for upstream repositories.
328+
</p>
329+
</Show>
322330
</div>
323331
);
324332
}

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import FilterBar from "../layout/FilterBar";
66
import ActionsTab from "./ActionsTab";
77
import IssuesTab from "./IssuesTab";
88
import PullRequestsTab from "./PullRequestsTab";
9-
import { config, setConfig } from "../../stores/config";
9+
import { config, setConfig, type TrackedUser } from "../../stores/config";
1010
import { viewState, updateViewState } from "../../stores/view";
1111
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
1212
import { fetchOrgs } from "../../services/api";
@@ -158,6 +158,7 @@ async function pollFetch(): Promise<DashboardData> {
158158
pr.totalReviewCount = e.totalReviewCount;
159159
pr.enriched = e.enriched;
160160
pr.nodeId = e.nodeId;
161+
pr.surfacedBy = e.surfacedBy;
161162
}
162163
} else {
163164
state.pullRequests = data.pullRequests;
@@ -307,6 +308,14 @@ export default function DashboardPage() {
307308
}));
308309

309310
const userLogin = createMemo(() => user()?.login ?? "");
311+
const allUsers = createMemo(() => {
312+
const login = userLogin().toLowerCase();
313+
if (!login) return [];
314+
return [
315+
{ login, label: "Me" },
316+
...config.trackedUsers.map((u: TrackedUser) => ({ login: u.login, label: u.login })),
317+
];
318+
});
310319

311320
return (
312321
<div class="min-h-screen bg-base-200">
@@ -335,19 +344,24 @@ export default function DashboardPage() {
335344
issues={dashboardData.issues}
336345
loading={dashboardData.loading}
337346
userLogin={userLogin()}
347+
allUsers={allUsers()}
348+
trackedUsers={config.trackedUsers}
338349
/>
339350
</Match>
340351
<Match when={activeTab() === "pullRequests"}>
341352
<PullRequestsTab
342353
pullRequests={dashboardData.pullRequests}
343354
loading={dashboardData.loading}
344355
userLogin={userLogin()}
356+
allUsers={allUsers()}
357+
trackedUsers={config.trackedUsers}
345358
/>
346359
</Match>
347360
<Match when={activeTab() === "actions"}>
348361
<ActionsTab
349362
workflowRuns={dashboardData.workflowRuns}
350363
loading={dashboardData.loading}
364+
hasUpstreamRepos={config.upstreamRepos.length > 0}
351365
/>
352366
</Match>
353367
</Switch>

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
2-
import { config } from "../../stores/config";
2+
import { config, type TrackedUser } from "../../stores/config";
33
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type IssueFilterField } from "../../stores/view";
44
import type { Issue } from "../../services/api";
55
import ItemRow from "./ItemRow";
6+
import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge";
67
import IgnoreBadge from "./IgnoreBadge";
78
import SortDropdown from "../shared/SortDropdown";
89
import type { SortOption } from "../shared/SortDropdown";
@@ -20,6 +21,8 @@ export interface IssuesTabProps {
2021
issues: Issue[];
2122
loading?: boolean;
2223
userLogin: string;
24+
allUsers?: { login: string; label: string }[];
25+
trackedUsers?: TrackedUser[];
2326
}
2427

2528
type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "comments";
@@ -55,6 +58,27 @@ const sortOptions: SortOption[] = [
5558
export default function IssuesTab(props: IssuesTabProps) {
5659
const [page, setPage] = createSignal(0);
5760

61+
const trackedUserMap = createMemo(() =>
62+
new Map(props.trackedUsers?.map(u => [u.login, u]) ?? [])
63+
);
64+
65+
const upstreamRepoSet = createMemo(() =>
66+
new Set((config.upstreamRepos ?? []).map(r => r.fullName))
67+
);
68+
69+
const filterGroups = createMemo<FilterChipGroupDef[]>(() => {
70+
const users = props.allUsers;
71+
if (!users || users.length <= 1) return issueFilterGroups;
72+
return [
73+
...issueFilterGroups,
74+
{
75+
label: "User",
76+
field: "user",
77+
options: users.map((u) => ({ value: u.login, label: u.label })),
78+
},
79+
];
80+
});
81+
5882
const sortPref = createMemo(() => {
5983
const pref = viewState.sortPreferences["issues"];
6084
return pref ?? { field: "updatedAt", direction: "desc" as const };
@@ -76,7 +100,7 @@ export default function IssuesTab(props: IssuesTabProps) {
76100
if (filter.repo && issue.repoFullName !== filter.repo) return false;
77101
if (filter.org && !issue.repoFullName.startsWith(filter.org + "/")) return false;
78102

79-
const roles = deriveInvolvementRoles(props.userLogin, issue.userLogin, issue.assigneeLogins, []);
103+
const roles = deriveInvolvementRoles(props.userLogin, issue.userLogin, issue.assigneeLogins, [], upstreamRepoSet().has(issue.repoFullName));
80104

81105
if (tabFilter.role !== "all") {
82106
if (!roles.includes(tabFilter.role as "author" | "assignee")) return false;
@@ -87,6 +111,14 @@ export default function IssuesTab(props: IssuesTabProps) {
87111
if (tabFilter.comments === "none" && issue.comments > 0) return false;
88112
}
89113

114+
if (tabFilter.user !== "all") {
115+
const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilter.user);
116+
if (validUser) {
117+
const surfacedBy = issue.surfacedBy ?? [props.userLogin.toLowerCase()];
118+
if (!surfacedBy.includes(tabFilter.user)) return false;
119+
}
120+
}
121+
90122
meta.set(issue.id, { roles });
91123
return true;
92124
});
@@ -172,7 +204,7 @@ export default function IssuesTab(props: IssuesTabProps) {
172204
onChange={handleSort}
173205
/>
174206
<FilterChips
175-
groups={issueFilterGroups}
207+
groups={filterGroups()}
176208
values={viewState.tabFilters.issues}
177209
onChange={(field, value) => {
178210
setTabFilter("issues", field as IssueFilterField, value);
@@ -291,6 +323,14 @@ export default function IssuesTab(props: IssuesTabProps) {
291323
onIgnore={() => handleIgnore(issue)}
292324
density={config.viewDensity}
293325
commentCount={issue.comments}
326+
surfacedByBadge={
327+
props.trackedUsers && props.trackedUsers.length > 0
328+
? <UserAvatarBadge
329+
users={buildSurfacedByUsers(issue.surfacedBy, trackedUserMap())}
330+
currentUserLogin={props.userLogin}
331+
/>
332+
: undefined
333+
}
294334
>
295335
<RoleBadge roles={issueMeta().get(issue.id)?.roles ?? []} />
296336
</ItemRow>

src/app/components/dashboard/ItemRow.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { For, JSX, Show } from "solid-js";
22
import { isSafeGitHubUrl } from "../../lib/url";
33
import { relativeTime, labelTextColor, formatCount } from "../../lib/format";
4+
import { expandEmoji } from "../../lib/emoji";
45

56
export interface ItemRowProps {
67
repo: string;
@@ -15,6 +16,7 @@ export interface ItemRowProps {
1516
density: "compact" | "comfortable";
1617
commentCount?: number;
1718
hideRepo?: boolean;
19+
surfacedByBadge?: JSX.Element;
1820
}
1921

2022
export default function ItemRow(props: ItemRowProps) {
@@ -77,7 +79,7 @@ export default function ItemRow(props: ItemRowProps) {
7779
class="inline-flex items-center rounded-full text-xs px-2 py-0.5 font-medium bg-[var(--lb)] text-[var(--lf)]"
7880
style={{ "--lb": bg, "--lf": fg }}
7981
>
80-
{label.name}
82+
{expandEmoji(label.name)}
8183
</span>
8284
);
8385
}}
@@ -94,6 +96,9 @@ export default function ItemRow(props: ItemRowProps) {
9496
{/* Author + time + comment count */}
9597
<div class={`shrink-0 flex flex-col items-end gap-0.5 text-xs text-base-content/60 ${isCompact() ? "" : "pt-0.5"}`}>
9698
<span>{props.author}</span>
99+
<Show when={props.surfacedByBadge !== undefined}>
100+
<div class="relative z-10">{props.surfacedByBadge}</div>
101+
</Show>
97102
<span title={props.createdAt}>{relativeTime(props.createdAt)}</span>
98103
<Show when={(props.commentCount ?? 0) > 0}>
99104
<span class="flex items-center gap-0.5">

src/app/components/dashboard/PullRequestsTab.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
2-
import { config } from "../../stores/config";
2+
import { config, type TrackedUser } from "../../stores/config";
33
import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type PullRequestFilterField } from "../../stores/view";
44
import type { PullRequest } from "../../services/api";
55
import { deriveInvolvementRoles, prSizeCategory } from "../../lib/format";
66
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
77
import ItemRow from "./ItemRow";
8+
import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge";
89
import StatusDot from "../shared/StatusDot";
910
import IgnoreBadge from "./IgnoreBadge";
1011
import SortDropdown from "../shared/SortDropdown";
@@ -23,6 +24,8 @@ export interface PullRequestsTabProps {
2324
pullRequests: PullRequest[];
2425
loading?: boolean;
2526
userLogin: string;
27+
allUsers?: { login: string; label: string }[];
28+
trackedUsers?: TrackedUser[];
2629
}
2730

2831
type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "checkStatus" | "reviewDecision" | "size";
@@ -120,6 +123,27 @@ const sortOptions: SortOption[] = [
120123
export default function PullRequestsTab(props: PullRequestsTabProps) {
121124
const [page, setPage] = createSignal(0);
122125

126+
const trackedUserMap = createMemo(() =>
127+
new Map(props.trackedUsers?.map(u => [u.login, u]) ?? [])
128+
);
129+
130+
const upstreamRepoSet = createMemo(() =>
131+
new Set((config.upstreamRepos ?? []).map(r => r.fullName))
132+
);
133+
134+
const filterGroups = createMemo<FilterChipGroupDef[]>(() => {
135+
const users = props.allUsers;
136+
if (!users || users.length <= 1) return prFilterGroups;
137+
return [
138+
...prFilterGroups,
139+
{
140+
label: "User",
141+
field: "user",
142+
options: users.map((u) => ({ value: u.login, label: u.label })),
143+
},
144+
];
145+
});
146+
123147
const sortPref = createMemo(() => {
124148
const pref = viewState.sortPreferences["pullRequests"];
125149
return pref ?? { field: "updatedAt", direction: "desc" as const };
@@ -141,7 +165,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
141165
if (filter.repo && pr.repoFullName !== filter.repo) return false;
142166
if (filter.org && !pr.repoFullName.startsWith(filter.org + "/")) return false;
143167

144-
const roles = deriveInvolvementRoles(props.userLogin, pr.userLogin, pr.assigneeLogins, pr.reviewerLogins);
168+
const roles = deriveInvolvementRoles(props.userLogin, pr.userLogin, pr.assigneeLogins, pr.reviewerLogins, upstreamRepoSet().has(pr.repoFullName));
145169
const sizeCategory = prSizeCategory(pr.additions, pr.deletions);
146170

147171
// Tab filters — light-field filters always apply; heavy-field filters
@@ -170,6 +194,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
170194
if (sizeCategory !== tabFilters.sizeCategory) return false;
171195
}
172196

197+
if (tabFilters.user !== "all") {
198+
const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilters.user);
199+
if (validUser) {
200+
const surfacedBy = pr.surfacedBy ?? [props.userLogin.toLowerCase()];
201+
if (!surfacedBy.includes(tabFilters.user)) return false;
202+
}
203+
}
204+
173205
meta.set(pr.id, { roles, sizeCategory });
174206
return true;
175207
});
@@ -261,7 +293,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
261293
onChange={handleSort}
262294
/>
263295
<FilterChips
264-
groups={prFilterGroups}
296+
groups={filterGroups()}
265297
values={viewState.tabFilters.pullRequests}
266298
onChange={(field, value) => {
267299
setTabFilter("pullRequests", field as PullRequestFilterField, value);
@@ -435,6 +467,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
435467
commentCount={pr.enriched !== false ? pr.comments + pr.reviewThreads : undefined}
436468
onIgnore={() => handleIgnore(pr)}
437469
density={config.viewDensity}
470+
surfacedByBadge={
471+
props.trackedUsers && props.trackedUsers.length > 0
472+
? <UserAvatarBadge
473+
users={buildSurfacedByUsers(pr.surfacedBy, trackedUserMap())}
474+
currentUserLogin={props.userLogin}
475+
/>
476+
: undefined
477+
}
438478
>
439479
<div class="flex items-center gap-2 flex-wrap">
440480
<Show when={pr.enriched !== false}>

src/app/components/onboarding/OnboardingWizard.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export default function OnboardingWizard() {
1616
const [selectedRepos, setSelectedRepos] = createSignal<RepoRef[]>(
1717
config.selectedRepos.length > 0 ? [...config.selectedRepos] : []
1818
);
19+
const [upstreamRepos, setUpstreamRepos] = createSignal<RepoRef[]>(
20+
config.upstreamRepos.length > 0 ? [...config.upstreamRepos] : []
21+
);
1922

2023
const [loading, setLoading] = createSignal(true);
2124
const [error, setError] = createSignal<string | null>(null);
@@ -53,6 +56,7 @@ export default function OnboardingWizard() {
5356
updateConfig({
5457
selectedOrgs: uniqueOrgs,
5558
selectedRepos: selectedRepos(),
59+
upstreamRepos: upstreamRepos(),
5660
onboardingComplete: true,
5761
});
5862
// Flush synchronously — the debounced persistence effect won't fire before page unload
@@ -108,6 +112,10 @@ export default function OnboardingWizard() {
108112
orgEntries={orgEntries()}
109113
selected={selectedRepos()}
110114
onChange={setSelectedRepos}
115+
showUpstreamDiscovery={true}
116+
upstreamRepos={upstreamRepos()}
117+
onUpstreamChange={setUpstreamRepos}
118+
trackedUsers={config.trackedUsers}
111119
/>
112120
</Match>
113121
</Switch>
@@ -120,12 +128,12 @@ export default function OnboardingWizard() {
120128
<button
121129
type="button"
122130
onClick={handleFinish}
123-
disabled={selectedRepos().length === 0}
131+
disabled={selectedRepos().length === 0 && upstreamRepos().length === 0}
124132
class="btn btn-primary"
125133
>
126-
{selectedRepos().length === 0
134+
{selectedRepos().length + upstreamRepos().length === 0
127135
? "Finish Setup"
128-
: `Finish Setup (${selectedRepos().length} ${selectedRepos().length === 1 ? "repo" : "repos"})`}
136+
: `Finish Setup (${selectedRepos().length + upstreamRepos().length} ${selectedRepos().length + upstreamRepos().length === 1 ? "repo" : "repos"})`}
129137
</button>
130138
</div>
131139
</Show>

0 commit comments

Comments
 (0)