diff --git a/scripts/test-my-prs-section.ts b/scripts/test-my-prs-section.ts new file mode 100644 index 0000000..8bb4e13 --- /dev/null +++ b/scripts/test-my-prs-section.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import type { PullRequestSummary } from "../src/shared/domain/github-work.js"; +import { + authoredMyPullRequests, + filterMyPullRequests, + myPullRequestStatusCounts +} from "../src/renderer/features/github-work/my-pull-requests.js"; + +const basePr: PullRequestSummary = { + id: "base", + repoId: "repo", + number: 1, + title: "Base PR", + body: null, + authorLogin: "octocat", + assigneeLogins: [], + requestedReviewerLogins: [], + state: "open", + isDraft: false, + merged: false, + repoFullName: "octo/repo", + headSha: "head", + baseSha: "base", + baseBranch: "main", + headBranch: "feature", + additions: 1, + deletions: 0, + changedFiles: 1, + commitsCount: 1, + commentsCount: 0, + reviewCommentsCount: 0, + reviewState: null, + checkState: "unknown", + checkCount: 0, + labels: [], + htmlUrl: "https://github.com/octo/repo/pull/1", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + closedAt: null, + mergedAt: null, + lastSyncedAt: "2026-01-02T00:00:00Z" +}; + +const prs: PullRequestSummary[] = [ + pr({ id: "created", number: 2, authorLogin: "mona", updatedAt: "2026-01-05T00:00:00Z" }), + pr({ id: "assigned", number: 3, assigneeLogins: ["mona"], updatedAt: "2026-01-04T00:00:00Z" }), + pr({ id: "review", number: 4, requestedReviewerLogins: ["MONA"], updatedAt: "2026-01-06T00:00:00Z" }), + pr({ id: "mentioned", number: 5, body: "cc @mona", updatedAt: "2026-01-03T00:00:00Z" }), + pr({ id: "draft", number: 8, authorLogin: "mona", isDraft: true, updatedAt: "2026-01-06T12:00:00Z" }), + pr({ + id: "closed", + number: 6, + authorLogin: "mona", + state: "closed", + updatedAt: "2026-01-07T00:00:00Z", + closedAt: "2026-01-07T00:00:00Z" + }), + pr({ + id: "merged", + number: 7, + authorLogin: "mona", + state: "closed", + merged: true, + updatedAt: "2026-01-08T00:00:00Z", + mergedAt: "2026-01-08T00:00:00Z" + }) +]; + +const authored = authoredMyPullRequests(prs, "mona"); +const activeMine = filterMyPullRequests(prs, "mona", ""); +const closedMine = filterMyPullRequests(prs, "mona", "is:closed"); +const allMine = filterMyPullRequests(prs, "mona", "is:all"); +const repoQualifiedMine = filterMyPullRequests(prs, "mona", "repo:octo/repo type:pr"); +const counts = myPullRequestStatusCounts(authored); + +assert.deepEqual( + activeMine.map((item) => item.number), + [8, 2] +); +assert.deepEqual( + closedMine.map((item) => item.number), + [6] +); +assert.deepEqual( + allMine.map((item) => item.number), + [6, 8, 2] +); +assert.deepEqual( + repoQualifiedMine.map((item) => item.number), + [8, 2] +); +assert.equal(counts.open, 1); +assert.equal(counts.draft, 1); +assert.equal(counts.closed, 1); + +console.log("My PRs section tests ok"); + +function pr(overrides: Partial): PullRequestSummary { + return { ...basePr, ...overrides }; +} diff --git a/src/renderer/features/github-work/MyPullRequestRow.tsx b/src/renderer/features/github-work/MyPullRequestRow.tsx new file mode 100644 index 0000000..7267e37 --- /dev/null +++ b/src/renderer/features/github-work/MyPullRequestRow.tsx @@ -0,0 +1,87 @@ +import { GitPullRequestIcon as GitHubPullRequestIcon } from "@primer/octicons-react"; +import type { PullRequestSummary } from "../../../shared/domain/github-work"; +import { compactCount, formatRelative } from "../../lib/format"; + +export function MyPullRequestRow({ pr, onClick }: { pr: PullRequestSummary; onClick: () => void }) { + const status = myPullRequestStatus(pr); + return ( +
+ + + +
+ +
+
+ +
+
+ ); +} + +function myPullRequestPreview(pr: PullRequestSummary): string { + const stats = [ + pr.checkState === "failing" + ? "Checks failing" + : pr.checkState === "pending" + ? "Checks pending" + : pr.checkState === "passing" + ? "Checks passing" + : null, + pr.reviewState ? reviewStateLabel(pr.reviewState) : null, + pr.changedFiles != null ? `${compactCount(pr.changedFiles)} changed files` : null + ].filter((item): item is string => Boolean(item)); + return stats.length > 0 ? stats.join(" - ") : "No review or check signal cached"; +} + +function myPullRequestDetail(pr: PullRequestSummary): string { + const parts = [ + pr.headBranch && pr.baseBranch ? `${pr.headBranch} into ${pr.baseBranch}` : pr.headBranch || pr.baseBranch, + pr.commentsCount != null || pr.reviewCommentsCount != null + ? `${compactCount((pr.commentsCount ?? 0) + (pr.reviewCommentsCount ?? 0))} comments` + : null + ].filter((item): item is string => Boolean(item)); + return parts.length > 0 ? parts.join(" - ") : "Authored by you"; +} + +function reviewStateLabel(value: string): string { + if (value === "changes requested") return "Changes requested"; + if (value === "approved") return "Approved"; + if (value === "reviewed") return "Reviewed"; + return value; +} + +function myPullRequestStatus(pr: PullRequestSummary): { label: string; className: string } { + if (pr.merged) return { label: "Merged", className: "bg-purple-500/10 text-purple-300" }; + if (pr.state === "closed") return { label: "Closed", className: "bg-red-500/10 text-red-300" }; + if (pr.isDraft) return { label: "Draft", className: "bg-white/[0.06] text-neutral-400" }; + if (pr.checkState === "failing") return { label: "Checks failing", className: "bg-red-500/10 text-red-300" }; + if (pr.checkState === "pending") return { label: "Checks pending", className: "bg-amber-500/10 text-amber-300" }; + return { label: "Open", className: "bg-green-500/10 text-green-300" }; +} diff --git a/src/renderer/features/github-work/MyWorkView.tsx b/src/renderer/features/github-work/MyWorkView.tsx index dddf5cc..d7858c8 100644 --- a/src/renderer/features/github-work/MyWorkView.tsx +++ b/src/renderer/features/github-work/MyWorkView.tsx @@ -5,7 +5,7 @@ import { GitPullRequestIcon as GitHubPullRequestIcon, IssueOpenedIcon as GitHubIssueOpenedIcon } from "@primer/octicons-react"; -import { Archive, BellOff, ChevronDown, ChevronRight, Clock, Copy, ExternalLink, MoreHorizontal } from "lucide-react"; +import { Archive, BellOff, ChevronDown, ChevronRight, Clock, Copy, ExternalLink, MoreHorizontal, RefreshCw } from "lucide-react"; import type { AuthState } from "../../../shared/domain/auth"; import type { AttentionItem, AttentionLane } from "../../../shared/attention"; import { myWorkLaneCopy, myWorkLaneOrder } from "../../../shared/product-coherence"; @@ -22,12 +22,15 @@ import { type EntityQueryKind, type WorkPriorityGroupId } from "./work-query-language"; -import { MyWorkSearchInput } from "./EntitySearchInput"; +import { MyWorkSearchInput, SimpleSearchInput } from "./EntitySearchInput"; +import { MyPullRequestRow } from "./MyPullRequestRow"; +import { authoredMyPullRequests, filterMyPullRequests, myPullRequestStatusCounts } from "./my-pull-requests"; const ENTITY_PAGE_SIZE = 50; const ENTITY_LIST_STALE_TIME_MS = 5 * 60_000; const ENTITY_LIST_GC_TIME_MS = 30 * 60_000; const myWorkLanes: Array<{ id: AttentionLane; label: string }> = myWorkLaneOrder.map((id) => ({ id, label: myWorkLaneCopy[id].label })); +type MyWorkTabId = AttentionLane | "my_prs"; function WorkItemTypeIcon({ kind, state = "open" }: { kind: EntityQueryKind; state?: string }) { const iconClass = kind === "pr" ? "text-purple-900" : state === "closed" ? "text-red-900" : "text-green-900"; @@ -54,6 +57,8 @@ export function MyWorkView({ const [query, setQuery] = useState(""); const lane = useAppPreferencesStore((state) => state.myWorkLane); const setLane = useAppPreferencesStore((state) => state.setMyWorkLane); + const [activeTab, setActiveTab] = useState("my_prs"); + const [userSelectedTab, setUserSelectedTab] = useState(false); const [page, setPage] = useState(1); const [collapsedGroups, setCollapsedGroups] = useState>>({ low_priority: true }); const queryClient = useQueryClient(); @@ -67,6 +72,14 @@ export function MyWorkView({ gcTime: ENTITY_LIST_GC_TIME_MS, refetchOnWindowFocus: false }); + const { data: myPrs = [], isFetching: myPrsFetching } = useQuery({ + queryKey: ["myPrs", authAccountKey], + queryFn: window.fallback.prs.listMine, + enabled: auth.status === "connected", + staleTime: ENTITY_LIST_STALE_TIME_MS, + gcTime: ENTITY_LIST_GC_TIME_MS, + refetchOnWindowFocus: false + }); const counts = useMemo( () => myWorkLanes.map((item) => @@ -98,22 +111,51 @@ export function MyWorkView({ mutationFn: (id: string) => window.fallback.notifications.unmute(id), onSuccess: () => invalidateAttentionQueries(queryClient) }); + const refreshMyPrs = useMutation({ + mutationFn: window.fallback.prs.refreshMine, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["myPrs"] }), + queryClient.invalidateQueries({ queryKey: ["myWorkAttention"] }) + ]); + } + }); + const login = auth.status === "connected" ? auth.login : undefined; + const authoredMyPrs = useMemo(() => authoredMyPullRequests(myPrs, login), [login, myPrs]); + const activeAuthoredMyPrs = useMemo(() => filterMyPullRequests(myPrs, login, ""), [login, myPrs]); + const myPrStatusCounts = useMemo(() => myPullRequestStatusCounts(authoredMyPrs), [authoredMyPrs]); const visible = useMemo(() => { const parsed = parseWorkQuery(query); const filtered = allItems.filter((item) => matchesWorkQuery(item, parsed)); return [...filtered].sort(compareWorkItems); }, [allItems, query]); - const pageRows = useMemo(() => paginateItems(visible, page, ENTITY_PAGE_SIZE), [page, visible]); - const groupedPageRows = useMemo(() => groupWorkRows(pageRows), [pageRows]); - const loading = isFetching && allItems.length === 0; - const needsMeCount = counts[0]?.length ?? 0; + const visibleMyPrs = useMemo(() => filterMyPullRequests(myPrs, login, query), [login, myPrs, query]); + const laneCounts = useMemo(() => counts.map((items) => items.length), [counts]); + const workPageRows = useMemo(() => paginateItems(visible, page, ENTITY_PAGE_SIZE), [page, visible]); + const prPageRows = useMemo(() => paginateItems(visibleMyPrs, page, ENTITY_PAGE_SIZE), [page, visibleMyPrs]); + const groupedPageRows = useMemo(() => groupWorkRows(workPageRows), [workPageRows]); + const loading = activeTab === "my_prs" ? myPrsFetching && authoredMyPrs.length === 0 : isFetching && allItems.length === 0; + const needsMeCount = laneCounts[0] ?? 0; const laneEmptyCopy = myWorkLaneCopy[lane]; const canCollapseGroups = query.trim() === ""; + const activeItemCount = activeTab === "my_prs" ? visibleMyPrs.length : visible.length; - useEffect(() => setPage(1), [auth.status, authAccountKey, lane, query]); + useEffect(() => setPage(1), [activeTab, auth.status, authAccountKey, lane, query]); + useEffect(() => { + if (userSelectedTab || auth.status !== "connected" || myPrsFetching) return; + if (activeAuthoredMyPrs.length > 0) { + setActiveTab("my_prs"); + return; + } + setLane("needs_me"); + setActiveTab("needs_me"); + }, [activeAuthoredMyPrs.length, auth.status, myPrsFetching, setLane, userSelectedTab]); + useEffect(() => { + if (activeTab !== "my_prs") setActiveTab(lane); + }, [activeTab, lane]); useEffect(() => { - setPage((current) => Math.min(current, pageCountFor(visible.length, ENTITY_PAGE_SIZE))); - }, [visible.length]); + setPage((current) => Math.min(current, pageCountFor(activeItemCount, ENTITY_PAGE_SIZE))); + }, [activeItemCount]); return (
@@ -126,94 +168,165 @@ export function MyWorkView({

- {loading ? "Loading..." : `${compactCount(visible.length)} items`} + {loading ? "Loading..." : `${compactCount(activeItemCount)} items`}
+ {myWorkLanes.map((item, index) => ( ))}
- + {activeTab === "my_prs" ? ( + + ) : ( + + )}
- {auth.status !== "connected" && visible.length === 0 && ( -
Connect GitHub to build your work queue.
- )} - {auth.status === "connected" && allItems.length === 0 && !loading && ( -
-
{laneEmptyCopy.emptyTitle}
-
{laneEmptyCopy.emptyDetail}
-
- )} - {allItems.length > 0 && visible.length === 0 && ( -
No work items match this search.
- )} - {groupedPageRows.map(({ group, rows }) => ( - - +
+ {auth.status !== "connected" && ( +
Connect GitHub to see your PRs.
+ )} + {auth.status === "connected" && authoredMyPrs.length === 0 && !myPrsFetching && ( +
No authored PRs are cached yet.
+ )} + {authoredMyPrs.length > 0 && visibleMyPrs.length === 0 && ( +
No authored PRs match this search.
+ )} + {prPageRows.map((pr) => ( + onPrClick({ repoId: pr.repoId, number: pr.number })} /> + ))} + + ) : ( + <> + {auth.status !== "connected" && visible.length === 0 && ( +
Connect GitHub to build your work queue.
+ )} + {auth.status === "connected" && allItems.length === 0 && !loading && ( +
+
{laneEmptyCopy.emptyTitle}
+
{laneEmptyCopy.emptyDetail}
- - {(!collapsedGroups[group.id] || !canCollapseGroups) && - rows.map((item) => ( - - item.entityType === "pull_request" && item.number - ? onPrClick({ repoId: item.repoId, number: item.number }) - : item.entityType === "issue" && item.number - ? onIssueClick({ repoId: item.repoId, number: item.number }) - : item.htmlUrl - ? void window.fallback.shell.openExternal(item.htmlUrl) - : undefined - } - onDone={() => (item.doneAt ? undoDone.mutate(item.id) : markDone.mutate(item.id))} - onSnoozeUntil={(until) => - item.snoozedUntil ? unsnooze.mutate(item.id) : snooze.mutate({ id: item.id, until: until ?? tomorrowIso() }) - } - onMute={() => (item.muted ? unmute.mutate(item.id) : mute.mutate(item.id))} - /> - ))} - - ))} - + )} + {allItems.length > 0 && visible.length === 0 && ( +
No work items match this search.
+ )} + {groupedPageRows.map(({ group, rows }) => ( + + + {(!collapsedGroups[group.id] || !canCollapseGroups) && + rows.map((item) => ( + + item.entityType === "pull_request" && item.number + ? onPrClick({ repoId: item.repoId, number: item.number }) + : item.entityType === "issue" && item.number + ? onIssueClick({ repoId: item.repoId, number: item.number }) + : item.htmlUrl + ? void window.fallback.shell.openExternal(item.htmlUrl) + : undefined + } + onDone={() => (item.doneAt ? undoDone.mutate(item.id) : markDone.mutate(item.id))} + onSnoozeUntil={(until) => + item.snoozedUntil ? unsnooze.mutate(item.id) : snooze.mutate({ id: item.id, until: until ?? tomorrowIso() }) + } + onMute={() => (item.muted ? unmute.mutate(item.id) : mute.mutate(item.id))} + /> + ))} + + ))} + + )} + diff --git a/src/renderer/features/github-work/my-pull-requests.ts b/src/renderer/features/github-work/my-pull-requests.ts new file mode 100644 index 0000000..54541d9 --- /dev/null +++ b/src/renderer/features/github-work/my-pull-requests.ts @@ -0,0 +1,38 @@ +import type { PullRequestSummary } from "../../../shared/domain/github-work"; +import { filterPullRequests, queryWithDefaultOpen } from "./entity-query"; + +export function authoredMyPullRequests(pullRequests: PullRequestSummary[], login?: string): PullRequestSummary[] { + return pullRequests.filter((pr) => sameLogin(pr.authorLogin, login) && !pr.merged).sort(byUpdatedDesc); +} + +export function filterMyPullRequests(pullRequests: PullRequestSummary[], login: string | undefined, query: string): PullRequestSummary[] { + return filterPullRequests(authoredMyPullRequests(pullRequests, login), queryWithDefaultOpen(query), login); +} + +export function myPullRequestStatusCounts(pullRequests: PullRequestSummary[]): { + open: number; + draft: number; + closed: number; +} { + return pullRequests.reduce( + (counts, pr) => { + if (pr.state === "closed") counts.closed += 1; + else if (pr.isDraft) counts.draft += 1; + else counts.open += 1; + return counts; + }, + { open: 0, draft: 0, closed: 0 } + ); +} + +function sameLogin(value: string | null | undefined, login?: string): boolean { + return Boolean(value && login && value.toLowerCase() === login.toLowerCase()); +} + +function byUpdatedDesc(a: { updatedAt: string | null }, b: { updatedAt: string | null }): number { + return timestamp(b.updatedAt) - timestamp(a.updatedAt); +} + +function timestamp(value: string | null): number { + return value ? Date.parse(value) : 0; +} diff --git a/tests/github-work/suite.ts b/tests/github-work/suite.ts index d7eca2b..09f8d8b 100644 --- a/tests/github-work/suite.ts +++ b/tests/github-work/suite.ts @@ -4,6 +4,7 @@ await runScriptTests([ "scripts/test-github-work-cache-persistence.ts", "scripts/test-entity-query-filters.ts", "scripts/test-entity-filter-suggestions.ts", + "scripts/test-my-prs-section.ts", "scripts/test-saved-entity-searches.ts", "scripts/test-offline-actions.ts", "scripts/test-pr-review-drafts.ts",