From 9b182f5179b8bc984d4fb69bc0d0155fb33212f9 Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 11:48:27 -0400 Subject: [PATCH 01/14] feat(ui): adds PersonalSummaryStrip with actionable counts above tab bar --- .../components/dashboard/DashboardPage.tsx | 8 ++ .../dashboard/PersonalSummaryStrip.tsx | 97 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/app/components/dashboard/PersonalSummaryStrip.tsx diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 9a70498e..4c29feae 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -6,6 +6,7 @@ import FilterBar from "../layout/FilterBar"; import ActionsTab from "./ActionsTab"; import IssuesTab from "./IssuesTab"; import PullRequestsTab from "./PullRequestsTab"; +import PersonalSummaryStrip from "./PersonalSummaryStrip"; import { config, setConfig, type TrackedUser } from "../../stores/config"; import { viewState, updateViewState } from "../../stores/view"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; @@ -377,6 +378,13 @@ export default function DashboardPage() {
+ void; +} + +export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { + const assignedIssues = createMemo(() => { + const login = props.userLogin.toLowerCase(); + if (!login) return 0; + return props.issues.filter((i) => + i.assigneeLogins.some((a) => a.toLowerCase() === login) + ).length; + }); + + const prsAwaitingReview = createMemo(() => { + const login = props.userLogin.toLowerCase(); + if (!login) return 0; + return props.pullRequests.filter( + (pr) => + pr.enriched !== false && + pr.reviewDecision === "REVIEW_REQUIRED" && + pr.reviewerLogins.some((r) => r.toLowerCase() === login) + ).length; + }); + + const prsReadyToMerge = createMemo(() => { + const login = props.userLogin.toLowerCase(); + if (!login) return 0; + return props.pullRequests.filter( + (pr) => + pr.userLogin.toLowerCase() === login && + !pr.draft && + pr.checkStatus === "success" && + (pr.reviewDecision === "APPROVED" || pr.reviewDecision === null) + ).length; + }); + + const prsBlocked = createMemo(() => { + const login = props.userLogin.toLowerCase(); + if (!login) return 0; + return props.pullRequests.filter( + (pr) => + pr.userLogin.toLowerCase() === login && + !pr.draft && + (pr.checkStatus === "failure" || pr.checkStatus === "conflict") + ).length; + }); + + const runningActions = createMemo(() => + props.workflowRuns.filter((r) => r.status === "in_progress").length + ); + + const summaryItems = createMemo(() => { + const items: SummaryCount[] = []; + if (assignedIssues() > 0) items.push({ label: "assigned", count: assignedIssues(), tab: "issues" }); + if (prsAwaitingReview() > 0) items.push({ label: "awaiting review", count: prsAwaitingReview(), tab: "pullRequests" }); + if (prsReadyToMerge() > 0) items.push({ label: "ready to merge", count: prsReadyToMerge(), tab: "pullRequests" }); + if (prsBlocked() > 0) items.push({ label: "blocked", count: prsBlocked(), tab: "pullRequests" }); + if (runningActions() > 0) items.push({ label: "running", count: runningActions(), tab: "actions" }); + return items; + }); + + return ( + 0}> +
+ + {(item, idx) => ( + <> + 0}> + · + + + + )} + +
+
+ ); +} From f1b37a137860a968640e896cc0f070d735869d8b Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 11:49:53 -0400 Subject: [PATCH 02/14] feat(filters): adds scope filter schema and chips with defaultValue --- src/app/components/dashboard/IssuesTab.tsx | 19 +++++++++-- .../components/dashboard/PullRequestsTab.tsx | 19 +++++++++-- src/app/components/shared/FilterChips.tsx | 33 ++++++++++--------- src/app/stores/view.ts | 14 ++++---- 4 files changed, 60 insertions(+), 25 deletions(-) diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index a8692d7c..10fdf1bf 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -32,6 +32,16 @@ export interface IssuesTabProps { type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "comments"; +const scopeFilterGroup: FilterChipGroupDef = { + label: "Scope", + field: "scope", + defaultValue: "involves_me", + options: [ + { value: "involves_me", label: "Involves me" }, + { value: "all", label: "All activity" }, + ], +}; + const issueFilterGroups: FilterChipGroupDef[] = [ { label: "Role", @@ -77,9 +87,14 @@ export default function IssuesTab(props: IssuesTabProps) { const filterGroups = createMemo(() => { const users = props.allUsers; - if (!users || users.length <= 1) return issueFilterGroups; + const hasMonitoredRepos = (props.monitoredRepos ?? []).length > 0; + const hasTrackedUsers = (props.allUsers?.length ?? 0) > 1; + const base = (hasMonitoredRepos || hasTrackedUsers) + ? [scopeFilterGroup, ...issueFilterGroups] + : [...issueFilterGroups]; + if (!users || users.length <= 1) return base; return [ - ...issueFilterGroups, + ...base, { label: "User", field: "user", diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index f1ae9bb5..bfb27a77 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -66,6 +66,16 @@ function reviewDecisionOrder(decision: PullRequest["reviewDecision"]): number { } } +const scopeFilterGroup: FilterChipGroupDef = { + label: "Scope", + field: "scope", + defaultValue: "involves_me", + options: [ + { value: "involves_me", label: "Involves me" }, + { value: "all", label: "All activity" }, + ], +}; + const prFilterGroups: FilterChipGroupDef[] = [ { label: "Role", @@ -145,9 +155,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const filterGroups = createMemo(() => { const users = props.allUsers; - if (!users || users.length <= 1) return prFilterGroups; + const hasMonitoredRepos = (props.monitoredRepos ?? []).length > 0; + const hasTrackedUsers = (props.allUsers?.length ?? 0) > 1; + const base = (hasMonitoredRepos || hasTrackedUsers) + ? [scopeFilterGroup, ...prFilterGroups] + : [...prFilterGroups]; + if (!users || users.length <= 1) return base; return [ - ...prFilterGroups, + ...base, { label: "User", field: "user", diff --git a/src/app/components/shared/FilterChips.tsx b/src/app/components/shared/FilterChips.tsx index ba449a84..868ccf3c 100644 --- a/src/app/components/shared/FilterChips.tsx +++ b/src/app/components/shared/FilterChips.tsx @@ -4,6 +4,7 @@ export interface FilterChipGroupDef { label: string; field: string; options: { value: string; label: string }[]; + defaultValue?: string; // When set, replaces "all" as the "no filter active" value } interface FilterChipsProps { @@ -16,31 +17,33 @@ interface FilterChipsProps { export default function FilterChips(props: FilterChipsProps) { const hasActiveFilter = () => - props.groups.some((g) => props.values[g.field] !== "all" && props.values[g.field] !== undefined); + props.groups.some((g) => props.values[g.field] !== undefined && props.values[g.field] !== (g.defaultValue ?? "all")); return (
{(group) => { - const current = () => props.values[group.field] ?? "all"; - const isActive = () => current() !== "all"; + const current = () => props.values[group.field] ?? (group.defaultValue ?? "all"); + const isActive = () => current() !== (group.defaultValue ?? "all"); return (
{group.label}:
- + + + {(opt) => (
} diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index bfb27a77..a27ea442 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -195,6 +195,20 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const roles = deriveInvolvementRoles(props.userLogin, pr.userLogin, pr.assigneeLogins, pr.reviewerLogins, upstreamRepoSet().has(pr.repoFullName)); const sizeCategory = prSizeCategory(pr.additions, pr.deletions); + // Scope filter + if (tabFilters.scope === "involves_me") { + const login = props.userLogin.toLowerCase(); + const surfacedBy = pr.surfacedBy ?? []; + if (surfacedBy.length > 0) { + if (!surfacedBy.includes(login)) return false; + } else if (monitoredRepoNameSet().has(pr.repoFullName)) { + const isInvolved = pr.userLogin.toLowerCase() === login || + pr.assigneeLogins.some(a => a.toLowerCase() === login) || + (pr.enriched !== false && pr.reviewerLogins.some(r => r.toLowerCase() === login)); + if (!isInvolved) return false; + } + } + // Tab filters — light-field filters always apply; heavy-field filters // only apply to enriched PRs so unenriched phase-1 PRs aren't incorrectly hidden const isEnriched = pr.enriched !== false; @@ -400,9 +414,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { d="M8 7h8m-8 5h5m-5 5h8M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z" /> -

No open pull requests involving you

+

+ {viewState.tabFilters.pullRequests.scope === "all" ? "No open pull requests found" : "No open pull requests involving you"} +

- PRs where you are the author, assignee, or reviewer will appear here. + {viewState.tabFilters.pullRequests.scope === "all" + ? "No pull requests match your current filters." + : "PRs where you are the author, assignee, or reviewer will appear here."}

} diff --git a/src/app/lib/grouping.ts b/src/app/lib/grouping.ts index 8055c8a1..67446f9d 100644 --- a/src/app/lib/grouping.ts +++ b/src/app/lib/grouping.ts @@ -1,15 +1,16 @@ export interface RepoGroup { repoFullName: string; + starCount?: number; items: T[]; } -export function groupByRepo(items: T[]): RepoGroup[] { +export function groupByRepo(items: T[]): RepoGroup[] { const groups: RepoGroup[] = []; const map = new Map>(); for (const item of items) { let group = map.get(item.repoFullName); if (!group) { - group = { repoFullName: item.repoFullName, items: [] }; + group = { repoFullName: item.repoFullName, starCount: item.starCount, items: [] }; map.set(item.repoFullName, group); groups.push(group); } diff --git a/src/app/services/api.ts b/src/app/services/api.ts index 82714ba0..b60070c7 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -40,6 +40,7 @@ export interface Issue { assigneeLogins: string[]; repoFullName: string; comments: number; + starCount?: number; surfacedBy?: string[]; } @@ -73,6 +74,7 @@ export interface PullRequest { labels: { name: string; color: string }[]; reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; totalReviewCount: number; + starCount?: number; /** False when only light fields are loaded (phase 1); true/undefined when fully enriched */ enriched?: boolean; /** GraphQL global node ID — used for hot-poll status updates */ @@ -266,7 +268,7 @@ interface GraphQLIssueNode { author: { login: string; avatarUrl: string } | null; labels: { nodes: { name: string; color: string }[] }; assignees: { nodes: { login: string }[] }; - repository: { nameWithOwner: string } | null; + repository: { nameWithOwner: string; stargazerCount: number } | null; comments: { totalCount: number }; } @@ -293,7 +295,7 @@ interface GraphQLPRNode { headRefName: string; baseRefName: string; headRepository: { owner: { login: string }; nameWithOwner: string } | null; - repository: { nameWithOwner: string } | null; + repository: { nameWithOwner: string; stargazerCount: number } | null; mergeStateStatus: string; assignees: { nodes: { login: string }[] }; reviewRequests: { nodes: { requestedReviewer: { login: string } | null }[] }; @@ -356,7 +358,7 @@ const LIGHT_ISSUE_FRAGMENT = ` author { login avatarUrl } labels(first: 10) { nodes { name color } } assignees(first: 10) { nodes { login } } - repository { nameWithOwner } + repository { nameWithOwner stargazerCount } comments { totalCount } } `; @@ -398,7 +400,7 @@ const PR_SEARCH_QUERY = ` headRefName baseRefName headRepository { owner { login } nameWithOwner } - repository { nameWithOwner } + repository { nameWithOwner stargazerCount } mergeStateStatus assignees(first: 10) { nodes { login } } reviewRequests(first: 10) { @@ -444,7 +446,7 @@ const LIGHT_PR_FRAGMENT = ` createdAt updatedAt author { login avatarUrl } - repository { nameWithOwner } + repository { nameWithOwner stargazerCount } headRefName baseRefName reviewDecision @@ -644,7 +646,7 @@ interface GraphQLLightPRNode { createdAt: string; updatedAt: string; author: { login: string; avatarUrl: string } | null; - repository: { nameWithOwner: string } | null; + repository: { nameWithOwner: string; stargazerCount: number } | null; headRefName: string; baseRefName: string; reviewDecision: string | null; @@ -821,6 +823,7 @@ function processIssueNode( assigneeLogins: node.assignees.nodes.map((a) => a.login), repoFullName: node.repository.nameWithOwner, comments: node.comments.totalCount, + starCount: node.repository.stargazerCount, }); return true; } @@ -957,6 +960,7 @@ function processLightPRNode( labels: node.labels.nodes.map((l) => ({ name: l.name, color: l.color })), reviewDecision: mapReviewDecision(node.reviewDecision), totalReviewCount: 0, + starCount: node.repository.stargazerCount, enriched: false, nodeId: node.id, }); @@ -1583,27 +1587,7 @@ async function graphqlSearchIssues( octokit, ISSUES_SEARCH_QUERY, queryString, `search-batch-${chunkIdx + 1}/${chunks.length}`, errors, - (node) => { - if (node.databaseId == null || !node.repository) return false; - if (seen.has(node.databaseId)) return false; - seen.add(node.databaseId); - issues.push({ - id: node.databaseId, - number: node.number, - title: node.title, - state: node.state, - htmlUrl: node.url, - createdAt: node.createdAt, - updatedAt: node.updatedAt, - userLogin: node.author?.login ?? "", - userAvatarUrl: node.author?.avatarUrl ?? "", - labels: node.labels.nodes.map((l) => ({ name: l.name, color: l.color })), - assigneeLogins: node.assignees.nodes.map((a) => a.login), - repoFullName: node.repository.nameWithOwner, - comments: node.comments.totalCount, - }); - return true; - }, + (node) => processIssueNode(node, seen, issues), () => issues.length, SEARCH_RESULT_CAP, ); @@ -1726,6 +1710,7 @@ async function graphqlSearchPRs( labels: node.labels.nodes.map((l) => ({ name: l.name, color: l.color })), reviewDecision: mapReviewDecision(node.reviewDecision), totalReviewCount: node.latestReviews.totalCount, + starCount: node.repository.stargazerCount, }); return true; } diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index f3ffb4e0..6a2ae7d3 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -84,6 +84,7 @@ describe("IssuesTab — user filter logic", () => { makeIssue({ id: 1, title: "Main issue", repoFullName: "owner/repo-a", surfacedBy: ["me"] }), makeIssue({ id: 2, title: "Tracked issue", repoFullName: "owner/repo-b", surfacedBy: ["tracked1"] }), ]; + setTabFilter("issues", "scope", "all"); setAllExpanded("issues", ["owner/repo-a", "owner/repo-b"], true); render(() => ( @@ -107,6 +108,7 @@ describe("IssuesTab — user filter logic", () => { makeIssue({ id: 2, title: "Tracked issue", repoFullName: "owner/repo-b", surfacedBy: ["tracked1"] }), ]; + setTabFilter("issues", "scope", "all"); setTabFilter("issues", "user", "tracked1"); setAllExpanded("issues", ["owner/repo-a", "owner/repo-b"], true); @@ -201,6 +203,7 @@ describe("IssuesTab — avatar badge", () => { makeIssue({ id: 1, title: "Tracked issue", repoFullName: "owner/repo", surfacedBy: ["tracked1"] }), ]; + setTabFilter("issues", "scope", "all"); setAllExpanded("issues", ["owner/repo"], true); render(() => ( @@ -246,6 +249,7 @@ describe("IssuesTab — monitored repos filter bypass", () => { const issues = [ makeIssue({ id: 1, title: "Monitored issue", repoFullName: "org/monitored", surfacedBy: ["other-user"] }), ]; + setTabFilter("issues", "scope", "all"); setTabFilter("issues", "user", "me"); setAllExpanded("issues", ["org/monitored"], true); diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index 39e873e3..6019e36a 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -75,6 +75,7 @@ describe("PullRequestsTab — user filter logic", () => { makePullRequest({ id: 1, title: "My PR", repoFullName: "owner/repo-a", surfacedBy: ["me"] }), makePullRequest({ id: 2, title: "Tracked PR", repoFullName: "owner/repo-b", surfacedBy: ["tracked1"] }), ]; + setTabFilter("pullRequests", "scope", "all"); setAllExpanded("pullRequests", ["owner/repo-a", "owner/repo-b"], true); render(() => ( @@ -98,6 +99,7 @@ describe("PullRequestsTab — user filter logic", () => { makePullRequest({ id: 2, title: "Tracked PR", repoFullName: "owner/repo-b", surfacedBy: ["tracked1"] }), ]; + setTabFilter("pullRequests", "scope", "all"); setTabFilter("pullRequests", "user", "tracked1"); setAllExpanded("pullRequests", ["owner/repo-a", "owner/repo-b"], true); @@ -170,6 +172,7 @@ describe("PullRequestsTab — avatar badge", () => { makePullRequest({ id: 1, title: "Tracked PR", repoFullName: "owner/repo", surfacedBy: ["tracked1"] }), ]; + setTabFilter("pullRequests", "scope", "all"); setAllExpanded("pullRequests", ["owner/repo"], true); render(() => ( @@ -196,6 +199,7 @@ describe("PullRequestsTab — monitored repos filter bypass", () => { const prs = [ makePullRequest({ id: 1, title: "Monitored PR", repoFullName: "org/monitored", surfacedBy: ["other-user"] }), ]; + setTabFilter("pullRequests", "scope", "all"); setTabFilter("pullRequests", "user", "me"); setAllExpanded("pullRequests", ["org/monitored"], true); diff --git a/tests/helpers/index.tsx b/tests/helpers/index.tsx index 144853fc..8bd3a53f 100644 --- a/tests/helpers/index.tsx +++ b/tests/helpers/index.tsx @@ -21,7 +21,6 @@ export function makeIssue(overrides: Partial = {}): Issue { assigneeLogins: [], repoFullName: "owner/repo", comments: 0, - surfacedBy: ["testuser"], ...overrides, }; } @@ -54,7 +53,6 @@ export function makePullRequest(overrides: Partial = {}): PullReque reviewDecision: null, totalReviewCount: 0, enriched: true, - surfacedBy: ["testuser"], ...overrides, }; } From f36228f054fd7a59d32df3937cf8189d6693347c Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 12:13:45 -0400 Subject: [PATCH 04/14] feat(ui): adds star counts and involvement border accent to repo groups --- src/app/components/dashboard/IssuesTab.tsx | 24 ++++++++++++++++-- .../components/dashboard/PullRequestsTab.tsx | 25 +++++++++++++++++-- src/app/lib/format.ts | 11 ++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index c7268ef7..22051537 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -14,7 +14,7 @@ import RoleBadge from "../shared/RoleBadge"; import SkeletonRows from "../shared/SkeletonRows"; import ChevronIcon from "../shared/ChevronIcon"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; -import { deriveInvolvementRoles } from "../../lib/format"; +import { deriveInvolvementRoles, formatStarCount } from "../../lib/format"; import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import RepoLockControls from "../shared/RepoLockControls"; @@ -239,6 +239,17 @@ export default function IssuesTab(props: IssuesTabProps) { setPage(0); } + function isInvolvedItem(item: Issue): boolean { + const login = props.userLogin.toLowerCase(); + const surfacedBy = item.surfacedBy ?? []; + if (surfacedBy.length > 0) return surfacedBy.includes(login); + if (monitoredRepoNameSet().has(item.repoFullName)) { + return item.userLogin.toLowerCase() === login || + item.assigneeLogins.some(a => a.toLowerCase() === login); + } + return true; + } + function handleIgnore(issue: Issue) { ignoreItem({ id: String(issue.id), @@ -367,6 +378,11 @@ export default function IssuesTab(props: IssuesTabProps) { Monitoring all + 0}> + + ★ {formatStarCount(repoGroup.starCount!)} + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} @@ -391,7 +407,11 @@ export default function IssuesTab(props: IssuesTabProps) {
{(issue) => ( -
+
0) return surfacedBy.includes(login); + if (monitoredRepoNameSet().has(item.repoFullName)) { + return item.userLogin.toLowerCase() === login || + item.assigneeLogins.some(a => a.toLowerCase() === login) || + (item.enriched !== false && item.reviewerLogins.some(r => r.toLowerCase() === login)); + } + return true; + } + function handleIgnore(pr: PullRequest) { ignoreItem({ id: String(pr.id), @@ -469,6 +481,11 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { Monitoring all + 0}> + + ★ {formatStarCount(repoGroup.starCount!)} + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "PR" : "PRs"} @@ -544,7 +561,11 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
{(pr) => ( -
+
= 1000000) return `${parseFloat((count / 1000000).toFixed(1))}M`; + if (count >= 10000) return `${Math.round(count / 1000)}k`; + if (count >= 1000) return `${parseFloat((count / 1000).toFixed(1))}k`; + return String(count); +} From f3c7866f691694a9048b250e8e3a0a87bedeb89d Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 13:38:22 -0400 Subject: [PATCH 05/14] feat(filters): implements scope filtering logic in Issues and PRs tabs --- tests/components/dashboard/IssuesTab.test.tsx | 209 ++++++++++++++++++ .../dashboard/PullRequestsTab.test.tsx | 208 +++++++++++++++++ 2 files changed, 417 insertions(+) diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index 6a2ae7d3..6502d028 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -315,3 +315,212 @@ describe("IssuesTab — monitored repos filter bypass", () => { expect(screen.queryByText("Monitoring all")).toBeNull(); }); }); + +// ── IssuesTab — scope filter ─────────────────────────────────────────────────── + +describe("IssuesTab — scope filter", () => { + it("default scope shows only items involving the user (surfacedBy includes userLogin)", () => { + const issues = [ + makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo", surfacedBy: ["me"] }), + makeIssue({ id: 2, title: "Community issue", repoFullName: "org/repo", surfacedBy: ["other"] }), + ]; + setAllExpanded("issues", ["org/repo"], true); + + render(() => ( + + )); + + screen.getByText("My issue"); + expect(screen.queryByText("Community issue")).toBeNull(); + }); + + it("scope 'all' shows all items including community items", () => { + const issues = [ + makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo", surfacedBy: ["me"] }), + makeIssue({ id: 2, title: "Community issue", repoFullName: "org/repo", surfacedBy: ["other"] }), + ]; + setTabFilter("issues", "scope", "all"); + setAllExpanded("issues", ["org/repo"], true); + + render(() => ( + + )); + + screen.getByText("My issue"); + screen.getByText("Community issue"); + }); + + it("scope 'involves_me' with monitored repo shows items where user is author", () => { + const issues = [ + makeIssue({ id: 1, title: "My monitored issue", repoFullName: "org/monitored", userLogin: "me" }), + ]; + setAllExpanded("issues", ["org/monitored"], true); + + render(() => ( + + )); + + screen.getByText("My monitored issue"); + }); + + it("scope 'involves_me' with monitored repo hides community items (user not author/assignee)", () => { + const issues = [ + makeIssue({ id: 1, title: "Community monitored issue", repoFullName: "org/monitored", userLogin: "other-user", assigneeLogins: [] }), + ]; + setAllExpanded("issues", ["org/monitored"], true); + + render(() => ( + + )); + + expect(screen.queryByText("Community monitored issue")).toBeNull(); + }); + + it("scope 'involves_me' with monitored repo shows items where user is assignee", () => { + const issues = [ + makeIssue({ id: 1, title: "Assigned monitored issue", repoFullName: "org/monitored", userLogin: "other-user", assigneeLogins: ["me"] }), + ]; + setAllExpanded("issues", ["org/monitored"], true); + + render(() => ( + + )); + + screen.getByText("Assigned monitored issue"); + }); +}); + +// ── IssuesTab — left border accent ──────────────────────────────────────────── + +describe("IssuesTab — left border accent in 'all' scope", () => { + it("adds border-l-2 class to items involving the user in 'all' scope", () => { + const issues = [ + makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo", surfacedBy: ["me"] }), + ]; + setTabFilter("issues", "scope", "all"); + setAllExpanded("issues", ["org/repo"], true); + + const { container } = render(() => ( + + )); + + const listitem = container.querySelector('[role="listitem"]'); + expect(listitem?.className).toContain("border-l-2"); + }); + + it("does not add border-l-2 to community items in 'all' scope", () => { + const issues = [ + makeIssue({ id: 1, title: "Community issue", repoFullName: "org/monitored", surfacedBy: ["other"], userLogin: "other", assigneeLogins: [] }), + ]; + setTabFilter("issues", "scope", "all"); + setAllExpanded("issues", ["org/monitored"], true); + + const { container } = render(() => ( + + )); + + const listitem = container.querySelector('[role="listitem"]'); + expect(listitem?.className).not.toContain("border-l-2"); + }); + + it("does not add border-l-2 in default 'involves_me' scope", () => { + const issues = [ + makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo", surfacedBy: ["me"] }), + ]; + setAllExpanded("issues", ["org/repo"], true); + + const { container } = render(() => ( + + )); + + const listitem = container.querySelector('[role="listitem"]'); + expect(listitem?.className).not.toContain("border-l-2"); + }); +}); + +// ── IssuesTab — star count in repo headers ──────────────────────────────────── + +describe("IssuesTab — star count in repo headers", () => { + it("shows star count in repo header when starCount is present", () => { + const issues = [ + makeIssue({ id: 1, title: "Issue", repoFullName: "org/repo", surfacedBy: ["me"], starCount: 1234 }), + ]; + + render(() => ( + + )); + + screen.getByText("★ 1.2k"); + }); + + it("does not show star display when starCount is undefined", () => { + const issues = [ + makeIssue({ id: 1, title: "Issue", repoFullName: "org/repo", surfacedBy: ["me"] }), + ]; + + const { container } = render(() => ( + + )); + + // No star character should be present + expect(container.textContent).not.toContain("★"); + }); + + it("does not show star display when starCount is 0", () => { + const issues = [ + makeIssue({ id: 1, title: "Issue", repoFullName: "org/repo", surfacedBy: ["me"], starCount: 0 }), + ]; + + const { container } = render(() => ( + + )); + + expect(container.textContent).not.toContain("★"); + }); +}); diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index 6019e36a..32ac3053 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -265,3 +265,211 @@ describe("PullRequestsTab — monitored repos filter bypass", () => { expect(screen.queryByText("Monitoring all")).toBeNull(); }); }); + +// ── PullRequestsTab — scope filter ──────────────────────────────────────────── + +describe("PullRequestsTab — scope filter", () => { + it("default scope shows only items involving the user (surfacedBy includes userLogin)", () => { + const prs = [ + makePullRequest({ id: 1, title: "My PR", repoFullName: "org/repo", surfacedBy: ["me"] }), + makePullRequest({ id: 2, title: "Community PR", repoFullName: "org/repo", surfacedBy: ["other"] }), + ]; + setAllExpanded("pullRequests", ["org/repo"], true); + + render(() => ( + + )); + + screen.getByText("My PR"); + expect(screen.queryByText("Community PR")).toBeNull(); + }); + + it("scope 'all' shows all PRs including community items", () => { + const prs = [ + makePullRequest({ id: 1, title: "My PR", repoFullName: "org/repo", surfacedBy: ["me"] }), + makePullRequest({ id: 2, title: "Community PR", repoFullName: "org/repo", surfacedBy: ["other"] }), + ]; + setTabFilter("pullRequests", "scope", "all"); + setAllExpanded("pullRequests", ["org/repo"], true); + + render(() => ( + + )); + + screen.getByText("My PR"); + screen.getByText("Community PR"); + }); + + it("scope 'involves_me' with monitored repo shows PRs where user is author", () => { + const prs = [ + makePullRequest({ id: 1, title: "My monitored PR", repoFullName: "org/monitored", userLogin: "me" }), + ]; + setAllExpanded("pullRequests", ["org/monitored"], true); + + render(() => ( + + )); + + screen.getByText("My monitored PR"); + }); + + it("scope 'involves_me' with monitored repo hides community PRs (user not author/assignee/reviewer)", () => { + const prs = [ + makePullRequest({ id: 1, title: "Community monitored PR", repoFullName: "org/monitored", userLogin: "other-user", assigneeLogins: [], reviewerLogins: [] }), + ]; + setAllExpanded("pullRequests", ["org/monitored"], true); + + render(() => ( + + )); + + expect(screen.queryByText("Community monitored PR")).toBeNull(); + }); + + it("scope 'involves_me' with monitored repo shows PRs where user is reviewer (enriched)", () => { + const prs = [ + makePullRequest({ id: 1, title: "Review monitored PR", repoFullName: "org/monitored", userLogin: "other-user", assigneeLogins: [], reviewerLogins: ["me"], enriched: true }), + ]; + setAllExpanded("pullRequests", ["org/monitored"], true); + + render(() => ( + + )); + + screen.getByText("Review monitored PR"); + }); +}); + +// ── PullRequestsTab — left border accent ────────────────────────────────────── + +describe("PullRequestsTab — left border accent in 'all' scope", () => { + it("adds border-l-2 class to PRs involving the user in 'all' scope", () => { + const prs = [ + makePullRequest({ id: 1, title: "My PR", repoFullName: "org/repo", surfacedBy: ["me"] }), + ]; + setTabFilter("pullRequests", "scope", "all"); + setAllExpanded("pullRequests", ["org/repo"], true); + + const { container } = render(() => ( + + )); + + const listitem = container.querySelector('[role="listitem"]'); + expect(listitem?.className).toContain("border-l-2"); + }); + + it("does not add border-l-2 to community PRs in 'all' scope", () => { + const prs = [ + makePullRequest({ id: 1, title: "Community PR", repoFullName: "org/monitored", surfacedBy: ["other"], userLogin: "other", assigneeLogins: [], reviewerLogins: [] }), + ]; + setTabFilter("pullRequests", "scope", "all"); + setAllExpanded("pullRequests", ["org/monitored"], true); + + const { container } = render(() => ( + + )); + + const listitem = container.querySelector('[role="listitem"]'); + expect(listitem?.className).not.toContain("border-l-2"); + }); + + it("does not add border-l-2 in default 'involves_me' scope", () => { + const prs = [ + makePullRequest({ id: 1, title: "My PR", repoFullName: "org/repo", surfacedBy: ["me"] }), + ]; + setAllExpanded("pullRequests", ["org/repo"], true); + + const { container } = render(() => ( + + )); + + const listitem = container.querySelector('[role="listitem"]'); + expect(listitem?.className).not.toContain("border-l-2"); + }); +}); + +// ── PullRequestsTab — star count in repo headers ────────────────────────────── + +describe("PullRequestsTab — star count in repo headers", () => { + it("shows star count in repo header when starCount is present", () => { + const prs = [ + makePullRequest({ id: 1, title: "PR", repoFullName: "org/repo", surfacedBy: ["me"], starCount: 1234 }), + ]; + + render(() => ( + + )); + + screen.getByText("★ 1.2k"); + }); + + it("does not show star display when starCount is undefined", () => { + const prs = [ + makePullRequest({ id: 1, title: "PR", repoFullName: "org/repo", surfacedBy: ["me"] }), + ]; + + const { container } = render(() => ( + + )); + + expect(container.textContent).not.toContain("★"); + }); + + it("does not show star display when starCount is 0", () => { + const prs = [ + makePullRequest({ id: 1, title: "PR", repoFullName: "org/repo", surfacedBy: ["me"], starCount: 0 }), + ]; + + const { container } = render(() => ( + + )); + + expect(container.textContent).not.toContain("★"); + }); +}); From a6e615d42cc1307c5c1535fb27bd6a75de2c56dd Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 13:39:05 -0400 Subject: [PATCH 06/14] feat(api): adds stargazerCount to GraphQL fragments and data model --- .../dashboard/PersonalSummaryStrip.test.tsx | 353 ++++++++++++++++++ tests/components/shared/FilterChips.test.tsx | 206 ++++++++++ tests/lib/format.test.ts | 40 +- tests/lib/grouping.test.ts | 19 +- 4 files changed, 615 insertions(+), 3 deletions(-) create mode 100644 tests/components/dashboard/PersonalSummaryStrip.test.tsx create mode 100644 tests/components/shared/FilterChips.test.tsx diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx new file mode 100644 index 00000000..f5474f9b --- /dev/null +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -0,0 +1,353 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import { makeIssue, makePullRequest, makeWorkflowRun } from "../../helpers/index"; +import PersonalSummaryStrip from "../../../src/app/components/dashboard/PersonalSummaryStrip"; +import type { Issue, PullRequest, WorkflowRun } from "../../../src/app/services/api"; + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function renderStrip(opts: { + issues?: Issue[]; + pullRequests?: PullRequest[]; + workflowRuns?: WorkflowRun[]; + userLogin?: string; + onTabChange?: (tab: "issues" | "pullRequests" | "actions") => void; +}) { + const onTabChange = opts.onTabChange ?? vi.fn(); + return render(() => ( + + )); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("PersonalSummaryStrip — empty state", () => { + it("renders nothing when there are no actionable counts", () => { + const { container } = renderStrip({}); + // Strip should not render when all counts are zero — container inner div is empty + expect(container.innerHTML).toBe(""); + }); + + it("renders nothing when issues have no assignees", () => { + const issues = [makeIssue({ assigneeLogins: [] })]; + const { container } = renderStrip({ issues }); + expect(container.innerHTML).toBe(""); + }); +}); + +describe("PersonalSummaryStrip — assigned issues", () => { + it("shows assigned issues count when user is assigned", () => { + const issues = [ + makeIssue({ assigneeLogins: ["me"] }), + makeIssue({ assigneeLogins: ["me"] }), + ]; + + renderStrip({ issues }); + + expect(screen.getByText("2")).toBeDefined(); + expect(screen.getByText(/assigned/)).toBeDefined(); + }); + + it("does not count issues where user is not assigned", () => { + const issues = [ + makeIssue({ assigneeLogins: ["other-user"] }), + ]; + + const { container } = renderStrip({ issues }); + expect(container.innerHTML).toBe(""); + }); + + it("uses case-insensitive comparison for assignee login", () => { + const issues = [makeIssue({ assigneeLogins: ["ME"] })]; + + renderStrip({ issues, userLogin: "me" }); + expect(screen.getByText(/assigned/)).toBeDefined(); + }); +}); + +describe("PersonalSummaryStrip — PRs awaiting review", () => { + it("shows awaiting review count for enriched PRs where user is reviewer with REVIEW_REQUIRED", () => { + const prs = [ + makePullRequest({ + enriched: true, + reviewDecision: "REVIEW_REQUIRED", + reviewerLogins: ["me"], + userLogin: "author", + }), + ]; + + renderStrip({ pullRequests: prs }); + expect(screen.getByText(/awaiting review/)).toBeDefined(); + }); + + it("does not count unenriched PRs for awaiting review (enrichment gate)", () => { + const prs = [ + makePullRequest({ + enriched: false, + reviewDecision: "REVIEW_REQUIRED", + reviewerLogins: ["me"], + userLogin: "author", + }), + ]; + + const { container } = renderStrip({ pullRequests: prs }); + expect(container.innerHTML).toBe(""); + }); + + it("does not count PRs where user is not a reviewer", () => { + const prs = [ + makePullRequest({ + enriched: true, + reviewDecision: "REVIEW_REQUIRED", + reviewerLogins: ["other"], + userLogin: "author", + }), + ]; + + const { container } = renderStrip({ pullRequests: prs }); + expect(container.innerHTML).toBe(""); + }); + + it("does not count PRs with non-REVIEW_REQUIRED decision", () => { + const prs = [ + makePullRequest({ + enriched: true, + reviewDecision: "APPROVED", + reviewerLogins: ["me"], + userLogin: "author", + }), + ]; + + const { container } = renderStrip({ pullRequests: prs }); + expect(container.innerHTML).toBe(""); + }); +}); + +describe("PersonalSummaryStrip — PRs ready to merge", () => { + it("shows ready to merge count for user's authored PRs with checkStatus=success and APPROVED", () => { + const prs = [ + makePullRequest({ + userLogin: "me", + draft: false, + checkStatus: "success", + reviewDecision: "APPROVED", + }), + ]; + + renderStrip({ pullRequests: prs }); + expect(screen.getByText(/ready to merge/)).toBeDefined(); + }); + + it("shows ready to merge when reviewDecision is null (no review policy)", () => { + const prs = [ + makePullRequest({ + userLogin: "me", + draft: false, + checkStatus: "success", + reviewDecision: null, + }), + ]; + + renderStrip({ pullRequests: prs }); + expect(screen.getByText(/ready to merge/)).toBeDefined(); + }); + + it("does not count PRs authored by other users", () => { + const prs = [ + makePullRequest({ + userLogin: "other-user", + draft: false, + checkStatus: "success", + reviewDecision: "APPROVED", + }), + ]; + + const { container } = renderStrip({ pullRequests: prs }); + expect(container.innerHTML).toBe(""); + }); + + it("does not count draft PRs as ready to merge", () => { + const prs = [ + makePullRequest({ + userLogin: "me", + draft: true, + checkStatus: "success", + reviewDecision: "APPROVED", + }), + ]; + + const { container } = renderStrip({ pullRequests: prs }); + expect(container.innerHTML).toBe(""); + }); + + it("does not count PRs with non-success checkStatus", () => { + const prs = [ + makePullRequest({ + userLogin: "me", + draft: false, + checkStatus: "failure", + reviewDecision: "APPROVED", + }), + ]; + + renderStrip({ pullRequests: prs }); + // Should show blocked, not ready to merge + expect(screen.queryByText(/ready to merge/)).toBeNull(); + }); +}); + +describe("PersonalSummaryStrip — PRs blocked", () => { + it("shows blocked count for user's authored non-draft PRs with checkStatus=failure", () => { + const prs = [ + makePullRequest({ + userLogin: "me", + draft: false, + checkStatus: "failure", + }), + ]; + + renderStrip({ pullRequests: prs }); + expect(screen.getByText(/blocked/)).toBeDefined(); + }); + + it("shows blocked count for user's authored non-draft PRs with checkStatus=conflict", () => { + const prs = [ + makePullRequest({ + userLogin: "me", + draft: false, + checkStatus: "conflict", + }), + ]; + + renderStrip({ pullRequests: prs }); + expect(screen.getByText(/blocked/)).toBeDefined(); + }); + + it("does not count draft PRs with failing CI as blocked", () => { + const prs = [ + makePullRequest({ + userLogin: "me", + draft: true, + checkStatus: "failure", + }), + ]; + + const { container } = renderStrip({ pullRequests: prs }); + expect(container.innerHTML).toBe(""); + }); + + it("does not count other user's blocked PRs", () => { + const prs = [ + makePullRequest({ + userLogin: "other", + draft: false, + checkStatus: "failure", + }), + ]; + + const { container } = renderStrip({ pullRequests: prs }); + expect(container.innerHTML).toBe(""); + }); +}); + +describe("PersonalSummaryStrip — running actions", () => { + it("shows running count for in_progress workflow runs", () => { + const runs = [ + makeWorkflowRun({ status: "in_progress" }), + makeWorkflowRun({ status: "in_progress" }), + ]; + + renderStrip({ workflowRuns: runs }); + expect(screen.getByText(/running/)).toBeDefined(); + // Count is 2 + expect(screen.getByText("2")).toBeDefined(); + }); + + it("does not count completed workflow runs", () => { + const runs = [ + makeWorkflowRun({ status: "completed" }), + ]; + + const { container } = renderStrip({ workflowRuns: runs }); + expect(container.innerHTML).toBe(""); + }); +}); + +describe("PersonalSummaryStrip — click behavior", () => { + it("clicking assigned count calls onTabChange with 'issues'", () => { + const onTabChange = vi.fn(); + const issues = [makeIssue({ assigneeLogins: ["me"] })]; + + renderStrip({ issues, onTabChange }); + + const button = screen.getByText(/assigned/); + fireEvent.click(button); + expect(onTabChange).toHaveBeenCalledWith("issues"); + }); + + it("clicking awaiting review calls onTabChange with 'pullRequests'", () => { + const onTabChange = vi.fn(); + const prs = [ + makePullRequest({ + enriched: true, + reviewDecision: "REVIEW_REQUIRED", + reviewerLogins: ["me"], + userLogin: "author", + }), + ]; + + renderStrip({ pullRequests: prs, onTabChange }); + + const button = screen.getByText(/awaiting review/); + fireEvent.click(button); + expect(onTabChange).toHaveBeenCalledWith("pullRequests"); + }); + + it("clicking running actions calls onTabChange with 'actions'", () => { + const onTabChange = vi.fn(); + const runs = [makeWorkflowRun({ status: "in_progress" })]; + + renderStrip({ workflowRuns: runs, onTabChange }); + + const button = screen.getByText(/running/); + fireEvent.click(button); + expect(onTabChange).toHaveBeenCalledWith("actions"); + }); +}); + +describe("PersonalSummaryStrip — mixed state", () => { + it("only shows non-zero counts", () => { + const issues = [makeIssue({ assigneeLogins: ["me"] })]; + // No blocked PRs, no awaiting review, no running actions + + renderStrip({ issues }); + + expect(screen.getByText(/assigned/)).toBeDefined(); + expect(screen.queryByText(/awaiting review/)).toBeNull(); + expect(screen.queryByText(/ready to merge/)).toBeNull(); + expect(screen.queryByText(/blocked/)).toBeNull(); + expect(screen.queryByText(/running/)).toBeNull(); + }); + + it("shows multiple counts when they are all non-zero", () => { + const issues = [makeIssue({ assigneeLogins: ["me"] })]; + const runs = [makeWorkflowRun({ status: "in_progress" })]; + + renderStrip({ issues, workflowRuns: runs }); + + expect(screen.getByText(/assigned/)).toBeDefined(); + expect(screen.getByText(/running/)).toBeDefined(); + }); +}); diff --git a/tests/components/shared/FilterChips.test.tsx b/tests/components/shared/FilterChips.test.tsx new file mode 100644 index 00000000..69d80a5a --- /dev/null +++ b/tests/components/shared/FilterChips.test.tsx @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import FilterChips from "../../../src/app/components/shared/FilterChips"; +import type { FilterChipGroupDef } from "../../../src/app/components/shared/FilterChips"; + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function renderChips(opts: { + groups: FilterChipGroupDef[]; + values?: Record; + onChange?: (field: string, value: string) => void; + onReset?: (field: string) => void; + onResetAll?: () => void; +}) { + const onChange = opts.onChange ?? vi.fn(); + const onReset = opts.onReset ?? vi.fn(); + const onResetAll = opts.onResetAll ?? vi.fn(); + return render(() => ( + + )); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("FilterChips — standard group (no defaultValue)", () => { + const groups: FilterChipGroupDef[] = [ + { + label: "Role", + field: "role", + options: [ + { value: "author", label: "Author" }, + { value: "reviewer", label: "Reviewer" }, + ], + }, + ]; + + it("renders the auto-generated 'All' button when no defaultValue", () => { + renderChips({ groups }); + screen.getByText("All"); + }); + + it("'All' button is aria-pressed=true when value is 'all' (default)", () => { + renderChips({ groups, values: { role: "all" } }); + const allBtn = screen.getByText("All"); + expect(allBtn.getAttribute("aria-pressed")).toBe("true"); + }); + + it("does not show reset '×' button when filter is at default 'all'", () => { + renderChips({ groups, values: { role: "all" } }); + expect(screen.queryByLabelText("Reset Role filter")).toBeNull(); + }); + + it("shows reset '×' button when a non-default option is selected", () => { + renderChips({ groups, values: { role: "author" } }); + screen.getByLabelText("Reset Role filter"); + }); + + it("shows 'Reset all' button when filter differs from default", () => { + renderChips({ groups, values: { role: "author" } }); + screen.getByText("Reset all"); + }); + + it("does not show 'Reset all' button when filter is at default", () => { + renderChips({ groups, values: { role: "all" } }); + expect(screen.queryByText("Reset all")).toBeNull(); + }); +}); + +describe("FilterChips — group with defaultValue", () => { + const groups: FilterChipGroupDef[] = [ + { + label: "Scope", + field: "scope", + defaultValue: "involves_me", + options: [ + { value: "involves_me", label: "Involves me" }, + { value: "all", label: "All activity" }, + ], + }, + ]; + + it("does NOT render auto-generated 'All' button when defaultValue is set", () => { + renderChips({ groups }); + // "All activity" is an option, but "All" (auto-generated) should not appear + expect(screen.queryByText("All")).toBeNull(); + // The "All activity" option should still be present + screen.getByText("All activity"); + }); + + it("'Involves me' button is aria-pressed=true when value equals defaultValue", () => { + renderChips({ groups, values: { scope: "involves_me" } }); + const btn = screen.getByText("Involves me"); + expect(btn.getAttribute("aria-pressed")).toBe("true"); + }); + + it("does not show reset '×' button when value equals defaultValue", () => { + renderChips({ groups, values: { scope: "involves_me" } }); + expect(screen.queryByLabelText("Reset Scope filter")).toBeNull(); + }); + + it("shows reset '×' button when value differs from defaultValue", () => { + renderChips({ groups, values: { scope: "all" } }); + screen.getByLabelText("Reset Scope filter"); + }); + + it("'All activity' button is aria-pressed=true when value is 'all'", () => { + renderChips({ groups, values: { scope: "all" } }); + const btn = screen.getByText("All activity"); + expect(btn.getAttribute("aria-pressed")).toBe("true"); + }); + + it("shows 'Reset all' when value differs from defaultValue", () => { + renderChips({ groups, values: { scope: "all" } }); + screen.getByText("Reset all"); + }); + + it("does not show 'Reset all' when value equals defaultValue", () => { + renderChips({ groups, values: { scope: "involves_me" } }); + expect(screen.queryByText("Reset all")).toBeNull(); + }); + + it("does not show 'Reset all' when values object is empty (defaults apply)", () => { + renderChips({ groups, values: {} }); + expect(screen.queryByText("Reset all")).toBeNull(); + }); +}); + +describe("FilterChips — mixed groups (one standard, one with defaultValue)", () => { + const groups: FilterChipGroupDef[] = [ + { + label: "Scope", + field: "scope", + defaultValue: "involves_me", + options: [ + { value: "involves_me", label: "Involves me" }, + { value: "all", label: "All activity" }, + ], + }, + { + label: "Role", + field: "role", + options: [ + { value: "author", label: "Author" }, + { value: "reviewer", label: "Reviewer" }, + ], + }, + ]; + + it("shows 'All' for standard group but not for defaultValue group", () => { + renderChips({ groups }); + // "All" is the auto-generated button for Role group only + const allButtons = screen.getAllByText("All"); + expect(allButtons).toHaveLength(1); + // "All activity" is an option for Scope group + screen.getByText("All activity"); + }); + + it("shows 'Reset all' only when at least one filter differs from its default", () => { + // scope at default, role at default + renderChips({ groups, values: { scope: "involves_me", role: "all" } }); + expect(screen.queryByText("Reset all")).toBeNull(); + }); + + it("shows 'Reset all' when scope differs from defaultValue", () => { + renderChips({ groups, values: { scope: "all", role: "all" } }); + screen.getByText("Reset all"); + }); + + it("shows 'Reset all' when role differs from 'all'", () => { + renderChips({ groups, values: { scope: "involves_me", role: "author" } }); + screen.getByText("Reset all"); + }); + + it("calls onChange when a chip is clicked", () => { + const onChange = vi.fn(); + renderChips({ groups, onChange }); + fireEvent.click(screen.getByText("All activity")); + expect(onChange).toHaveBeenCalledWith("scope", "all"); + }); + + it("calls onReset when reset '×' button is clicked", () => { + const onReset = vi.fn(); + renderChips({ groups, values: { scope: "all" }, onReset }); + fireEvent.click(screen.getByLabelText("Reset Scope filter")); + expect(onReset).toHaveBeenCalledWith("scope"); + }); + + it("calls onResetAll when 'Reset all' is clicked", () => { + const onResetAll = vi.fn(); + renderChips({ groups, values: { scope: "all" }, onResetAll }); + fireEvent.click(screen.getByText("Reset all")); + expect(onResetAll).toHaveBeenCalled(); + }); +}); diff --git a/tests/lib/format.test.ts b/tests/lib/format.test.ts index 4ae3661c..99000186 100644 --- a/tests/lib/format.test.ts +++ b/tests/lib/format.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { relativeTime, shortRelativeTime, labelTextColor, formatDuration, prSizeCategory, deriveInvolvementRoles, formatCount } from "../../src/app/lib/format"; +import { relativeTime, shortRelativeTime, labelTextColor, formatDuration, prSizeCategory, deriveInvolvementRoles, formatCount, formatStarCount } from "../../src/app/lib/format"; describe("relativeTime", () => { beforeEach(() => { @@ -348,3 +348,41 @@ describe("formatCount", () => { expect(formatCount(10000)).toBe("10k"); }); }); + +describe("formatStarCount", () => { + it("returns '0' for 0", () => { + expect(formatStarCount(0)).toBe("0"); + }); + + it("returns '42' for 42", () => { + expect(formatStarCount(42)).toBe("42"); + }); + + it("returns '999' for 999", () => { + expect(formatStarCount(999)).toBe("999"); + }); + + it("returns '1k' for 1000", () => { + expect(formatStarCount(1000)).toBe("1k"); + }); + + it("returns '1.2k' for 1234", () => { + expect(formatStarCount(1234)).toBe("1.2k"); + }); + + it("returns '10k' for 9999 (rounds up)", () => { + expect(formatStarCount(9999)).toBe("10k"); + }); + + it("returns '15k' for 15000", () => { + expect(formatStarCount(15000)).toBe("15k"); + }); + + it("returns '1M' for 1000000", () => { + expect(formatStarCount(1000000)).toBe("1M"); + }); + + it("returns '1.2M' for 1234567", () => { + expect(formatStarCount(1234567)).toBe("1.2M"); + }); +}); diff --git a/tests/lib/grouping.test.ts b/tests/lib/grouping.test.ts index e9a7ce46..3cc11b80 100644 --- a/tests/lib/grouping.test.ts +++ b/tests/lib/grouping.test.ts @@ -4,10 +4,13 @@ import { groupByRepo, computePageLayout, slicePageGroups, type RepoGroup } from interface Item { repoFullName: string; id: number; + starCount?: number; } -function makeItem(repo: string, id: number): Item { - return { repoFullName: repo, id }; +function makeItem(repo: string, id: number, starCount?: number): Item { + const item: Item = { repoFullName: repo, id }; + if (starCount !== undefined) item.starCount = starCount; + return item; } function makeGroup(repo: string, count: number): RepoGroup { @@ -42,6 +45,18 @@ describe("groupByRepo", () => { expect(groups).toHaveLength(1); expect(groups[0].items).toHaveLength(2); }); + + it("propagates starCount from first item in group", () => { + const items = [makeItem("org/repo", 1, 42), makeItem("org/repo", 2, 42)]; + const groups = groupByRepo(items); + expect(groups[0].starCount).toBe(42); + }); + + it("leaves starCount undefined when items have no starCount", () => { + const items = [makeItem("org/repo", 1), makeItem("org/repo", 2)]; + const groups = groupByRepo(items); + expect(groups[0].starCount).toBeUndefined(); + }); }); describe("computePageLayout", () => { From 6768848a916321235acb7528c8018b5709c510c1 Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 14:00:07 -0400 Subject: [PATCH 07/14] fix: addresses review findings from Phase 4 and 4.5 --- .../components/dashboard/DashboardPage.tsx | 1 + src/app/components/dashboard/IssuesTab.tsx | 27 +++---- .../dashboard/PersonalSummaryStrip.tsx | 74 ++++++++++--------- .../components/dashboard/PullRequestsTab.tsx | 26 +++---- tests/components/dashboard/IssuesTab.test.tsx | 2 +- .../dashboard/PullRequestsTab.test.tsx | 2 +- tests/lib/format.test.ts | 2 +- 7 files changed, 72 insertions(+), 62 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 4c29feae..6d5b9196 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -163,6 +163,7 @@ async function pollFetch(): Promise { pr.enriched = e.enriched; pr.nodeId = e.nodeId; pr.surfacedBy = e.surfacedBy; + pr.starCount = e.starCount; } } else { state.pullRequests = data.pullRequests; diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 22051537..27047517 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -103,6 +103,16 @@ export default function IssuesTab(props: IssuesTabProps) { ]; }); + // Auto-reset scope to default when neither monitored repos nor tracked users are present + // (the scope chip group is hidden in that case, so any non-default scope would be sticky/invisible) + createEffect(() => { + const hasMonitoredRepos = (props.monitoredRepos ?? []).length > 0; + const hasTrackedUsers = (props.allUsers?.length ?? 0) > 1; + if (!hasMonitoredRepos && !hasTrackedUsers && viewState.tabFilters.issues.scope !== "involves_me") { + setTabFilter("issues", "scope", "involves_me"); + } + }); + const sortPref = createMemo(() => { const pref = viewState.sortPreferences["issues"]; return pref ?? { field: "updatedAt", direction: "desc" as const }; @@ -117,6 +127,7 @@ export default function IssuesTab(props: IssuesTabProps) { .map((i) => i.id) ); + const userLoginLower = props.userLogin.toLowerCase(); const meta = new Map }>(); let items = props.issues.filter((issue) => { @@ -127,17 +138,7 @@ export default function IssuesTab(props: IssuesTabProps) { const roles = deriveInvolvementRoles(props.userLogin, issue.userLogin, issue.assigneeLogins, [], upstreamRepoSet().has(issue.repoFullName)); // Scope filter - if (tabFilter.scope === "involves_me") { - const login = props.userLogin.toLowerCase(); - const surfacedBy = issue.surfacedBy ?? []; - if (surfacedBy.length > 0) { - if (!surfacedBy.includes(login)) return false; - } else if (monitoredRepoNameSet().has(issue.repoFullName)) { - const isInvolved = issue.userLogin.toLowerCase() === login || - issue.assigneeLogins.some(a => a.toLowerCase() === login); - if (!isInvolved) return false; - } - } + if (tabFilter.scope === "involves_me" && !isInvolvedItem(issue)) return false; if (tabFilter.role !== "all") { if (!roles.includes(tabFilter.role as "author" | "assignee")) return false; @@ -155,7 +156,7 @@ export default function IssuesTab(props: IssuesTabProps) { if (!monitoredRepoNameSet().has(issue.repoFullName)) { const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilter.user); if (validUser) { - const surfacedBy = issue.surfacedBy ?? [props.userLogin.toLowerCase()]; + const surfacedBy = issue.surfacedBy ?? [userLoginLower]; if (!surfacedBy.includes(tabFilter.user)) return false; } } @@ -245,7 +246,7 @@ export default function IssuesTab(props: IssuesTabProps) { if (surfacedBy.length > 0) return surfacedBy.includes(login); if (monitoredRepoNameSet().has(item.repoFullName)) { return item.userLogin.toLowerCase() === login || - item.assigneeLogins.some(a => a.toLowerCase() === login); + item.assigneeLogins.some((a) => a.toLowerCase() === login); } return true; } diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index 96ab29fb..fa600803 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -17,46 +17,50 @@ interface PersonalSummaryStripProps { } export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { - const assignedIssues = createMemo(() => { + // Single-pass over issues to count assigned + const issueCounts = createMemo(() => { const login = props.userLogin.toLowerCase(); - if (!login) return 0; - return props.issues.filter((i) => - i.assigneeLogins.some((a) => a.toLowerCase() === login) - ).length; + if (!login) return { assignedIssues: 0 }; + let assignedIssues = 0; + for (const i of props.issues) { + if (i.assigneeLogins.some((a) => a.toLowerCase() === login)) assignedIssues++; + } + return { assignedIssues }; }); - const prsAwaitingReview = createMemo(() => { + // Single-pass over PRs to count awaiting review, ready to merge, and blocked + const prCounts = createMemo(() => { const login = props.userLogin.toLowerCase(); - if (!login) return 0; - return props.pullRequests.filter( - (pr) => + if (!login) return { prsAwaitingReview: 0, prsReadyToMerge: 0, prsBlocked: 0 }; + let prsAwaitingReview = 0; + let prsReadyToMerge = 0; + let prsBlocked = 0; + for (const pr of props.pullRequests) { + const isAuthor = pr.userLogin.toLowerCase() === login; + if ( pr.enriched !== false && pr.reviewDecision === "REVIEW_REQUIRED" && pr.reviewerLogins.some((r) => r.toLowerCase() === login) - ).length; - }); - - const prsReadyToMerge = createMemo(() => { - const login = props.userLogin.toLowerCase(); - if (!login) return 0; - return props.pullRequests.filter( - (pr) => - pr.userLogin.toLowerCase() === login && + ) { + prsAwaitingReview++; + } + if ( + isAuthor && !pr.draft && pr.checkStatus === "success" && (pr.reviewDecision === "APPROVED" || pr.reviewDecision === null) - ).length; - }); - - const prsBlocked = createMemo(() => { - const login = props.userLogin.toLowerCase(); - if (!login) return 0; - return props.pullRequests.filter( - (pr) => - pr.userLogin.toLowerCase() === login && + ) { + prsReadyToMerge++; + } + if ( + isAuthor && !pr.draft && (pr.checkStatus === "failure" || pr.checkStatus === "conflict") - ).length; + ) { + prsBlocked++; + } + } + return { prsAwaitingReview, prsReadyToMerge, prsBlocked }; }); const runningActions = createMemo(() => @@ -64,12 +68,15 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { ); const summaryItems = createMemo(() => { + const { assignedIssues } = issueCounts(); + const { prsAwaitingReview, prsReadyToMerge, prsBlocked } = prCounts(); + const running = runningActions(); const items: SummaryCount[] = []; - if (assignedIssues() > 0) items.push({ label: "assigned", count: assignedIssues(), tab: "issues" }); - if (prsAwaitingReview() > 0) items.push({ label: "awaiting review", count: prsAwaitingReview(), tab: "pullRequests" }); - if (prsReadyToMerge() > 0) items.push({ label: "ready to merge", count: prsReadyToMerge(), tab: "pullRequests" }); - if (prsBlocked() > 0) items.push({ label: "blocked", count: prsBlocked(), tab: "pullRequests" }); - if (runningActions() > 0) items.push({ label: "running", count: runningActions(), tab: "actions" }); + if (assignedIssues > 0) items.push({ label: "assigned", count: assignedIssues, tab: "issues" }); + if (prsAwaitingReview > 0) items.push({ label: "awaiting review", count: prsAwaitingReview, tab: "pullRequests" }); + if (prsReadyToMerge > 0) items.push({ label: "ready to merge", count: prsReadyToMerge, tab: "pullRequests" }); + if (prsBlocked > 0) items.push({ label: "blocked", count: prsBlocked, tab: "pullRequests" }); + if (running > 0) items.push({ label: "running", count: running, tab: "actions" }); return items; }); @@ -83,6 +90,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { · diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 03069a90..d78fe594 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -12,8 +12,7 @@ import IgnoreBadge from "./IgnoreBadge"; import SortDropdown from "../shared/SortDropdown"; import type { SortOption } from "../shared/SortDropdown"; import PaginationControls from "../shared/PaginationControls"; -import FilterChips from "../shared/FilterChips"; -import type { FilterChipGroupDef } from "../shared/FilterChips"; +import FilterChips, { scopeFilterGroup, type FilterChipGroupDef } from "../shared/FilterChips"; import ReviewBadge from "../shared/ReviewBadge"; import SizeBadge from "../shared/SizeBadge"; import RoleBadge from "../shared/RoleBadge"; @@ -66,16 +65,6 @@ function reviewDecisionOrder(decision: PullRequest["reviewDecision"]): number { } } -const scopeFilterGroup: FilterChipGroupDef = { - label: "Scope", - field: "scope", - defaultValue: "involves_me", - options: [ - { value: "involves_me", label: "Involves me" }, - { value: "all", label: "All activity" }, - ], -}; - const prFilterGroups: FilterChipGroupDef[] = [ { label: "Role", @@ -111,6 +100,7 @@ const prFilterGroups: FilterChipGroupDef[] = [ { value: "failure", label: "Failing" }, { value: "pending", label: "Pending" }, { value: "conflict", label: "Conflict" }, + { value: "blocked", label: "Blocked" }, { value: "none", label: "No CI" }, ], }, @@ -155,11 +145,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const userLoginLower = createMemo(() => props.userLogin.toLowerCase()); + const showScopeFilter = createMemo(() => + (props.monitoredRepos ?? []).length > 0 || (props.allUsers?.length ?? 0) > 1 + ); + const filterGroups = createMemo(() => { const users = props.allUsers; - const hasMonitoredRepos = (props.monitoredRepos ?? []).length > 0; - const hasTrackedUsers = (props.allUsers?.length ?? 0) > 1; - const base = (hasMonitoredRepos || hasTrackedUsers) + const base = showScopeFilter() ? [scopeFilterGroup, ...prFilterGroups] : [...prFilterGroups]; if (!users || users.length <= 1) return base; @@ -173,16 +165,25 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { ]; }); - // Auto-reset scope to default when neither monitored repos nor tracked users are present - // (the scope chip group is hidden in that case, so any non-default scope would be sticky/invisible) + // Auto-reset scope to default when scope chip is hidden (localStorage hygiene) createEffect(() => { - const hasMonitoredRepos = (props.monitoredRepos ?? []).length > 0; - const hasTrackedUsers = (props.allUsers?.length ?? 0) > 1; - if (!hasMonitoredRepos && !hasTrackedUsers && viewState.tabFilters.pullRequests.scope !== "involves_me") { + if (!showScopeFilter() && viewState.tabFilters.pullRequests.scope !== "involves_me") { setTabFilter("pullRequests", "scope", "involves_me"); } }); + function isInvolvedItem(item: PullRequest): boolean { + const login = userLoginLower(); + const surfacedBy = item.surfacedBy ?? []; + if (surfacedBy.length > 0) return surfacedBy.includes(login); + if (monitoredRepoNameSet().has(item.repoFullName)) { + return item.userLogin.toLowerCase() === login || + item.assigneeLogins.some(a => a.toLowerCase() === login) || + (item.enriched !== false && item.reviewerLogins.some(r => r.toLowerCase() === login)); + } + return true; + } + const sortPref = createMemo(() => { const pref = viewState.sortPreferences["pullRequests"]; return pref ?? { field: "updatedAt", direction: "desc" as const }; @@ -207,8 +208,9 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const roles = deriveInvolvementRoles(props.userLogin, pr.userLogin, pr.assigneeLogins, pr.reviewerLogins, upstreamRepoSet().has(pr.repoFullName)); const sizeCategory = prSizeCategory(pr.additions, pr.deletions); - // Scope filter - if (tabFilters.scope === "involves_me" && !isInvolvedItem(pr)) return false; + // Scope filter — use effective scope to avoid one-render flash when auto-reset effect hasn't fired yet + const effectiveScope = showScopeFilter() ? tabFilters.scope : "involves_me"; + if (effectiveScope === "involves_me" && !isInvolvedItem(pr)) return false; // Tab filters — light-field filters always apply; heavy-field filters // only apply to enriched PRs so unenriched phase-1 PRs aren't incorrectly hidden @@ -228,6 +230,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (tabFilters.checkStatus !== "all" && isEnriched) { if (tabFilters.checkStatus === "none") { if (pr.checkStatus !== null) return false; + } else if (tabFilters.checkStatus === "blocked") { + if (pr.checkStatus !== "failure" && pr.checkStatus !== "conflict") return false; } else { if (pr.checkStatus !== tabFilters.checkStatus) return false; } @@ -333,6 +337,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { () => repoGroups().map(g => g.repoFullName), () => viewState.lockedRepos.pullRequests, () => viewState.ignoredItems.filter(i => i.type === "pullRequest").length, + () => JSON.stringify(viewState.tabFilters.pullRequests), ); function handleSort(field: string, direction: "asc" | "desc") { @@ -340,18 +345,6 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { setPage(0); } - function isInvolvedItem(item: PullRequest): boolean { - const login = userLoginLower(); - const surfacedBy = item.surfacedBy ?? []; - if (surfacedBy.length > 0) return surfacedBy.includes(login); - if (monitoredRepoNameSet().has(item.repoFullName)) { - return item.userLogin.toLowerCase() === login || - item.assigneeLogins.some(a => a.toLowerCase() === login) || - (item.enriched !== false && item.reviewerLogins.some(r => r.toLowerCase() === login)); - } - return true; - } - function handleIgnore(pr: PullRequest) { ignoreItem({ id: String(pr.id), @@ -564,8 +557,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { {(pr) => (
; diff --git a/src/app/lib/reorderHighlight.ts b/src/app/lib/reorderHighlight.ts index a1a357a5..8d31b4a1 100644 --- a/src/app/lib/reorderHighlight.ts +++ b/src/app/lib/reorderHighlight.ts @@ -5,10 +5,12 @@ export function createReorderHighlight( getRepoOrder: Accessor, getLockedOrder: Accessor, getIgnoredCount: Accessor, + getFilterKey?: Accessor, ): Accessor> { let prevOrder: string[] = []; let prevLocked: string[] = []; let prevIgnoredCount = getIgnoredCount(); + let prevFilterKey = getFilterKey?.() ?? ""; let timeout: ReturnType | undefined; const [highlighted, setHighlighted] = createSignal>(new Set()); @@ -16,12 +18,14 @@ export function createReorderHighlight( const currentOrder = getRepoOrder(); const currentLocked = getLockedOrder(); const currentIgnoredCount = getIgnoredCount(); + const currentFilterKey = getFilterKey?.() ?? ""; const lockedChanged = currentLocked.length !== prevLocked.length || currentLocked.some((r, i) => r !== prevLocked[i]); const ignoredChanged = currentIgnoredCount !== prevIgnoredCount; + const filterChanged = currentFilterKey !== prevFilterKey; - if (prevOrder.length > 0 && !lockedChanged && !ignoredChanged) { + if (prevOrder.length > 0 && !lockedChanged && !ignoredChanged && !filterChanged) { const moved = detectReorderedRepos(prevOrder, currentOrder); if (moved.size > 0) { setHighlighted(moved); @@ -33,6 +37,7 @@ export function createReorderHighlight( prevOrder = currentOrder; prevLocked = [...currentLocked]; prevIgnoredCount = currentIgnoredCount; + prevFilterKey = currentFilterKey; }); onCleanup(() => clearTimeout(timeout)); diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 245b6670..666213ec 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -18,7 +18,7 @@ const PullRequestFiltersSchema = z.object({ role: z.enum(["all", "author", "reviewer", "assignee"]).default("all"), reviewDecision: z.enum(["all", "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]).default("all"), draft: z.enum(["all", "draft", "ready"]).default("all"), - checkStatus: z.enum(["all", "success", "failure", "pending", "conflict", "none"]).default("all"), + checkStatus: z.enum(["all", "success", "failure", "pending", "conflict", "blocked", "none"]).default("all"), sizeCategory: z.enum(["all", "XS", "S", "M", "L", "XL"]).default("all"), user: z.enum(["all"]).or(z.string()).default("all"), }); diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index 0373a40f..3042b47c 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -431,7 +431,7 @@ describe("IssuesTab — left border accent in 'all' scope", () => { )); const listitem = container.querySelector('[role="listitem"]'); - expect(listitem?.className).toContain("border-l-2"); + expect(listitem?.className).toContain("border-l-primary"); }); it("does not add border-l-2 to community items in 'all' scope", () => { @@ -450,7 +450,7 @@ describe("IssuesTab — left border accent in 'all' scope", () => { )); const listitem = container.querySelector('[role="listitem"]'); - expect(listitem?.className).not.toContain("border-l-2"); + expect(listitem?.className).not.toContain("border-l-primary"); }); it("does not add border-l-2 in default 'involves_me' scope", () => { @@ -468,7 +468,7 @@ describe("IssuesTab — left border accent in 'all' scope", () => { )); const listitem = container.querySelector('[role="listitem"]'); - expect(listitem?.className).not.toContain("border-l-2"); + expect(listitem?.className).not.toContain("border-l-primary"); }); }); @@ -524,3 +524,24 @@ describe("IssuesTab — star count in repo headers", () => { expect(container.textContent).not.toContain("★"); }); }); + +// ── IssuesTab — scope filter fallback path ───────────────────────────────── + +describe("IssuesTab — scope filter with undefined surfacedBy (non-monitored repo)", () => { + it("scope 'involves_me' passes items with undefined surfacedBy from non-monitored repos", () => { + const issues = [ + makeIssue({ id: 1, title: "Legacy issue", repoFullName: "org/repo" }), + ]; + setAllExpanded("issues", ["org/repo"], true); + + render(() => ( + + )); + + screen.getByText("Legacy issue"); + }); +}); diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx index f5474f9b..72a06acc 100644 --- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -1,13 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@solidjs/testing-library"; -import { makeIssue, makePullRequest, makeWorkflowRun } from "../../helpers/index"; +import { makeIssue, makePullRequest, makeWorkflowRun, resetViewStore } from "../../helpers/index"; import PersonalSummaryStrip from "../../../src/app/components/dashboard/PersonalSummaryStrip"; +import IssuesTab from "../../../src/app/components/dashboard/IssuesTab"; +import PullRequestsTab from "../../../src/app/components/dashboard/PullRequestsTab"; import type { Issue, PullRequest, WorkflowRun } from "../../../src/app/services/api"; +import { viewState, setAllExpanded, ignoreItem } from "../../../src/app/stores/view"; // ── Setup ───────────────────────────────────────────────────────────────────── beforeEach(() => { vi.clearAllMocks(); + resetViewStore(); }); // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -315,7 +319,7 @@ describe("PersonalSummaryStrip — click behavior", () => { expect(onTabChange).toHaveBeenCalledWith("pullRequests"); }); - it("clicking running actions calls onTabChange with 'actions'", () => { + it("clicking running actions calls onTabChange with 'actions' and sets conclusion=running", () => { const onTabChange = vi.fn(); const runs = [makeWorkflowRun({ status: "in_progress" })]; @@ -324,6 +328,7 @@ describe("PersonalSummaryStrip — click behavior", () => { const button = screen.getByText(/running/); fireEvent.click(button); expect(onTabChange).toHaveBeenCalledWith("actions"); + expect(viewState.tabFilters.actions.conclusion).toBe("running"); }); }); @@ -351,3 +356,345 @@ describe("PersonalSummaryStrip — mixed state", () => { expect(screen.getByText(/running/)).toBeDefined(); }); }); + +describe("PersonalSummaryStrip — label context", () => { + it("shows 'issue assigned' (singular) for 1 assigned issue", () => { + const issues = [makeIssue({ assigneeLogins: ["me"] })]; + renderStrip({ issues }); + screen.getByText(/issue assigned/); + }); + + it("shows 'issues assigned' (plural) for multiple assigned issues", () => { + const issues = [ + makeIssue({ id: 1, assigneeLogins: ["me"] }), + makeIssue({ id: 2, assigneeLogins: ["me"] }), + ]; + renderStrip({ issues }); + screen.getByText(/issues assigned/); + }); + + it("shows 'PR awaiting review' for 1 PR", () => { + const prs = [makePullRequest({ enriched: true, reviewDecision: "REVIEW_REQUIRED", reviewerLogins: ["me"], userLogin: "author" })]; + renderStrip({ pullRequests: prs }); + screen.getByText(/PR awaiting review/); + }); + + it("shows 'PRs blocked' for multiple blocked PRs", () => { + const prs = [ + makePullRequest({ id: 1, userLogin: "me", draft: false, checkStatus: "failure" }), + makePullRequest({ id: 2, userLogin: "me", draft: false, checkStatus: "conflict" }), + ]; + renderStrip({ pullRequests: prs }); + screen.getByText(/PRs blocked/); + }); + + it("shows 'action running' for 1 running action", () => { + const runs = [makeWorkflowRun({ status: "in_progress" })]; + renderStrip({ workflowRuns: runs }); + screen.getByText(/action running/); + }); +}); + +describe("PersonalSummaryStrip — cor-2: excludes self-authored PRs from awaiting review", () => { + it("does not count PRs authored by the user as awaiting review even if user is in reviewerLogins", () => { + const prs = [ + makePullRequest({ + enriched: true, + reviewDecision: "REVIEW_REQUIRED", + reviewerLogins: ["me"], + userLogin: "me", + }), + ]; + + const { container } = renderStrip({ pullRequests: prs }); + expect(container.textContent).not.toContain("awaiting review"); + }); +}); + +describe("PersonalSummaryStrip — empty userLogin", () => { + it("renders nothing for issue/PR counts when userLogin is empty", () => { + const issues = [makeIssue({ assigneeLogins: ["me"] })]; + const { container } = renderStrip({ issues, userLogin: "" }); + expect(container.innerHTML).toBe(""); + }); + + it("still shows running actions count when userLogin is empty", () => { + const runs = [makeWorkflowRun({ status: "in_progress" })]; + renderStrip({ workflowRuns: runs, userLogin: "" }); + screen.getByText(/running/); + }); +}); + +describe("PersonalSummaryStrip — click applies filters", () => { + it("clicking assigned issues sets scope=all and role=assignee", () => { + const onTabChange = vi.fn(); + const issues = [makeIssue({ assigneeLogins: ["me"] })]; + + renderStrip({ issues, onTabChange }); + + const button = screen.getByText(/assigned/); + fireEvent.click(button); + + expect(onTabChange).toHaveBeenCalledWith("issues"); + expect(viewState.tabFilters.issues.scope).toBe("all"); + expect(viewState.tabFilters.issues.role).toBe("assignee"); + }); + + it("clicking awaiting review sets scope=all, role=reviewer, reviewDecision=REVIEW_REQUIRED", () => { + const onTabChange = vi.fn(); + const prs = [makePullRequest({ enriched: true, reviewDecision: "REVIEW_REQUIRED", reviewerLogins: ["me"], userLogin: "author" })]; + + renderStrip({ pullRequests: prs, onTabChange }); + + const button = screen.getByText(/awaiting review/); + fireEvent.click(button); + + expect(onTabChange).toHaveBeenCalledWith("pullRequests"); + expect(viewState.tabFilters.pullRequests.scope).toBe("all"); + expect(viewState.tabFilters.pullRequests.role).toBe("reviewer"); + expect(viewState.tabFilters.pullRequests.reviewDecision).toBe("REVIEW_REQUIRED"); + }); + + it("clicking ready to merge sets scope=all, role=author, checkStatus=success", () => { + const onTabChange = vi.fn(); + const prs = [makePullRequest({ userLogin: "me", draft: false, checkStatus: "success", reviewDecision: "APPROVED" })]; + + renderStrip({ pullRequests: prs, onTabChange }); + + const button = screen.getByText(/ready to merge/); + fireEvent.click(button); + + expect(onTabChange).toHaveBeenCalledWith("pullRequests"); + expect(viewState.tabFilters.pullRequests.scope).toBe("all"); + expect(viewState.tabFilters.pullRequests.role).toBe("author"); + expect(viewState.tabFilters.pullRequests.checkStatus).toBe("success"); + }); + + it("clicking blocked sets scope=all, role=author, draft=ready, checkStatus=blocked", () => { + const onTabChange = vi.fn(); + const prs = [makePullRequest({ userLogin: "me", draft: false, checkStatus: "failure" })]; + + renderStrip({ pullRequests: prs, onTabChange }); + + const button = screen.getByText(/blocked/); + fireEvent.click(button); + + expect(onTabChange).toHaveBeenCalledWith("pullRequests"); + expect(viewState.tabFilters.pullRequests.scope).toBe("all"); + expect(viewState.tabFilters.pullRequests.role).toBe("author"); + expect(viewState.tabFilters.pullRequests.draft).toBe("ready"); + expect(viewState.tabFilters.pullRequests.checkStatus).toBe("blocked"); + }); +}); + +// ── Integration: summary count matches filtered tab view ────────────────── +// These tests render the summary strip, click a count, then render the tab +// with the same data and verify the number of visible items matches the count. + +describe("PersonalSummaryStrip — count-to-filter contract", () => { + const userLogin = "me"; + + // Realistic mixed dataset + const mixedPRs: PullRequest[] = [ + // PR authored by me, passing CI, approved → ready to merge + makePullRequest({ id: 1, title: "Ready PR", repoFullName: "org/repo-a", userLogin: "me", draft: false, checkStatus: "success", reviewDecision: "APPROVED", surfacedBy: ["me"], enriched: true, reviewerLogins: [] }), + // PR authored by me, conflict → blocked + makePullRequest({ id: 2, title: "Conflict PR", repoFullName: "org/repo-a", userLogin: "me", draft: false, checkStatus: "conflict", reviewDecision: null, surfacedBy: ["me"], enriched: true, reviewerLogins: [] }), + // PR authored by me, failing CI → blocked + makePullRequest({ id: 3, title: "Failing PR", repoFullName: "org/repo-b", userLogin: "me", draft: false, checkStatus: "failure", reviewDecision: null, surfacedBy: ["me"], enriched: true, reviewerLogins: [] }), + // PR authored by someone else, I'm a reviewer, needs review → awaiting review + makePullRequest({ id: 4, title: "Review PR", repoFullName: "org/repo-c", userLogin: "other-author", draft: false, checkStatus: "pending", reviewDecision: "REVIEW_REQUIRED", surfacedBy: ["other-author"], enriched: true, reviewerLogins: ["me"] }), + // PR authored by me, draft with failing CI → NOT blocked (draft excluded) + makePullRequest({ id: 5, title: "Draft PR", repoFullName: "org/repo-a", userLogin: "me", draft: true, checkStatus: "failure", reviewDecision: null, surfacedBy: ["me"], enriched: true, reviewerLogins: [] }), + // PR from tracked user, user not involved → only visible in scope=all + makePullRequest({ id: 6, title: "Tracked PR", repoFullName: "org/repo-d", userLogin: "tracked-user", draft: false, checkStatus: "success", reviewDecision: "APPROVED", surfacedBy: ["tracked-user"], enriched: true, reviewerLogins: [] }), + ]; + + const mixedIssues: Issue[] = [ + // Issue where me is assignee + makeIssue({ id: 101, title: "Assigned issue", repoFullName: "org/repo-a", assigneeLogins: ["me"], surfacedBy: ["me"] }), + // Issue where me is NOT assignee + makeIssue({ id: 102, title: "Other issue", repoFullName: "org/repo-b", assigneeLogins: ["other"], surfacedBy: ["me"] }), + ]; + + it("'blocked' count matches PullRequestsTab filtered view (failure + conflict, non-draft)", () => { + const onTabChange = vi.fn(); + const { unmount } = render(() => ( + + )); + + // Summary should show 2 blocked (ids 2 + 3, not draft id 5) + const blockedButton = screen.getByText(/blocked/); + expect(blockedButton.textContent).toContain("2"); + + // Click it — applies filters + fireEvent.click(blockedButton); + unmount(); + + // Render PullRequestsTab with same data and applied filters + setAllExpanded("pullRequests", ["org/repo-a", "org/repo-b", "org/repo-c", "org/repo-d"], true); + render(() => ( + + )); + + // Should see exactly the 2 blocked PRs + screen.getByText("Conflict PR"); + screen.getByText("Failing PR"); + expect(screen.queryByText("Ready PR")).toBeNull(); + expect(screen.queryByText("Draft PR")).toBeNull(); + expect(screen.queryByText("Tracked PR")).toBeNull(); + expect(screen.queryByText("Review PR")).toBeNull(); + }); + + it("'awaiting review' count matches PullRequestsTab filtered view", () => { + const onTabChange = vi.fn(); + const { unmount } = render(() => ( + + )); + + const reviewButton = screen.getByText(/awaiting review/); + expect(reviewButton.textContent).toContain("1"); + fireEvent.click(reviewButton); + unmount(); + + setAllExpanded("pullRequests", ["org/repo-a", "org/repo-b", "org/repo-c", "org/repo-d"], true); + render(() => ( + + )); + + screen.getByText("Review PR"); + expect(screen.queryByText("Ready PR")).toBeNull(); + expect(screen.queryByText("Conflict PR")).toBeNull(); + }); + + it("'ready to merge' count matches PullRequestsTab filtered view", () => { + const onTabChange = vi.fn(); + const { unmount } = render(() => ( + + )); + + const mergeButton = screen.getByText(/ready to merge/); + expect(mergeButton.textContent).toContain("1"); + fireEvent.click(mergeButton); + unmount(); + + setAllExpanded("pullRequests", ["org/repo-a", "org/repo-b", "org/repo-c", "org/repo-d"], true); + render(() => ( + + )); + + screen.getByText("Ready PR"); + expect(screen.queryByText("Conflict PR")).toBeNull(); + expect(screen.queryByText("Failing PR")).toBeNull(); + }); + + it("'assigned' count matches IssuesTab filtered view", () => { + const onTabChange = vi.fn(); + const { unmount } = render(() => ( + + )); + + const assignedButton = screen.getByText(/assigned/); + expect(assignedButton.textContent).toContain("1"); + fireEvent.click(assignedButton); + unmount(); + + setAllExpanded("issues", ["org/repo-a", "org/repo-b"], true); + render(() => ( + + )); + + screen.getByText("Assigned issue"); + expect(screen.queryByText("Other issue")).toBeNull(); + }); + + it("'blocked' count includes PRs from tracked-user-only repos (surfacedBy excludes current user)", () => { + // PR authored by me in a repo only surfaced by a tracked user + const trackedOnlyPRs: PullRequest[] = [ + makePullRequest({ id: 10, title: "Tracked-only blocked PR", repoFullName: "org/tracked-repo", userLogin: "me", draft: false, checkStatus: "conflict", surfacedBy: ["tracked-user"], enriched: true, reviewerLogins: [] }), + ]; + + const onTabChange = vi.fn(); + const { unmount } = render(() => ( + + )); + + const blockedButton = screen.getByText(/blocked/); + expect(blockedButton.textContent).toContain("1"); + fireEvent.click(blockedButton); + unmount(); + + setAllExpanded("pullRequests", ["org/tracked-repo"], true); + render(() => ( + + )); + + // Must be visible — scope=all ensures surfacedBy doesn't filter it out + screen.getByText("Tracked-only blocked PR"); + }); +}); + +// ── Ignored items exclusion ─────────────────────────────────────────────── + +describe("PersonalSummaryStrip — excludes ignored items", () => { + it("does not count ignored PRs in awaiting review", () => { + const prs = [ + makePullRequest({ id: 99, enriched: true, reviewDecision: "REVIEW_REQUIRED", reviewerLogins: ["me"], userLogin: "author" }), + ]; + ignoreItem({ id: "99", type: "pullRequest", repo: "org/repo", title: "Ignored PR", ignoredAt: Date.now() }); + + const { container } = renderStrip({ pullRequests: prs }); + expect(container.textContent).not.toContain("awaiting review"); + }); + + it("does not count ignored PRs in blocked", () => { + const prs = [ + makePullRequest({ id: 50, userLogin: "me", draft: false, checkStatus: "failure" }), + ]; + ignoreItem({ id: "50", type: "pullRequest", repo: "org/repo", title: "Ignored blocked", ignoredAt: Date.now() }); + + const { container } = renderStrip({ pullRequests: prs }); + expect(container.textContent).not.toContain("blocked"); + }); + + it("does not count ignored issues in assigned", () => { + const issues = [ + makeIssue({ id: 200, assigneeLogins: ["me"] }), + ]; + ignoreItem({ id: "200", type: "issue", repo: "org/repo", title: "Ignored issue", ignoredAt: Date.now() }); + + const { container } = renderStrip({ issues }); + expect(container.innerHTML).toBe(""); + }); + + it("still counts non-ignored items when some are ignored", () => { + const prs = [ + makePullRequest({ id: 1, userLogin: "me", draft: false, checkStatus: "failure" }), + makePullRequest({ id: 2, userLogin: "me", draft: false, checkStatus: "conflict" }), + ]; + ignoreItem({ id: "1", type: "pullRequest", repo: "org/repo", title: "Ignored", ignoredAt: Date.now() }); + + renderStrip({ pullRequests: prs }); + const blockedButton = screen.getByText(/blocked/); + expect(blockedButton.textContent).toContain("1"); + }); +}); diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index 561792f2..6443281e 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -381,10 +381,10 @@ describe("PullRequestsTab — left border accent in 'all' scope", () => { )); const listitem = container.querySelector('[role="listitem"]'); - expect(listitem?.className).toContain("border-l-2"); + expect(listitem?.className).toContain("border-l-primary"); }); - it("does not add border-l-2 to community PRs in 'all' scope", () => { + it("does not add border-l-primary to community PRs in 'all' scope", () => { const prs = [ makePullRequest({ id: 1, title: "Community PR", repoFullName: "org/monitored", surfacedBy: ["other"], userLogin: "other", assigneeLogins: [], reviewerLogins: [] }), ]; @@ -400,10 +400,10 @@ describe("PullRequestsTab — left border accent in 'all' scope", () => { )); const listitem = container.querySelector('[role="listitem"]'); - expect(listitem?.className).not.toContain("border-l-2"); + expect(listitem?.className).not.toContain("border-l-primary"); }); - it("does not add border-l-2 in default 'involves_me' scope", () => { + it("does not add border-l-primary in default 'involves_me' scope", () => { const prs = [ makePullRequest({ id: 1, title: "My PR", repoFullName: "org/repo", surfacedBy: ["me"] }), ]; @@ -418,7 +418,7 @@ describe("PullRequestsTab — left border accent in 'all' scope", () => { )); const listitem = container.querySelector('[role="listitem"]'); - expect(listitem?.className).not.toContain("border-l-2"); + expect(listitem?.className).not.toContain("border-l-primary"); }); }); diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index 088375ce..ec82edc5 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -9,6 +9,7 @@ import { pruneStaleIgnoredItems, setSortPreference, setGlobalFilter, + setTabFilter, resetAllTabFilters, initViewPersistence, ViewStateSchema, @@ -373,6 +374,22 @@ describe("hideDepDashboard", () => { resetAllTabFilters("issues"); expect(viewState.hideDepDashboard).toBe(false); }); +}); + +describe("resetAllTabFilters — scope reset", () => { + it("resets issues scope from 'all' back to 'involves_me'", () => { + setTabFilter("issues", "scope", "all"); + expect(viewState.tabFilters.issues.scope).toBe("all"); + resetAllTabFilters("issues"); + expect(viewState.tabFilters.issues.scope).toBe("involves_me"); + }); + + it("resets pullRequests scope from 'all' back to 'involves_me'", () => { + setTabFilter("pullRequests", "scope", "all"); + expect(viewState.tabFilters.pullRequests.scope).toBe("all"); + resetAllTabFilters("pullRequests"); + expect(viewState.tabFilters.pullRequests.scope).toBe("involves_me"); + }); it("is reset by resetViewState", () => { updateViewState({ hideDepDashboard: false }); From 1da5632825ad1e6d6c5eecd25fa494142369cbb1 Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 16:42:55 -0400 Subject: [PATCH 10/14] fix: adds draft=ready filter to ready-to-merge summary click --- src/app/components/dashboard/PersonalSummaryStrip.tsx | 1 + tests/components/dashboard/PersonalSummaryStrip.test.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index 4c7bea08..bcf018d5 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -117,6 +117,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { resetAllTabFilters("pullRequests"); setTabFilter("pullRequests", "scope", "all"); setTabFilter("pullRequests", "role", "author"); + setTabFilter("pullRequests", "draft", "ready"); setTabFilter("pullRequests", "checkStatus", "success"); }, }); diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx index 72a06acc..8be3d518 100644 --- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -455,7 +455,7 @@ describe("PersonalSummaryStrip — click applies filters", () => { expect(viewState.tabFilters.pullRequests.reviewDecision).toBe("REVIEW_REQUIRED"); }); - it("clicking ready to merge sets scope=all, role=author, checkStatus=success", () => { + it("clicking ready to merge sets scope=all, role=author, draft=ready, checkStatus=success", () => { const onTabChange = vi.fn(); const prs = [makePullRequest({ userLogin: "me", draft: false, checkStatus: "success", reviewDecision: "APPROVED" })]; @@ -467,6 +467,7 @@ describe("PersonalSummaryStrip — click applies filters", () => { expect(onTabChange).toHaveBeenCalledWith("pullRequests"); expect(viewState.tabFilters.pullRequests.scope).toBe("all"); expect(viewState.tabFilters.pullRequests.role).toBe("author"); + expect(viewState.tabFilters.pullRequests.draft).toBe("ready"); expect(viewState.tabFilters.pullRequests.checkStatus).toBe("success"); }); From dd67c5e3600d79b363a79ae449aa613e3333e6a8 Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 17:58:38 -0400 Subject: [PATCH 11/14] fix: excludes Dep Dashboard from summary, adds missing tests --- .../dashboard/PersonalSummaryStrip.tsx | 3 +- tests/components/dashboard/IssuesTab.test.tsx | 50 ++++++++++++++- .../dashboard/PersonalSummaryStrip.test.tsx | 24 +++++++- .../dashboard/PullRequestsTab.test.tsx | 61 ++++++++++++++++++- tests/lib/reorderHighlight.test.ts | 52 ++++++++++++++++ 5 files changed, 186 insertions(+), 4 deletions(-) diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index bcf018d5..40c8a1af 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -25,7 +25,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { return ids; }); - // Single-pass over issues to count assigned (excludes ignored) + // Single-pass over issues to count assigned (excludes ignored + Dep Dashboard) const issueCounts = createMemo(() => { const login = props.userLogin.toLowerCase(); if (!login) return { assignedIssues: 0 }; @@ -33,6 +33,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { let assignedIssues = 0; for (const i of props.issues) { if (ignored.has(String(i.id))) continue; + if (viewState.hideDepDashboard && i.title === "Dependency Dashboard") continue; if (i.assigneeLogins.some((a) => a.toLowerCase() === login)) assignedIssues++; } return { assignedIssues }; diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index 3042b47c..e5d67c85 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -29,7 +29,7 @@ vi.mock("../../../src/app/lib/url", () => ({ // ── Imports ─────────────────────────────────────────────────────────────────── import IssuesTab from "../../../src/app/components/dashboard/IssuesTab"; -import { setTabFilter, setAllExpanded, resetViewState } from "../../../src/app/stores/view"; +import { viewState, setTabFilter, setAllExpanded, resetViewState } from "../../../src/app/stores/view"; import type { TrackedUser } from "../../../src/app/stores/config"; // ── Setup ───────────────────────────────────────────────────────────────────── @@ -545,3 +545,51 @@ describe("IssuesTab — scope filter with undefined surfacedBy (non-monitored re screen.getByText("Legacy issue"); }); }); + +// ── IssuesTab — scope chip visibility ────────────────────────────────────── + +describe("IssuesTab — scope chip visibility", () => { + it("does not show Scope chip when no monitored repos and no tracked users", () => { + const issues = [makeIssue({ id: 1, title: "Issue", repoFullName: "org/repo", surfacedBy: ["me"] })]; + + const { container } = render(() => ( + + )); + + expect(container.textContent).not.toContain("Scope:"); + }); + + it("shows Scope chip when monitored repos exist", () => { + const issues = [makeIssue({ id: 1, title: "Issue", repoFullName: "org/repo", surfacedBy: ["me"] })]; + + const { container } = render(() => ( + + )); + + expect(container.textContent).toContain("Scope:"); + }); + + it("shows Scope chip when tracked users exist (allUsers > 1)", () => { + const issues = [makeIssue({ id: 1, title: "Issue", repoFullName: "org/repo", surfacedBy: ["me"] })]; + + const { container } = render(() => ( + + )); + + expect(container.textContent).toContain("Scope:"); + }); + + it("auto-resets scope to involves_me when scope chip becomes hidden", () => { + setTabFilter("issues", "scope", "all"); + expect(viewState.tabFilters.issues.scope).toBe("all"); + + // Render with no monitored repos and no tracked users — scope chip hidden, effect should reset + render(() => ( + + )); + + expect(viewState.tabFilters.issues.scope).toBe("involves_me"); + }); +}); diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx index 8be3d518..54954cab 100644 --- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -5,7 +5,7 @@ import PersonalSummaryStrip from "../../../src/app/components/dashboard/Personal import IssuesTab from "../../../src/app/components/dashboard/IssuesTab"; import PullRequestsTab from "../../../src/app/components/dashboard/PullRequestsTab"; import type { Issue, PullRequest, WorkflowRun } from "../../../src/app/services/api"; -import { viewState, setAllExpanded, ignoreItem } from "../../../src/app/stores/view"; +import { viewState, updateViewState, setAllExpanded, ignoreItem } from "../../../src/app/stores/view"; // ── Setup ───────────────────────────────────────────────────────────────────── @@ -699,3 +699,25 @@ describe("PersonalSummaryStrip — excludes ignored items", () => { expect(blockedButton.textContent).toContain("1"); }); }); + +describe("PersonalSummaryStrip — hideDepDashboard exclusion", () => { + it("excludes Dependency Dashboard issues from assigned count when hideDepDashboard is true", () => { + const issues = [ + makeIssue({ id: 1, title: "Dependency Dashboard", assigneeLogins: ["me"] }), + ]; + // hideDepDashboard defaults to true via resetViewStore + + const { container } = renderStrip({ issues }); + expect(container.innerHTML).toBe(""); + }); + + it("includes Dependency Dashboard issues when hideDepDashboard is false", () => { + updateViewState({ hideDepDashboard: false }); + const issues = [ + makeIssue({ id: 1, title: "Dependency Dashboard", assigneeLogins: ["me"] }), + ]; + + renderStrip({ issues }); + screen.getByText(/assigned/); + }); +}); diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index 6443281e..1acd9760 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -29,7 +29,7 @@ vi.mock("../../../src/app/lib/url", () => ({ // ── Imports ─────────────────────────────────────────────────────────────────── import PullRequestsTab from "../../../src/app/components/dashboard/PullRequestsTab"; -import { setTabFilter, setAllExpanded, resetViewState } from "../../../src/app/stores/view"; +import { viewState, setTabFilter, setAllExpanded, resetViewState } from "../../../src/app/stores/view"; import type { TrackedUser } from "../../../src/app/stores/config"; // ── Setup ───────────────────────────────────────────────────────────────────── @@ -473,3 +473,62 @@ describe("PullRequestsTab — star count in repo headers", () => { expect(container.textContent).not.toContain("★"); }); }); + +// ── PullRequestsTab — scope chip visibility ──────────────────────────────── + +describe("PullRequestsTab — scope chip visibility", () => { + it("does not show Scope chip when no monitored repos and no tracked users", () => { + const prs = [makePullRequest({ id: 1, title: "PR", repoFullName: "org/repo", surfacedBy: ["me"] })]; + + const { container } = render(() => ( + + )); + + expect(container.textContent).not.toContain("Scope:"); + }); + + it("shows Scope chip when monitored repos exist", () => { + const prs = [makePullRequest({ id: 1, title: "PR", repoFullName: "org/repo", surfacedBy: ["me"] })]; + + const { container } = render(() => ( + + )); + + expect(container.textContent).toContain("Scope:"); + }); + + it("auto-resets scope to involves_me when scope chip becomes hidden", () => { + setTabFilter("pullRequests", "scope", "all"); + expect(viewState.tabFilters.pullRequests.scope).toBe("all"); + + render(() => ( + + )); + + expect(viewState.tabFilters.pullRequests.scope).toBe("involves_me"); + }); +}); + +// ── PullRequestsTab — blocked composite filter ──────────────────────────── + +describe("PullRequestsTab — checkStatus=blocked filter", () => { + it("shows both failure and conflict PRs when checkStatus=blocked", () => { + const prs = [ + makePullRequest({ id: 1, title: "Failing PR", repoFullName: "org/repo", checkStatus: "failure", surfacedBy: ["me"], enriched: true }), + makePullRequest({ id: 2, title: "Conflict PR", repoFullName: "org/repo", checkStatus: "conflict", surfacedBy: ["me"], enriched: true }), + makePullRequest({ id: 3, title: "Passing PR", repoFullName: "org/repo", checkStatus: "success", surfacedBy: ["me"], enriched: true }), + ]; + setTabFilter("pullRequests", "checkStatus", "blocked"); + setAllExpanded("pullRequests", ["org/repo"], true); + + render(() => ( + + )); + + screen.getByText("Failing PR"); + screen.getByText("Conflict PR"); + expect(screen.queryByText("Passing PR")).toBeNull(); + }); +}); diff --git a/tests/lib/reorderHighlight.test.ts b/tests/lib/reorderHighlight.test.ts index 5740539e..e2d10a7f 100644 --- a/tests/lib/reorderHighlight.test.ts +++ b/tests/lib/reorderHighlight.test.ts @@ -210,4 +210,56 @@ describe("createReorderHighlight", () => { disposeRoot(); vi.useRealTimers(); }); + + it("suppresses highlight when filter key changes simultaneously with reorder", () => { + let highlighted!: Accessor>; + let setOrder!: (v: string[]) => void; + let setFilterKey!: (v: string) => void; + let disposeRoot!: () => void; + + createRoot((dispose) => { + const [order, _setOrder] = createSignal(["a", "b", "c"]); + const [locked] = createSignal([]); + const [ignored] = createSignal(0); + const [filterKey, _setFilterKey] = createSignal("initial"); + setOrder = _setOrder; + setFilterKey = _setFilterKey; + highlighted = createReorderHighlight(order, locked, ignored, filterKey); + disposeRoot = dispose; + }); + + // Reorder AND filter change in single batch — should suppress + batch(() => { + setFilterKey("changed"); + setOrder(["c", "a", "b"]); + }); + expect(highlighted().size).toBe(0); + + // Next reorder without filter change — should highlight + setOrder(["b", "c", "a"]); + expect(highlighted().size).toBeGreaterThan(0); + + disposeRoot(); + }); + + it("highlights normally when getFilterKey is not provided", () => { + let highlighted!: Accessor>; + let setOrder!: (v: string[]) => void; + let disposeRoot!: () => void; + + createRoot((dispose) => { + const [order, _setOrder] = createSignal(["a", "b", "c"]); + const [locked] = createSignal([]); + const [ignored] = createSignal(0); + setOrder = _setOrder; + // No getFilterKey argument — optional parameter + highlighted = createReorderHighlight(order, locked, ignored); + disposeRoot = dispose; + }); + + setOrder(["c", "a", "b"]); + expect(highlighted().size).toBeGreaterThan(0); + + disposeRoot(); + }); }); From 1d40198754f0590ada4ccc80707b4bc24bd6cb1d Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 18:05:32 -0400 Subject: [PATCH 12/14] fix: enables showPrRuns when clicking running actions count --- src/app/components/dashboard/PersonalSummaryStrip.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index 40c8a1af..9f68eeeb 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -1,7 +1,7 @@ import { createMemo, For, Show } from "solid-js"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import type { TabId } from "../layout/TabBar"; -import { viewState, resetAllTabFilters, setTabFilter } from "../../stores/view"; +import { viewState, updateViewState, resetAllTabFilters, setTabFilter } from "../../stores/view"; interface SummaryCount { label: string; @@ -141,6 +141,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { applyFilters: () => { resetAllTabFilters("actions"); setTabFilter("actions", "conclusion", "running"); + updateViewState({ showPrRuns: true }); }, }); return items; From 6bc382fddf81e4ccc0cfeb138cf00ab256d213ce Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 18:11:16 -0400 Subject: [PATCH 13/14] refactor: extracts isUserInvolved, documents approximations --- src/app/components/dashboard/IssuesTab.tsx | 14 ++----- .../dashboard/PersonalSummaryStrip.tsx | 14 ++++++- .../components/dashboard/PullRequestsTab.tsx | 16 ++------ src/app/lib/grouping.ts | 25 ++++++++++++ tests/lib/grouping.test.ts | 39 ++++++++++++++++++- 5 files changed, 82 insertions(+), 26 deletions(-) diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 72f16d03..4154d7d7 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -14,7 +14,7 @@ import SkeletonRows from "../shared/SkeletonRows"; import ChevronIcon from "../shared/ChevronIcon"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import { deriveInvolvementRoles, formatStarCount } from "../../lib/format"; -import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; @@ -103,16 +103,8 @@ export default function IssuesTab(props: IssuesTabProps) { } }); - function isInvolvedItem(item: Issue): boolean { - const login = userLoginLower(); - const surfacedBy = item.surfacedBy ?? []; - if (surfacedBy.length > 0) return surfacedBy.includes(login); - if (monitoredRepoNameSet().has(item.repoFullName)) { - return item.userLogin.toLowerCase() === login || - item.assigneeLogins.some((a) => a.toLowerCase() === login); - } - return true; - } + const isInvolvedItem = (item: Issue) => + isUserInvolved(item, userLoginLower(), monitoredRepoNameSet()); const sortPref = createMemo(() => { const pref = viewState.sortPreferences["issues"]; diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index 9f68eeeb..6a6579d7 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -87,8 +87,18 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { const { prsAwaitingReview, prsReadyToMerge, prsBlocked } = prCounts(); const running = runningActions(); const items: SummaryCount[] = []; - // Summary counts from unfiltered data — set scope=all so the filtered view - // matches. The specific filters (role, checkStatus) already ensure relevance. + // ── Count-to-filter contract ── + // Counts are computed from unfiltered data (ignoring scope, globalFilter, showPrRuns). + // Click filters set scope=all so tabs don't hide items the count included. + // Known approximations (single-value filter system cannot express these): + // - "ready to merge": count requires reviewDecision=APPROVED||null, but filter + // can't express OR — PRs with CHANGES_REQUESTED + passing CI may appear + // - "awaiting review": count excludes self-authored PRs (!isAuthor), but + // role=reviewer filter includes them if user is both author+reviewer (rare) + // - globalFilter (org/repo) is NOT applied here — counts are persistent + // awareness across all repos, matching the tab badge behavior + // - "running": count includes all in_progress runs; click enables showPrRuns + // so PR-triggered runs are visible in the tab if (assignedIssues > 0) items.push({ label: assignedIssues === 1 ? "issue assigned" : "issues assigned", count: assignedIssues, diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index d78fe594..500b10ec 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -18,7 +18,7 @@ import SizeBadge from "../shared/SizeBadge"; import RoleBadge from "../shared/RoleBadge"; import SkeletonRows from "../shared/SkeletonRows"; import ChevronIcon from "../shared/ChevronIcon"; -import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; import RepoLockControls from "../shared/RepoLockControls"; @@ -172,17 +172,9 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { } }); - function isInvolvedItem(item: PullRequest): boolean { - const login = userLoginLower(); - const surfacedBy = item.surfacedBy ?? []; - if (surfacedBy.length > 0) return surfacedBy.includes(login); - if (monitoredRepoNameSet().has(item.repoFullName)) { - return item.userLogin.toLowerCase() === login || - item.assigneeLogins.some(a => a.toLowerCase() === login) || - (item.enriched !== false && item.reviewerLogins.some(r => r.toLowerCase() === login)); - } - return true; - } + const isInvolvedItem = (item: PullRequest) => + isUserInvolved(item, userLoginLower(), monitoredRepoNameSet(), + item.enriched !== false ? item.reviewerLogins : undefined); const sortPref = createMemo(() => { const pref = viewState.sortPreferences["pullRequests"]; diff --git a/src/app/lib/grouping.ts b/src/app/lib/grouping.ts index 67446f9d..f0f49cf6 100644 --- a/src/app/lib/grouping.ts +++ b/src/app/lib/grouping.ts @@ -73,6 +73,31 @@ export function orderRepoGroups( return [...locked, ...unlocked]; } +/** + * Three-tier involvement check for scope filtering. + * Shared by IssuesTab and PullRequestsTab — keep both call sites in sync. + * + * Tier 1: surfacedBy annotation present → check if user is included + * Tier 2: monitored repo (no surfacedBy) → field-based fallback (author/assignee) + * Pass reviewerLogins for PRs (only when enriched — unenriched PRs have []) + * Tier 3: non-monitored, no surfacedBy → pass (fetched via involves:{user}) + */ +export function isUserInvolved( + item: { repoFullName: string; userLogin: string; assigneeLogins: string[]; surfacedBy?: string[] }, + login: string, + monitoredRepos: ReadonlySet, + reviewerLogins?: string[], +): boolean { + const surfacedBy = item.surfacedBy ?? []; + if (surfacedBy.length > 0) return surfacedBy.includes(login); + if (monitoredRepos.has(item.repoFullName)) { + return item.userLogin.toLowerCase() === login || + item.assigneeLogins.some(a => a.toLowerCase() === login) || + (reviewerLogins != null && reviewerLogins.some(r => r.toLowerCase() === login)); + } + return true; +} + export function detectReorderedRepos( previousOrder: string[], currentOrder: string[] diff --git a/tests/lib/grouping.test.ts b/tests/lib/grouping.test.ts index 3cc11b80..08da07a7 100644 --- a/tests/lib/grouping.test.ts +++ b/tests/lib/grouping.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { groupByRepo, computePageLayout, slicePageGroups, type RepoGroup } from "../../src/app/lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, isUserInvolved, type RepoGroup } from "../../src/app/lib/grouping"; interface Item { repoFullName: string; @@ -59,6 +59,43 @@ describe("groupByRepo", () => { }); }); +describe("isUserInvolved", () => { + const base = { repoFullName: "org/repo", userLogin: "author", assigneeLogins: [] as string[] }; + const monitored = new Set(["org/monitored"]); + + it("returns true when surfacedBy includes user", () => { + expect(isUserInvolved({ ...base, surfacedBy: ["me"] }, "me", monitored)).toBe(true); + }); + + it("returns false when surfacedBy excludes user", () => { + expect(isUserInvolved({ ...base, surfacedBy: ["other"] }, "me", monitored)).toBe(false); + }); + + it("returns true for non-monitored item with no surfacedBy (fetched via involves:{user})", () => { + expect(isUserInvolved(base, "me", monitored)).toBe(true); + }); + + it("returns true for monitored repo item when user is author", () => { + expect(isUserInvolved({ ...base, repoFullName: "org/monitored", userLogin: "me" }, "me", monitored)).toBe(true); + }); + + it("returns true for monitored repo item when user is assignee", () => { + expect(isUserInvolved({ ...base, repoFullName: "org/monitored", assigneeLogins: ["me"] }, "me", monitored)).toBe(true); + }); + + it("returns false for monitored repo item when user is not author/assignee", () => { + expect(isUserInvolved({ ...base, repoFullName: "org/monitored" }, "me", monitored)).toBe(false); + }); + + it("returns true for monitored repo item when user is in reviewerLogins", () => { + expect(isUserInvolved({ ...base, repoFullName: "org/monitored" }, "me", monitored, ["me"])).toBe(true); + }); + + it("does not check reviewerLogins when not provided", () => { + expect(isUserInvolved({ ...base, repoFullName: "org/monitored" }, "me", monitored)).toBe(false); + }); +}); + describe("computePageLayout", () => { it("returns single page for empty groups", () => { const result = computePageLayout([], 10); From 624035b9f6293a0bdfdca989cb090bbc776bbc9d Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 18:16:02 -0400 Subject: [PATCH 14/14] feat(filters): adds mergeable reviewDecision composite value --- .../dashboard/PersonalSummaryStrip.tsx | 4 ++-- .../components/dashboard/PullRequestsTab.tsx | 7 ++++++- src/app/stores/view.ts | 2 +- .../dashboard/PersonalSummaryStrip.test.tsx | 6 +++++- .../dashboard/PullRequestsTab.test.tsx | 20 +++++++++++++++++++ 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index 6a6579d7..77576476 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -91,8 +91,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { // Counts are computed from unfiltered data (ignoring scope, globalFilter, showPrRuns). // Click filters set scope=all so tabs don't hide items the count included. // Known approximations (single-value filter system cannot express these): - // - "ready to merge": count requires reviewDecision=APPROVED||null, but filter - // can't express OR — PRs with CHANGES_REQUESTED + passing CI may appear + // - "ready to merge": uses composite reviewDecision=mergeable (APPROVED||null) // - "awaiting review": count excludes self-authored PRs (!isAuthor), but // role=reviewer filter includes them if user is both author+reviewer (rare) // - globalFilter (org/repo) is NOT applied here — counts are persistent @@ -130,6 +129,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { setTabFilter("pullRequests", "role", "author"); setTabFilter("pullRequests", "draft", "ready"); setTabFilter("pullRequests", "checkStatus", "success"); + setTabFilter("pullRequests", "reviewDecision", "mergeable"); }, }); if (prsBlocked > 0) items.push({ diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 500b10ec..51d3ca83 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -82,6 +82,7 @@ const prFilterGroups: FilterChipGroupDef[] = [ { value: "APPROVED", label: "Approved" }, { value: "CHANGES_REQUESTED", label: "Changes" }, { value: "REVIEW_REQUIRED", label: "Needs review" }, + { value: "mergeable", label: "Mergeable" }, ], }, { @@ -213,7 +214,11 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (!isEnriched && tabFilters.role === "author" && !roles.includes("author")) return false; } if (tabFilters.reviewDecision !== "all") { - if (pr.reviewDecision !== tabFilters.reviewDecision) return false; + if (tabFilters.reviewDecision === "mergeable") { + if (pr.reviewDecision !== "APPROVED" && pr.reviewDecision !== null) return false; + } else { + if (pr.reviewDecision !== tabFilters.reviewDecision) return false; + } } if (tabFilters.draft !== "all") { if (tabFilters.draft === "draft" && !pr.draft) return false; diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 666213ec..8e5406ca 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -16,7 +16,7 @@ const IssueFiltersSchema = z.object({ const PullRequestFiltersSchema = z.object({ scope: z.enum(["involves_me", "all"]).default("involves_me"), role: z.enum(["all", "author", "reviewer", "assignee"]).default("all"), - reviewDecision: z.enum(["all", "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]).default("all"), + reviewDecision: z.enum(["all", "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", "mergeable"]).default("all"), draft: z.enum(["all", "draft", "ready"]).default("all"), checkStatus: z.enum(["all", "success", "failure", "pending", "conflict", "blocked", "none"]).default("all"), sizeCategory: z.enum(["all", "XS", "S", "M", "L", "XL"]).default("all"), diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx index 54954cab..cd1078e8 100644 --- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -455,7 +455,7 @@ describe("PersonalSummaryStrip — click applies filters", () => { expect(viewState.tabFilters.pullRequests.reviewDecision).toBe("REVIEW_REQUIRED"); }); - it("clicking ready to merge sets scope=all, role=author, draft=ready, checkStatus=success", () => { + it("clicking ready to merge sets scope=all, role=author, draft=ready, checkStatus=success, reviewDecision=mergeable", () => { const onTabChange = vi.fn(); const prs = [makePullRequest({ userLogin: "me", draft: false, checkStatus: "success", reviewDecision: "APPROVED" })]; @@ -469,6 +469,7 @@ describe("PersonalSummaryStrip — click applies filters", () => { expect(viewState.tabFilters.pullRequests.role).toBe("author"); expect(viewState.tabFilters.pullRequests.draft).toBe("ready"); expect(viewState.tabFilters.pullRequests.checkStatus).toBe("success"); + expect(viewState.tabFilters.pullRequests.reviewDecision).toBe("mergeable"); }); it("clicking blocked sets scope=all, role=author, draft=ready, checkStatus=blocked", () => { @@ -507,6 +508,8 @@ describe("PersonalSummaryStrip — count-to-filter contract", () => { makePullRequest({ id: 4, title: "Review PR", repoFullName: "org/repo-c", userLogin: "other-author", draft: false, checkStatus: "pending", reviewDecision: "REVIEW_REQUIRED", surfacedBy: ["other-author"], enriched: true, reviewerLogins: ["me"] }), // PR authored by me, draft with failing CI → NOT blocked (draft excluded) makePullRequest({ id: 5, title: "Draft PR", repoFullName: "org/repo-a", userLogin: "me", draft: true, checkStatus: "failure", reviewDecision: null, surfacedBy: ["me"], enriched: true, reviewerLogins: [] }), + // PR authored by me, passing CI, but CHANGES_REQUESTED → NOT ready to merge + makePullRequest({ id: 7, title: "Changes Requested PR", repoFullName: "org/repo-a", userLogin: "me", draft: false, checkStatus: "success", reviewDecision: "CHANGES_REQUESTED", surfacedBy: ["me"], enriched: true, reviewerLogins: [] }), // PR from tracked user, user not involved → only visible in scope=all makePullRequest({ id: 6, title: "Tracked PR", repoFullName: "org/repo-d", userLogin: "tracked-user", draft: false, checkStatus: "success", reviewDecision: "APPROVED", surfacedBy: ["tracked-user"], enriched: true, reviewerLogins: [] }), ]; @@ -598,6 +601,7 @@ describe("PersonalSummaryStrip — count-to-filter contract", () => { screen.getByText("Ready PR"); expect(screen.queryByText("Conflict PR")).toBeNull(); expect(screen.queryByText("Failing PR")).toBeNull(); + expect(screen.queryByText("Changes Requested PR")).toBeNull(); }); it("'assigned' count matches IssuesTab filtered view", () => { diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index 1acd9760..c7243437 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -532,3 +532,23 @@ describe("PullRequestsTab — checkStatus=blocked filter", () => { expect(screen.queryByText("Passing PR")).toBeNull(); }); }); + +describe("PullRequestsTab — reviewDecision=mergeable filter", () => { + it("shows APPROVED and null-review PRs, excludes CHANGES_REQUESTED", () => { + const prs = [ + makePullRequest({ id: 1, title: "Approved PR", repoFullName: "org/repo", reviewDecision: "APPROVED", surfacedBy: ["me"], enriched: true }), + makePullRequest({ id: 2, title: "No Review PR", repoFullName: "org/repo", reviewDecision: null, surfacedBy: ["me"], enriched: true }), + makePullRequest({ id: 3, title: "Changes PR", repoFullName: "org/repo", reviewDecision: "CHANGES_REQUESTED", surfacedBy: ["me"], enriched: true }), + ]; + setTabFilter("pullRequests", "reviewDecision", "mergeable"); + setAllExpanded("pullRequests", ["org/repo"], true); + + render(() => ( + + )); + + screen.getByText("Approved PR"); + screen.getByText("No Review PR"); + expect(screen.queryByText("Changes PR")).toBeNull(); + }); +});