diff --git a/src/browser/components/app-shell.tsx b/src/browser/components/app-shell.tsx index dfd3f24..99ad8da 100644 --- a/src/browser/components/app-shell.tsx +++ b/src/browser/components/app-shell.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useParams, useNavigate } from "react-router-dom"; import { GithubIcon } from "../ui/github-icon"; import { @@ -34,6 +35,69 @@ import { } from "../ui/hover-card"; import { version } from "../../../package.json"; +// ============================================================================ +// Home Page Filter Matching (for triggering a refresh when a notification fires) +// ============================================================================ + +const ALL_REPOS_KEY = "__all_repos__"; +const ORG_FILTER_PREFIX = "__org__:"; +const FILTER_GROUPS_KEY = "pulldash_filter_groups"; +const STATE_FILTER_KEY = "pulldash_filter_state"; + +interface RepoFilter { + name: string; + mode: string; + authoredBy?: string; + enabled?: boolean; +} + +interface FilterGroup { + id: string; + name: string; + repos: RepoFilter[]; +} + +interface FilterGroupsStorage { + groups: FilterGroup[]; + selectedGroupId: string; +} + +function isRepoInHomeFilters( + owner: string, + repo: string, + prState?: string +): boolean { + try { + const stored = localStorage.getItem(FILTER_GROUPS_KEY); + if (!stored) return false; + const parsed = JSON.parse(stored) as FilterGroupsStorage; + const group = parsed.groups.find((g) => g.id === parsed.selectedGroupId); + if (!group) return false; + + const enabledRepos = group.repos.filter((r) => r.enabled !== false); + if (enabledRepos.length === 0) return false; + + // Check state filter — only when we know the PR's state + if (prState) { + const stateFilter = localStorage.getItem(STATE_FILTER_KEY) ?? "open"; + if (stateFilter === "open" && prState !== "open") return false; + if (stateFilter === "closed" && prState !== "closed") return false; + } + + const fullName = `${owner}/${repo}`; + + for (const filter of enabledRepos) { + if (filter.name === ALL_REPOS_KEY) return true; + if (filter.name.startsWith(ORG_FILTER_PREFIX)) return true; + if (filter.name === fullName) return true; + } + + return false; + } catch { + return false; + } +} + // ============================================================================ // App Shell - Tab-based Layout // ============================================================================ @@ -59,6 +123,7 @@ export function AppShell() { tab?: string; }>(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const githubStore = useGitHubStore(); // URL is the source of truth - sync URL → Tab @@ -244,6 +309,9 @@ export function AppShell() { `https://avatars.githubusercontent.com/${tab.owner}` ); setNotifiedAt(prId, enrichment.updatedAt); + if (isRepoInHomeFilters(tab.owner, tab.repo)) { + queryClient.invalidateQueries({ queryKey: ["pr-list"] }); + } } } } @@ -277,6 +345,9 @@ export function AppShell() { `https://avatars.githubusercontent.com/${owner}` ); setNotifiedAt(prId, enrichment.updatedAt); + if (isRepoInHomeFilters(owner, repo, pr.state)) { + queryClient.invalidateQueries({ queryKey: ["pr-list"] }); + } } } } catch { @@ -293,7 +364,14 @@ export function AppShell() { return () => { if (interval) clearInterval(interval); }; - }, [tabs, activeTab, githubStore, markTabUpdated, clearTabUpdated]); + }, [ + tabs, + activeTab, + githubStore, + markTabUpdated, + clearTabUpdated, + queryClient, + ]); // Clear tabs when user logs out useEffect(() => { diff --git a/src/browser/contexts/pr-review/index.tsx b/src/browser/contexts/pr-review/index.tsx index fc3be74..6cd724e 100644 --- a/src/browser/contexts/pr-review/index.tsx +++ b/src/browser/contexts/pr-review/index.tsx @@ -589,6 +589,16 @@ export class PRReviewStore { } } catch {} + // Load review session from localStorage (pending review node ID + SHA) + try { + const stored = localStorage.getItem(`${this.storageKey}-review-session`); + if (stored) { + const session = JSON.parse(stored); + this.pendingReviewNodeId = session.pendingReviewNodeId ?? null; + this.reviewSha = session.reviewSha ?? null; + } + } catch {} + // Sort files to match file tree order (folders first, then alphabetically) const sortedFiles = sortFilesLikeTree(initialState.files); this.baseFiles = sortedFiles; @@ -1134,6 +1144,7 @@ export class PRReviewStore { try { localStorage.removeItem(`${this.storageKey}-pending`); localStorage.removeItem(`${this.storageKey}-body`); + localStorage.removeItem(`${this.storageKey}-review-session`); } catch {} } @@ -3028,20 +3039,41 @@ export class PRReviewStore { ? { ...c, nodeId: commentNodeId, databaseId: commentDatabaseId } : c ); - // Also store the review node ID + // Also store the review node ID and persist session this.pendingReviewNodeId = reviewNodeId; + this.persistReviewSession(); this.persistPendingComments(pendingComments); this.set({ pendingComments }); }; - // Store the pending review node ID for submission + // Store the pending review node ID and SHA for submission private pendingReviewNodeId: string | null = null; + private reviewSha: string | null = null; getPendingReviewNodeId = () => this.pendingReviewNodeId; setPendingReviewNodeId = (id: string | null) => { this.pendingReviewNodeId = id; + this.persistReviewSession(); + }; + + getReviewSha = () => this.reviewSha; + setReviewSha = (sha: string | null) => { + this.reviewSha = sha; + this.persistReviewSession(); }; + private persistReviewSession() { + try { + localStorage.setItem( + `${this.storageKey}-review-session`, + JSON.stringify({ + pendingReviewNodeId: this.pendingReviewNodeId, + reviewSha: this.reviewSha, + }) + ); + } catch {} + } + updateComment = (commentId: number, updatedComment: ReviewComment) => { this.set({ comments: this.state.comments.map((c) => @@ -3104,6 +3136,8 @@ export class PRReviewStore { clearReviewState = () => { this.clearPendingState(); + this.pendingReviewNodeId = null; + this.reviewSha = null; this.set({ pendingComments: [], pendingReviewId: null, diff --git a/src/browser/contexts/pr-review/useCommentActions.ts b/src/browser/contexts/pr-review/useCommentActions.ts index c250940..2c47aa6 100644 --- a/src/browser/contexts/pr-review/useCommentActions.ts +++ b/src/browser/contexts/pr-review/useCommentActions.ts @@ -76,6 +76,8 @@ export function useCommentActions() { result.commentId, result.commentDatabaseId ); + // Store the commit SHA so the REST fallback uses the correct version + store.setReviewSha(pr.head.sha); } catch (error) { console.error("Failed to sync pending comment to GitHub:", error); } diff --git a/src/browser/contexts/pr-review/useReviewActions.ts b/src/browser/contexts/pr-review/useReviewActions.ts index d8b171a..8add619 100644 --- a/src/browser/contexts/pr-review/useReviewActions.ts +++ b/src/browser/contexts/pr-review/useReviewActions.ts @@ -25,39 +25,57 @@ export function useReviewActions() { try { // Get the pending review node ID (from GraphQL) const reviewNodeId = store.getPendingReviewNodeId(); + const reviewSha = store.getReviewSha() ?? pr.head.sha; + + // Track whether GraphQL submission succeeded + let submittedViaGraphQL = false; if (reviewNodeId) { - // Submit via GraphQL - we'll find the review ID after refreshing - await github.submitPendingReview(reviewNodeId, event, state.reviewBody); - } else if (state.pendingComments.length > 0) { - // Fallback: create a new review with all comments via REST - // Redirect :commit metadata comments to the first real file - const firstFile = state.files[0]?.filename; - newReview = await github.createPRReview(owner, repo, pr.number, { - commit_id: pr.head.sha, - event, - body: state.reviewBody, - comments: state.pendingComments.map( - ({ path, line, body, side, start_line }) => { - const isMetadata = path === ":commit" && firstFile; - return { - path: isMetadata ? firstFile : path, - line: isMetadata ? 1 : line, - body, - side: side as "LEFT" | "RIGHT", - start_line: isMetadata ? undefined : start_line, - }; - } - ), - }); - } else { - // Just submitting a review with no comments (APPROVE, etc) - newReview = await github.createPRReview(owner, repo, pr.number, { - commit_id: pr.head.sha, - event, - body: state.reviewBody, - comments: [], - }); + try { + // Submit via GraphQL - we'll find the review ID after refreshing + await github.submitPendingReview( + reviewNodeId, + event, + state.reviewBody + ); + submittedViaGraphQL = true; + } catch { + // GraphQL failed (e.g. pending review was already submitted). + // Fall through to REST fallback below. + } + } + + if (!submittedViaGraphQL) { + if (state.pendingComments.length > 0) { + // REST fallback: create a new review with all comments + // Redirect :commit metadata comments to the first real file + const firstFile = state.files[0]?.filename; + newReview = await github.createPRReview(owner, repo, pr.number, { + commit_id: reviewSha, + event, + body: state.reviewBody, + comments: state.pendingComments.map( + ({ path, line, body, side, start_line }) => { + const isMetadata = path === ":commit" && firstFile; + return { + path: isMetadata ? firstFile : path, + line: isMetadata ? 1 : line, + body, + side: side as "LEFT" | "RIGHT", + start_line: isMetadata ? undefined : start_line, + }; + } + ), + }); + } else { + // Just submitting a review with no comments (APPROVE, etc) + newReview = await github.createPRReview(owner, repo, pr.number, { + commit_id: reviewSha, + event, + body: state.reviewBody, + comments: [], + }); + } } github.invalidatePR(owner, repo, pr.number);