From 809455f4638f986ac034079e4100a0cf3bbde6b4 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 16 Sep 2026 16:01:15 -0400 Subject: [PATCH] feat(search): add saved pull request views - Add editable GitHub searches with relative dates and pagination - Reuse PR actions and validate query and cache behavior --- CHANGELOG.md | 6 + CLAUDE.md | 6 + README.md | 20 ++- client/board/board-dialogs.tsx | 72 ++++++++ client/board/board-header.tsx | 6 +- client/board/github-board.tsx | 112 +++++------- client/board/item-row-format.ts | 28 ++- client/board/item-row.tsx | 5 + client/board/label-menu.tsx | 7 +- client/board/use-board-filters.tsx | 51 ++++-- client/detail/use-item-actions.tsx | 9 +- client/lib/board-modes.ts | 1 + client/lib/search-pages.test.ts | 41 +++++ client/lib/search-pages.ts | 14 ++ client/search/results-toolbar.tsx | 75 ++++++++ client/search/saved-views.tsx | 249 +++++++++++++++++++++++++++ client/search/use-search-results.tsx | 42 +++++ client/search/view-editor.tsx | 87 ++++++++++ index.server.ts | 4 + server/board/handler.ts | 2 +- server/board/pull-requests.ts | 10 +- server/cache/cache.test.ts | 21 +++ server/cache/cache.ts | 15 +- server/items/labels.ts | 2 + server/items/review.ts | 3 + server/search/cache.ts | 4 + server/search/fetch.test.ts | 86 +++++++++ server/search/fetch.ts | 65 +++++++ server/search/handler.ts | 42 +++++ shared/board.ts | 5 +- shared/saved-views.test.ts | 35 ++++ shared/saved-views.ts | 63 +++++++ shared/search-query.test.ts | 49 ++++++ shared/search-query.ts | 36 ++++ shared/settings.ts | 1 + 35 files changed, 1173 insertions(+), 101 deletions(-) create mode 100644 client/board/board-dialogs.tsx create mode 100644 client/lib/search-pages.test.ts create mode 100644 client/lib/search-pages.ts create mode 100644 client/search/results-toolbar.tsx create mode 100644 client/search/saved-views.tsx create mode 100644 client/search/use-search-results.tsx create mode 100644 client/search/view-editor.tsx create mode 100644 server/search/cache.ts create mode 100644 server/search/fetch.test.ts create mode 100644 server/search/fetch.ts create mode 100644 server/search/handler.ts create mode 100644 shared/saved-views.test.ts create mode 100644 shared/saved-views.ts create mode 100644 shared/search-query.test.ts create mode 100644 shared/search-query.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 73eca1d..13f3a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ pin and a line to read before you move. ## [Unreleased] +### Added + +- Named pull request search views with editable GitHub queries, Boolean operators, UTC relative + dates, preview, and pagination. Saved views preserve GitHub ordering and support PRs beyond the + viewer's personal relationships, including closed and merged work. + ## [1.0.1] — 2026-09-12 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 828e6e5..73ba463 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -565,3 +565,9 @@ what let a *cold* client — a fresh reload, a different device, a daemon restar calls too. `force` on `board.load` is still the Refresh button asking both layers to bypass themselves at once. Whatever should outlive an unmount and is not shaped like a cache belongs in one of the two settings stores instead — that is the only other thing here that survives one. + +## Saved pull request searches + +`shared/saved-views.ts` defines a separate host settings document and the `search.pull-requests` RPC. `client/search/` owns the view editor, selection, and paginated query state. `server/search/` runs an independent `ISSUE_ADVANCED` search and reuses PR mapping/checks from `server/board/`; do not pass its results through the relationship bucket merge or local board filters, since both alter query semantics and ordering. Search hits can have an empty `relations` array. + +`shared/search-query.ts` scopes the whole Boolean expression to PRs and resolves unquoted relative date qualifiers. The raw query is persisted; the effective UTC date travels with subsequent page cursors. `client/lib/search-pages.ts` deduplicates overlapping pages without sorting. Search cache keys include authenticated identity, resolved query, and cursor. Label/review/merge handlers clear the search cache, and their client action hooks reset loaded search pages. `Cache.clear()` also prevents an in-flight pre-mutation answer from repopulating the cache. diff --git a/README.md b/README.md index a627008..6f7c3ed 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,8 @@ inputs.paseo-github.url = "github:alysnnix/paseo-github-integration"; itself and cannot log you in. - **Projects needs a scope `gh auth login` does not grant.** Without `read:project` the Projects tab shows the command to run instead of your boards. -- **The board is a `state:open` search.** Closed issues and merged pull requests leave it; there is - no archive view, and nothing here searches history. +- **The ordinary board is a `state:open` search.** Closed issues and merged pull requests leave it; + saved pull request views can search closed and merged work. - **GitHub search backs the sweep**, so its rules apply: results are capped per query, and a watched owner with thousands of open items shows the most recently updated slice rather than all of them. @@ -131,3 +131,19 @@ and the other plugins that lived beside it there are not carried here. Thanks to starting point and for the MIT licence that made it possible. Licensed under the MIT licence — see [LICENSE](LICENSE). + +## Saved pull request views + +Open **Saved views** in the GitHub surface, choose **New view**, and enter a name and a GitHub search query. **Preview** runs the query without saving; **Save** keeps the view on this host for all its connected clients. Views can be renamed, edited, and deleted, and the selected view is remembered across workspace switches. + +For example: + +```text +(org:example OR org:sample) is:open draft:false review:required created:>@today-30d sort:updated-desc +``` + +Every saved view searches pull requests only. The query controls owner/repository scope, state, draft status, and ordering; the ordinary board's relationship and repository filters do not apply. An item need not have appeared on the ordinary board to appear in a saved view. Closed and merged PRs are supported when the query includes them. + +Date qualifiers (`created:`, `updated:`, `closed:`, and `merged:`) accept `@today` and `@today-Nd`. The daemon expands them using the current **UTC calendar date**, preserving comparisons and ranges such as `updated:@today-7d..@today`. Quoted text is left literal. The saved query remains relative; the resolved query is shown above the results and used by **Open search on GitHub**. + +**Refresh results** reloads the first page. **Load more** follows GitHub's cursor while keeping the same effective date. Results are cached for five minutes, and local label, review, and merge actions invalidate searches because they can change query membership. GitHub may take time to update its search index after a change. GitHub exposes at most 1,000 matches; narrow the query if you reach that ceiling. Saved views use advanced GraphQL search and show GitHub errors when that search is unavailable or the query is rejected. diff --git a/client/board/board-dialogs.tsx b/client/board/board-dialogs.tsx new file mode 100644 index 0000000..024248d --- /dev/null +++ b/client/board/board-dialogs.tsx @@ -0,0 +1,72 @@ +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { Pressable, View } from "react-native"; +import { SendDialog } from "../launch/send-dialog"; +import { LabelMenu } from "./label-menu"; +import type { Styles } from "../theme/use-styles"; +import type { UseBoardOverlaysResult } from "./use-board-overlays"; + +export function BoardDialogs({ + props, + styles, + overlays, +}: { + props: PluginSurfaceProps; + styles: Styles; + overlays: Pick< + UseBoardOverlaysResult, + | "labelTarget" + | "setLabelTarget" + | "applyItemLabels" + | "sendTarget" + | "setSendTarget" + | "handleLaunched" + >; +}) { + const { + labelTarget, + setLabelTarget, + applyItemLabels, + sendTarget, + setSendTarget, + handleLaunched, + } = overlays; + return ( + <> + {labelTarget !== null ? ( + + setLabelTarget(null)} + /> + setLabelTarget(null)} + onChanged={applyItemLabels} + /> + + ) : null} + + {sendTarget !== null ? ( + setSendTarget(null)} + onLaunched={handleLaunched} + /> + ) : null} + + ); +} diff --git a/client/board/board-header.tsx b/client/board/board-header.tsx index ef3fa06..1f1d3c0 100644 --- a/client/board/board-header.tsx +++ b/client/board/board-header.tsx @@ -23,7 +23,9 @@ export function BoardHeader({ busy, refresh, setOpenFilter, + showBoardStatus = true, }: { + showBoardStatus?: boolean; surfaceProps: PluginSurfaceProps; styles: Styles; showSettings: boolean; @@ -52,7 +54,7 @@ export function BoardHeader({ ) : ( <> - {board !== null ? ( + {showBoardStatus && board !== null ? ( Updated {relativeTime(board.fetchedAt)} @@ -71,7 +73,7 @@ export function BoardHeader({ {/* Compact refreshes by pulling the list down, so the button would be a second way to do the same thing in the row with the least room for one. */} - {surfaceProps.layout.compact ? null : ( + {surfaceProps.layout.compact || !showBoardStatus ? null : ( )} - {showSettings || mode === "projects" ? null : ( + {showSettings || mode === "projects" || mode === "saved-views" ? null : ( ) : null} - {error !== null ? ( + {error !== null && mode !== "saved-views" ? ( {error} ) : null} - - - {labelTarget !== null ? ( - - setLabelTarget(null)} - /> - setLabelTarget(null)} - onChanged={applyItemLabels} - /> - - ) : null} - - {sendTarget !== null ? ( - + ) : ( + setSendTarget(null)} - onLaunched={handleLaunched} + showSettings={showSettings} + promptValues={promptValues} + loginDraft={loginDraft} + busy={busy} + applyPrompts={applyPrompts} + applyLogin={applyLogin} + board={board} + mode={mode} + watchedOwners={watchedOwners} + displayRows={displayRows} + renderRow={renderRow} + modeRows={modeRows} + refresh={refresh} + bodyWidth={bodyWidth} + setBodyWidth={setBodyWidth} + detailTarget={detailTarget} + detailItem={detailItem} + detailProgress={detailProgress} + closeDetails={closeDetails} + savedFraction={savedFraction} + commitWidth={commitWidth} + openSendDialog={openSendDialog} + dropItem={dropItem} /> - ) : null} + )} + + ); } diff --git a/client/board/item-row-format.ts b/client/board/item-row-format.ts index fbaa594..7f068e5 100644 --- a/client/board/item-row-format.ts +++ b/client/board/item-row-format.ts @@ -64,13 +64,17 @@ export function describeRow({ const stampDate = stampMissing ? item.updatedAt : stamp; const iconName = - type === "draft-prs" - ? "GitPullRequestDraft" - : type === "open-prs" - ? "GitPullRequest" - : type === "discussions" - ? "MessageSquare" - : "CircleDot"; + item.prState === "MERGED" + ? "GitMerge" + : item.prState === "CLOSED" + ? "GitPullRequestClosed" + : type === "draft-prs" + ? "GitPullRequestDraft" + : type === "open-prs" + ? "GitPullRequest" + : type === "discussions" + ? "MessageSquare" + : "CircleDot"; const iconColor = type === "draft-prs" || type === "discussions" ? mutedColor : accentColor; const byline = item.author !== null && item.author !== viewerLogin ? item.author : null; @@ -85,5 +89,13 @@ export function describeRow({ accessibilityHint = isWeb ? "Right-click to edit labels." : "Press and hold to edit labels."; } - return { stampLabel, stampDate, iconName, iconColor, byline, accessibilityLabel, accessibilityHint }; + return { + stampLabel, + stampDate, + iconName, + iconColor, + byline, + accessibilityLabel, + accessibilityHint, + }; } diff --git a/client/board/item-row.tsx b/client/board/item-row.tsx index b365fa2..dba69cb 100644 --- a/client/board/item-row.tsx +++ b/client/board/item-row.tsx @@ -191,6 +191,11 @@ export const ItemRow = memo(function ItemRow({ {item.title} + {item.prState === "CLOSED" || item.prState === "MERGED" ? ( + + {item.prState === "MERGED" ? "Merged" : "Closed"} + + ) : null} {type === "draft-prs" ? Draft : null} diff --git a/client/board/label-menu.tsx b/client/board/label-menu.tsx index 2841ac9..9c12299 100644 --- a/client/board/label-menu.tsx +++ b/client/board/label-menu.tsx @@ -1,3 +1,4 @@ +import { SEARCH_KEY } from "../lib/search-pages"; import { useCallback, useMemo, useState } from "react"; import { ActivityIndicator, @@ -8,7 +9,7 @@ import { View, } from "react-native"; import { useRpc } from "@getpaseo/plugin/client"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { BoardItem, RepositoryLabel } from "../../shared/board"; import { listLabels, toggleLabel } from "../../shared/board"; import type { Styles } from "../theme/use-styles"; @@ -58,6 +59,7 @@ export function LabelMenu({ const { item } = target; const list = useRpc(listLabels); const apply = useRpc(toggleLabel); + const queryClient = useQueryClient(); /** * Each repository's label catalogue, keyed by `owner/name`. A label set @@ -96,6 +98,7 @@ export function LabelMenu({ apply({ itemId: item.id, labelId: label.id, add }) .then((result) => { setApplied(new Set(result.labels)); + void queryClient.resetQueries({ queryKey: [SEARCH_KEY] }); onChanged(item.id, result.labels); }) .catch((cause: unknown) => { @@ -109,7 +112,7 @@ export function LabelMenu({ }); }); }, - [applied, apply, item.id, onChanged, pending], + [applied, apply, item.id, onChanged, pending, queryClient], ); const shown = useMemo(() => { diff --git a/client/board/use-board-filters.tsx b/client/board/use-board-filters.tsx index dd11406..c9d0183 100644 --- a/client/board/use-board-filters.tsx +++ b/client/board/use-board-filters.tsx @@ -1,7 +1,15 @@ -import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type Dispatch, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import type { Board } from "../../shared/board"; -import type { BoardMode } from "../lib/board-modes"; +import { BOARD_MODES, type BoardMode } from "../lib/board-modes"; import { isRelation, RELATION_FILTERS } from "../lib/relations"; import { matchesSearchTerms, parseSearchQuery } from "../lib/search"; import { @@ -78,12 +86,17 @@ export function useBoardFilters( /** The last ordering picked, hydrated from and persisted to `displaySettings`. */ const [sortOrder, setSortOrder] = useState("updated"); const [searchQuery, setSearchQuery] = useState(""); - /** - * Which of the four modes fills the body. Not persisted — the user picks it - * fresh every time the surface mounts, the way a browser tab does. - */ + /** The selected mode survives workspace switches along with the filters. */ const [mode, setMode] = useState("pull-requests"); + const savedMode = display.status === "ready" ? display.values.mode : null; + const modeHydrated = useRef(false); + useEffect(() => { + if (modeHydrated.current || savedMode === null) return; + modeHydrated.current = true; + setMode(BOARD_MODES.find((entry) => entry.id === savedMode)?.id ?? "pull-requests"); + }, [savedMode]); + /** * The saved filter is adopted once, when the settings read first lands. Later * pushes must not overwrite what the user is toggling right now — and the @@ -203,7 +216,8 @@ export function useBoardFilters( } if (mode === "discussions") { return { - rows: discussionsColumn?.items.map((item) => ({ item, type: "discussions" as const })) ?? [], + rows: + discussionsColumn?.items.map((item) => ({ item, type: "discussions" as const })) ?? [], error: discussionsColumn?.error ?? null, }; } @@ -302,7 +316,15 @@ export function useBoardFilters( return [...searchedRows].sort((a, b) => compareBySortDate(activeOrder, a, b)); }, [searchedRows, activeOrder]); - const selectColumnMode = useCallback((id: BoardMode) => setMode(id), []); + const selectColumnMode = useCallback( + (id: BoardMode) => { + setMode(id); + setOpenFilter(null); + if (display.status === "ready") + void display.save({ ...display.values, mode: id }, display.revision); + }, + [display], + ); /** * Applies a selection locally and saves it, so it survives the next unmount. @@ -316,7 +338,10 @@ export function useBoardFilters( void display .save({ ...display.values, hiddenRepositories: [...next].sort() }, display.revision) .then((saved) => { - if (!saved) console.warn(`[github-board] repository filter could not be saved: ${display.saveError ?? ""}`); + if (!saved) + console.warn( + `[github-board] repository filter could not be saved: ${display.saveError ?? ""}`, + ); }); }, [display], @@ -327,7 +352,10 @@ export function useBoardFilters( setRelationFilter(next); if (display.status !== "ready") return; void display.save({ ...display.values, relation: next }, display.revision).then((saved) => { - if (!saved) console.warn(`[github-board] relation filter could not be saved: ${display.saveError ?? ""}`); + if (!saved) + console.warn( + `[github-board] relation filter could not be saved: ${display.saveError ?? ""}`, + ); }); }, [display], @@ -338,7 +366,8 @@ export function useBoardFilters( setSortOrder(next); if (display.status !== "ready") return; void display.save({ ...display.values, sort: next }, display.revision).then((saved) => { - if (!saved) console.warn(`[github-board] sort order could not be saved: ${display.saveError ?? ""}`); + if (!saved) + console.warn(`[github-board] sort order could not be saved: ${display.saveError ?? ""}`); }); }, [display], diff --git a/client/detail/use-item-actions.tsx b/client/detail/use-item-actions.tsx index 733f1cb..7cd2500 100644 --- a/client/detail/use-item-actions.tsx +++ b/client/detail/use-item-actions.tsx @@ -1,3 +1,5 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { SEARCH_KEY } from "../lib/search-pages"; import { useCallback, useState } from "react"; import { useToast } from "@getpaseo/plugin/client/react-native"; import { useRpc } from "@getpaseo/plugin/client"; @@ -30,6 +32,7 @@ export function useItemActions({ onDetailsChanged: (next: ItemDetails) => void; onMerged: (itemId: string) => void; }): UseItemActionsResult { + const queryClient = useQueryClient(); const approve = useRpc(approvePullRequest); const merge = useRpc(mergePullRequest); const toast = useToast(); @@ -40,6 +43,7 @@ export function useItemActions({ setActing(true); approve({ id: item.id, body: "" }) .then((next) => { + void queryClient.resetQueries({ queryKey: [SEARCH_KEY] }); onDetailsChanged(next); toast.show(`Approved ${item.repository} #${item.number}.`, { variant: "success" }); }) @@ -47,13 +51,14 @@ export function useItemActions({ toast.error(cause instanceof Error ? cause.message : String(cause)); }) .finally(() => setActing(false)); - }, [approve, item.id, item.number, item.repository, onDetailsChanged, toast]); + }, [approve, item.id, item.number, item.repository, onDetailsChanged, toast, queryClient]); const runMerge = useCallback( (method: MergeMethod) => { setActing(true); merge({ id: item.id, method }) .then((next) => { + void queryClient.resetQueries({ queryKey: [SEARCH_KEY] }); onDetailsChanged(next); setMergeOpen(false); onMerged(item.id); @@ -64,7 +69,7 @@ export function useItemActions({ }) .finally(() => setActing(false)); }, - [item.id, item.number, item.repository, merge, onDetailsChanged, onMerged, toast], + [item.id, item.number, item.repository, merge, onDetailsChanged, onMerged, toast, queryClient], ); return { acting, mergeOpen, setMergeOpen, runApprove, runMerge }; diff --git a/client/lib/board-modes.ts b/client/lib/board-modes.ts index 2e048c9..1bcc6a3 100644 --- a/client/lib/board-modes.ts +++ b/client/lib/board-modes.ts @@ -8,6 +8,7 @@ export const BOARD_MODES = [ { id: "pull-requests", label: "Pull requests" }, { id: "issues", label: "Issues" }, { id: "discussions", label: "Discussions" }, + { id: "saved-views", label: "Saved views" }, { id: "projects", label: "Projects" }, ] as const; diff --git a/client/lib/search-pages.test.ts b/client/lib/search-pages.test.ts new file mode 100644 index 0000000..d55b843 --- /dev/null +++ b/client/lib/search-pages.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from "vitest"; +import { mergeSearchPages } from "./search-pages"; +import type { SearchPage } from "../../shared/saved-views"; + +it("deduplicates moving search results across pages without changing GitHub order", () => { + const page = (ids: string[]): SearchPage => ({ + items: ids.map((id) => ({ + id, + number: 1, + title: id, + url: "", + repository: "", + updatedAt: "", + createdAt: "", + lastCommitAt: null, + commentsCount: 0, + labels: [], + author: null, + detail: null, + owner: "", + relations: [], + linkedIssues: [], + checks: null, + state: "OPEN", + isDraft: false, + })), + login: "viewer", + total: 4, + hasNextPage: false, + endCursor: null, + resolvedQuery: "is:pr", + effectiveDate: "2026-09-16", + fetchedAt: "", + repositoryProjects: {}, + }); + expect(mergeSearchPages([page(["b", "a"]), page(["a", "c"])]).map((item) => item.id)).toEqual([ + "b", + "a", + "c", + ]); +}); diff --git a/client/lib/search-pages.ts b/client/lib/search-pages.ts new file mode 100644 index 0000000..c6ec2c4 --- /dev/null +++ b/client/lib/search-pages.ts @@ -0,0 +1,14 @@ +import type { SearchPage } from "../../shared/saved-views"; + +export const SEARCH_KEY = "saved-pr-search"; + +export function mergeSearchPages(pages: readonly SearchPage[]) { + const seen = new Set(); + return pages.flatMap((page) => + page.items.filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }), + ); +} diff --git a/client/search/results-toolbar.tsx b/client/search/results-toolbar.tsx new file mode 100644 index 0000000..835c470 --- /dev/null +++ b/client/search/results-toolbar.tsx @@ -0,0 +1,75 @@ +import { Pressable, Text, View } from "react-native"; +import type { Styles } from "../theme/use-styles"; +import { openExternalUrl } from "../web"; +import type { useSearchResults } from "./use-search-results"; + +export function ResultsToolbar({ + search, + preview, + canRefresh, + styles, +}: { + search: ReturnType; + preview: boolean; + canRefresh: boolean; + styles: Styles; +}) { + return ( + + + {preview ? "Preview · " : ""} + {search.isFetching + ? "Loading…" + : search.page === null + ? "Create a view to search GitHub pull requests." + : `${search.items.length} of ${search.page.total} pull requests · GitHub search order`} + + {search.error === null ? null : {search.error.message}} + {search.page === null ? null : ( + + {search.page.resolvedQuery} + + )} + + {canRefresh ? ( + void search.refresh()} + > + Refresh results + + ) : null} + {search.page === null ? null : ( + + openExternalUrl( + `https://github.com/issues?q=${encodeURIComponent(search.page?.resolvedQuery ?? "")}`, + ) + } + > + Open search on GitHub + + )} + {search.hasNextPage ? ( + void search.fetchNextPage()} + > + Load more + + ) : null} + + {(search.page?.total ?? 0) > 1000 ? ( + + GitHub exposes at most 1,000 search results. Narrow the query to see more. + + ) : null} + + ); +} diff --git a/client/search/saved-views.tsx b/client/search/saved-views.tsx new file mode 100644 index 0000000..20cb940 --- /dev/null +++ b/client/search/saved-views.tsx @@ -0,0 +1,249 @@ +import { useCallback, useMemo, useState } from "react"; +import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import type { Board } from "../../shared/board"; +import { savedViewsSettings, selectedView, type SavedView } from "../../shared/saved-views"; +import { BoardBody } from "../board/board-body"; +import { BoardDialogs } from "../board/board-dialogs"; +import { useBoardOverlays } from "../board/use-board-overlays"; +import { useBoardSettings } from "../board/use-board-settings"; +import { DEFAULT_SORT_ORDER, type BoardRow } from "../lib/sort"; +import type { Styles } from "../theme/use-styles"; +import { ResultsToolbar } from "./results-toolbar"; +import { useSearchResults } from "./use-search-results"; +import { ViewEditor } from "./view-editor"; + +export function SavedViews({ props, styles }: { props: PluginSurfaceProps; styles: Styles }) { + const settings = useSettings(savedViewsSettings); + const boardSettings = useBoardSettings(); + const [editor, setEditor] = useState(null); + const [preview, setPreview] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [deleting, setDeleting] = useState(false); + const selected = settings.status === "ready" ? selectedView(settings.values) : null; + const search = useSearchResults(preview ?? selected?.query ?? null, props.host.id); + const rows = useMemo( + () => + search.items.map((item) => ({ + item, + type: item.isDraft && item.state === "OPEN" ? "draft-prs" : "open-prs", + })), + [search.items], + ); + const board = useMemo( + () => + search.page === null + ? null + : { + login: search.page.login, + fetchedAt: search.page.fetchedAt, + repositoryProjects: (search.data?.pages ?? []).reduce>( + (projects, page) => ({ ...projects, ...page.repositoryProjects }), + {}, + ), + columns: [{ id: "open-prs", title: "Search", items: search.items, error: null }], + }, + [search.page, search.items, search.data], + ); + // Mutation RPCs invalidate search membership; ordinary board patches cannot infer it. + const ignoreBoardPatch = useCallback(() => {}, []); + const overlays = useBoardOverlays(props, styles, { + board, + promptValues: boardSettings.promptValues, + mutateBoardCache: ignoreBoardPatch, + activeOrder: DEFAULT_SORT_ORDER, + }); + const persist = async function persist(views: SavedView[], selectedId: string | null) { + if (settings.status !== "ready") return false; + setSaving(true); + setSaveError(null); + try { + const saved = await settings.save({ views, selectedId }, settings.revision); + if (!saved) + setSaveError( + "Could not save views. Settings may have changed on another device; try again.", + ); + return saved; + } catch (error) { + setSaveError(error instanceof Error ? error.message : String(error)); + return false; + } finally { + setSaving(false); + } + }; + const saveView = async function saveView(view: SavedView) { + if (settings.status !== "ready") return false; + const views = settings.values.views; + if ( + editor !== null && + views.some( + (entry) => + entry.id === editor.id && (entry.name !== editor.name || entry.query !== editor.query), + ) + ) { + setSaveError("This view changed on another device. Reopen the editor before saving."); + return false; + } + return persist( + views.some((entry) => entry.id === view.id) + ? views.map((entry) => (entry.id === view.id ? view : entry)) + : [...views, view], + view.id, + ); + }; + const closeEditor = () => { + setEditor(null); + setPreview(null); + }; + const remove = async function remove() { + if (settings.status !== "ready" || selected === null) return; + const remaining = settings.values.views.filter((view) => view.id !== selected.id); + if (await persist(remaining, remaining[0]?.id ?? null)) setDeleting(false); + }; + const error = search.error === null ? null : search.error.message; + return ( + + + + + {(settings.status === "ready" ? settings.values.views : []).map((view) => ( + { + closeEditor(); + setDeleting(false); + overlays.closeDetails(); + if (settings.status === "ready") void persist(settings.values.views, view.id); + }} + > + + {view.name} + + + ))} + { + setPreview(null); + setDeleting(false); + setEditor({ + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + name: "", + query: "", + }); + }} + > + New view + + {selected === null ? null : ( + <> + { + setEditor(selected); + setDeleting(false); + }} + > + Edit + + setDeleting(true)} + > + Delete + + + )} + + + {deleting ? ( + + Delete “{selected?.name}”? + void remove()} + > + Delete view + + setDeleting(false)} + > + Keep view + + + ) : null} + {saveError === null ? null : {saveError}} + {settings.status === "ready" || settings.status === "loading" ? null : ( + Saved views could not be loaded. + )} + + {editor === null ? null : ( + { + overlays.closeDetails(); + if (query === (preview ?? selected?.query)) void search.refresh(); + setPreview(query); + }} + onCancel={closeEditor} + /> + )} + + + + + ); +} diff --git a/client/search/use-search-results.tsx b/client/search/use-search-results.tsx new file mode 100644 index 0000000..f885e2c --- /dev/null +++ b/client/search/use-search-results.tsx @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRpc } from "@getpaseo/plugin/client"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { searchPullRequests } from "../../shared/saved-views"; +import { mergeSearchPages, SEARCH_KEY } from "../lib/search-pages"; + +export function useSearchResults(query: string | null, hostId: string) { + const load = useRpc(searchPullRequests); + const client = useQueryClient(); + const force = useRef(false); + const [day, setDay] = useState(() => new Date().toISOString().slice(0, 10)); + useEffect(() => { + const timer = setInterval(() => setDay(new Date().toISOString().slice(0, 10)), 30_000); + return () => clearInterval(timer); + }, []); + const result = useInfiniteQuery({ + queryKey: [SEARCH_KEY, hostId, query, day], + enabled: query !== null, + staleTime: 5 * 60_000, + initialPageParam: undefined as { cursor: string; effectiveDate: string } | undefined, + queryFn: ({ pageParam }) => { + const bypass = force.current; + force.current = false; + return load({ query: query ?? "", ...pageParam, force: bypass }); + }, + getNextPageParam: (last, pages) => { + const count = pages.reduce((sum, page) => sum + page.items.length, 0); + return last.hasNextPage && last.endCursor !== null && count < 1000 + ? { cursor: last.endCursor, effectiveDate: last.effectiveDate } + : undefined; + }, + }); + const items = useMemo(() => mergeSearchPages(result.data?.pages ?? []), [result.data]); + const refresh = useCallback( + async function refresh() { + force.current = true; + await client.resetQueries({ queryKey: [SEARCH_KEY, hostId, query, day], exact: true }); + }, + [client, hostId, query, day], + ); + return { ...result, items, page: result.data?.pages[0] ?? null, refresh }; +} diff --git a/client/search/view-editor.tsx b/client/search/view-editor.tsx new file mode 100644 index 0000000..94fa40c --- /dev/null +++ b/client/search/view-editor.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import type { SavedView } from "../../shared/saved-views"; +import { SavedViewSchema } from "../../shared/saved-views"; +import type { Styles } from "../theme/use-styles"; + +export function ViewEditor({ + initial, + styles, + busy, + onSave, + onPreview, + onCancel, +}: { + initial: SavedView; + styles: Styles; + busy: boolean; + onSave: (view: SavedView) => Promise; + onPreview: (query: string) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(initial.name); + const [query, setQuery] = useState(initial.query); + const [error, setError] = useState(null); + const save = async function save() { + const parsed = SavedViewSchema.safeParse({ id: initial.id, name, query }); + if (!parsed.success) { + setError(parsed.error.issues.map((issue) => issue.message).join(" ")); + return; + } + if (await onSave(parsed.data)) onCancel(); + }; + return ( + + View name + + GitHub search query + + + GitHub keywords, AND/OR and parentheses are supported. Date filters accept @today and + @today-Nd (UTC). Results contain pull requests only and follow GitHub’s search order. + + {error === null ? null : {error}} + + onPreview(query)} + > + Preview + + void save()} + > + Save + + + Cancel + + + + ); +} diff --git a/index.server.ts b/index.server.ts index 31eef75..a7eef27 100644 --- a/index.server.ts +++ b/index.server.ts @@ -1,3 +1,5 @@ +import { savedViewsSettings, searchPullRequests } from "./shared/saved-views"; +import { searchPullRequestsHandler } from "./server/search/handler"; import type { PluginServerContext } from "@getpaseo/plugin/server"; import { loadBoardHandler } from "./server/board/handler"; @@ -35,6 +37,8 @@ import { displaySettings, promptSettings } from "./shared/settings"; export default function contribute(server: PluginServerContext) { server.handle(loadBoard, loadBoardHandler); + server.handle(searchPullRequests, searchPullRequestsHandler); + server.registerSettings(savedViewsSettings); server.handle(listProjects, listProjectsHandler); server.handle(loadProject, loadProjectHandler); server.handle(loadItem, loadItemHandler); diff --git a/server/board/handler.ts b/server/board/handler.ts index c1e2f2a..928f9db 100644 --- a/server/board/handler.ts +++ b/server/board/handler.ts @@ -19,7 +19,7 @@ import { boardCache, BOARD_TTL_MS } from "./cache"; * per-project overrides. That view now calls `paseo.projects.list()` on the * client, which is the same list without the detour. */ -async function describeRepositoryProjects( +export async function describeRepositoryProjects( paseo: PaseoApi, columns: readonly BoardColumn[], ): Promise<{ repositoryProjects: Record }> { diff --git a/server/board/pull-requests.ts b/server/board/pull-requests.ts index ed838e2..ee02dc8 100644 --- a/server/board/pull-requests.ts +++ b/server/board/pull-requests.ts @@ -11,7 +11,7 @@ import { UNARCHIVED_ONLY } from "./types"; * in the body and issues attached by hand from the Development panel. The board * needs it to fold an issue into the pull request that closes it. */ -const PULL_REQUEST_SELECTION = `... on PullRequest { +export const PULL_REQUEST_SELECTION = `... on PullRequest { id number title @@ -19,6 +19,7 @@ const PULL_REQUEST_SELECTION = `... on PullRequest { updatedAt createdAt isDraft + state author { login } comments { totalCount } labels(first: 20) { nodes { name } } @@ -31,13 +32,14 @@ const PULL_REQUEST_SELECTION = `... on PullRequest { } }`; -interface GhPullRequestNode extends GhSearchNode { +export interface GhPullRequestNode extends GhSearchNode { isDraft?: unknown; + state?: unknown; closingIssuesReferences?: { nodes?: unknown }; commits?: { nodes?: unknown }; } -function toLinkedIssues(node: GhPullRequestNode): LinkedIssue[] { +export function toLinkedIssues(node: GhPullRequestNode): LinkedIssue[] { const nodes = node.closingIssuesReferences?.nodes; if (!Array.isArray(nodes)) return []; return nodes @@ -59,7 +61,7 @@ function toLinkedIssues(node: GhPullRequestNode): LinkedIssue[] { * on every pull request. Null when GitHub reports no commit at all, which * happens on a pull request whose branch was force-pushed away underneath it. */ -function toLastCommitAt(node: GhPullRequestNode): string | null { +export function toLastCommitAt(node: GhPullRequestNode): string | null { const nodes = node.commits?.nodes; if (!Array.isArray(nodes) || nodes.length === 0) return null; const commit = (nodes[0] as { commit?: { committedDate?: unknown } } | undefined)?.commit; diff --git a/server/cache/cache.test.ts b/server/cache/cache.test.ts index e2cf499..26bac5c 100644 --- a/server/cache/cache.test.ts +++ b/server/cache/cache.test.ts @@ -25,6 +25,27 @@ afterEach(async () => { }); describe("Cache.get", () => { + it("clears every search and does not reuse or store an in-flight pre-mutation answer", async () => { + const cache = new Cache("clear-generation"); + await cache.set("other-query", 5); + let finish: (value: number) => void = () => {}; + let started: () => void = () => {}; + const ready = new Promise((resolve) => { started = resolve; }); + const stale = cache.get("query", 10_000, () => { + started(); + return new Promise((resolve) => { finish = resolve; }); + }); + await ready; + await cache.clear(); + expect(await cache.get("query", 10_000, async () => 2)).toBe(2); + finish(1); + await stale; + expect(await cache.get("query", 10_000, async () => 3)).toBe(2); + expect(await cache.get("other-query", 10_000, async () => 6)).toBe(6); + const reloaded = new Cache("clear-generation"); + expect(await reloaded.get("query", 10_000, async () => 4)).toBe(2); + }); + it("answers from memory within the TTL without calling the loader again", async () => { const cache = new Cache("ttl-hit"); const load = vi.fn().mockResolvedValue(1); diff --git a/server/cache/cache.ts b/server/cache/cache.ts index a3a1a4d..ddea4c6 100644 --- a/server/cache/cache.ts +++ b/server/cache/cache.ts @@ -31,6 +31,7 @@ export class Cache { private readonly file: string; private hydrated: Promise | null = null; private hardenedFile = false; + private generation = 0; constructor(name: string) { this.file = join(cacheDataDir(), `${name}.json`); @@ -121,22 +122,32 @@ export class Cache { const running = this.inFlight.get(key); if (running !== undefined) return running; + const generation = this.generation; const promise = (async () => { try { const value = await load(); - if (options?.shouldCache?.(value) !== false) { + if (generation === this.generation && options?.shouldCache?.(value) !== false) { this.memory.set(key, { value, storedAt: Date.now() }); await this.persist(); } return value; } finally { - this.inFlight.delete(key); + if (generation === this.generation) this.inFlight.delete(key); } })(); this.inFlight.set(key, promise); return promise; } + /** In-flight answers from before a mutation must not repopulate the cache. */ + async clear(): Promise { + await this.hydrate(); + this.generation++; + this.memory.clear(); + this.inFlight.clear(); + await this.persist(); + } + /** Stores a value the caller already has — a mutation's own response — bypassing `load`. */ async set(key: string, value: T): Promise { await this.hydrate(); diff --git a/server/items/labels.ts b/server/items/labels.ts index dc54d3e..932b30d 100644 --- a/server/items/labels.ts +++ b/server/items/labels.ts @@ -1,3 +1,4 @@ +import { searchCache } from "../search/cache"; import type { z } from "zod"; import type { RepositoryLabel, listLabels, toggleLabel } from "../../shared/board"; import { gh } from "../github/gh"; @@ -124,6 +125,7 @@ export async function toggleLabelHandler({ const parsed: unknown = JSON.parse(raw); const data = (parsed as { data?: Record }).data; const labels = labelNamesOf(add ? data?.addLabelsToLabelable : data?.removeLabelsFromLabelable); + await searchCache.clear(); await patchCachedLabels(itemId, labels); return { labels }; } diff --git a/server/items/review.ts b/server/items/review.ts index 0a76d77..bcd0b6d 100644 --- a/server/items/review.ts +++ b/server/items/review.ts @@ -1,3 +1,4 @@ +import { searchCache } from "../search/cache"; import type { z } from "zod"; import type { ItemDetails, MergeMethod, approvePullRequest, mergePullRequest } from "../../shared/board"; import { ghMutation } from "../github/graphql"; @@ -38,6 +39,7 @@ export async function approveHandler({ body, }: z.output): Promise> { await ghMutation(APPROVE_MUTATION, { id, body }); + await searchCache.clear(); return refreshDetails(id); } @@ -46,6 +48,7 @@ export async function mergeHandler({ method, }: z.output): Promise> { await ghMutation(MERGE_MUTATION, { id, method: MERGE_METHOD_NAMES[method] }); + await searchCache.clear(); const details = await refreshDetails(id); await dropCachedItem(id); return details; diff --git a/server/search/cache.ts b/server/search/cache.ts new file mode 100644 index 0000000..b743f1b --- /dev/null +++ b/server/search/cache.ts @@ -0,0 +1,4 @@ +import type { SearchPage } from "../../shared/saved-views"; +import { Cache } from "../cache/cache"; + +export const searchCache = new Cache("pr-search"); diff --git a/server/search/fetch.test.ts b/server/search/fetch.test.ts new file mode 100644 index 0000000..22d5707 --- /dev/null +++ b/server/search/fetch.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchSearchPage } from "./fetch"; +import type { BoardItem } from "../../shared/board"; + +function response() { + return { + data: { + search: { + issueCount: 45, + pageInfo: { hasNextPage: true, endCursor: "next" }, + nodes: [ + { + id: "older", + state: "OPEN", + isDraft: false, + updatedAt: "2026-01-01", + repository: { nameWithOwner: "team/project" }, + }, + { id: "newer", state: "MERGED", isDraft: false, updatedAt: "2026-02-01" }, + { id: "draft", state: "OPEN", isDraft: true }, + null, + ], + }, + }, + errors: [], + }; +} + +describe("fetchSearchPage", () => { + it("uses advanced search variables and keeps result order and cursor metadata", async () => { + const execute = vi.fn().mockResolvedValue(response()); + const checks = vi.fn(async (items: readonly BoardItem[]) => + items.map((item) => ({ + ...item, + checks: { passed: 2, failed: 0, pending: 0 }, + })), + ); + const query = "is:pr AND (org:alpha OR org:beta) sort:created-asc"; + const result = await fetchSearchPage(query, "previous", execute, checks); + expect(execute.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + "api", + "graphql", + `search=${query}`, + "cursor=previous", + expect.stringContaining("type: ISSUE_ADVANCED"), + ]), + ); + expect(result.items.map((item) => item.id)).toEqual(["older", "newer", "draft"]); + expect(result.items[0]).toMatchObject({ + repository: "team/project", + relations: [], + checks: { passed: 2 }, + }); + expect(result.items[1]).toMatchObject({ state: "MERGED", prState: "MERGED", checks: null }); + expect(checks.mock.calls[0]?.[0].map((item) => item.id)).toEqual(["older"]); + expect(result).toMatchObject({ total: 45, hasNextPage: true, endCursor: "next" }); + }); + + it("surfaces GitHub errors and malformed responses instead of reporting zero matches", async () => { + const execute = vi + .fn() + .mockResolvedValue({ data: null, errors: [{ message: "Invalid query" }] }); + await expect(fetchSearchPage("bad", undefined, execute)).rejects.toThrow("Invalid query"); + execute.mockResolvedValue({ data: {}, errors: [] }); + await expect(fetchSearchPage("bad", undefined, execute)).rejects.toThrow(); + }); + + it("returns an empty search without fetching checks for nonexistent PRs", async () => { + const execute = vi.fn().mockResolvedValue({ + data: { + search: { + issueCount: 0, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }, + }, + errors: [], + }); + const checks = vi.fn(async (items: readonly BoardItem[]) => [...items]); + const result = await fetchSearchPage("is:pr", undefined, execute, checks); + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + expect(checks).not.toHaveBeenCalled(); + }); +}); diff --git a/server/search/fetch.ts b/server/search/fetch.ts new file mode 100644 index 0000000..180c53d --- /dev/null +++ b/server/search/fetch.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import type { SearchPage } from "../../shared/saved-views"; +import { PULL_REQUEST_SELECTION, toLastCommitAt, toLinkedIssues } from "../board/pull-requests"; +import { toItem } from "../board/item"; +import { ghGraphqlRaw } from "../github/graphql"; +import { attachChecks } from "../board/checks"; + +const ConnectionSchema = z.object({ + issueCount: z.number().int().nonnegative(), + pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }), + nodes: z.array( + z + .object({ + id: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + isDraft: z.boolean(), + }) + .passthrough() + .nullable(), + ), +}); + +export const SEARCH_QUERY = `query($search: String!, $cursor: String) { + search(query: $search, type: ISSUE_ADVANCED, first: 30, after: $cursor) { + issueCount + pageInfo { hasNextPage endCursor } + nodes { ${PULL_REQUEST_SELECTION} } + } +}`; + +export async function fetchSearchPage( + query: string, + cursor: string | undefined, + execute = ghGraphqlRaw, + checks = attachChecks, +): Promise> { + const args = ["api", "graphql", "-f", `query=${SEARCH_QUERY}`, "-f", `search=${query}`]; + if (cursor !== undefined) args.push("-f", `cursor=${cursor}`); + const result = await execute(args); + if (result.errors.length > 0) + throw new Error(result.errors.map((error) => error.message).join("; ")); + const connection = ConnectionSchema.parse(result.data?.search); + const items = connection.nodes.flatMap((node) => + node === null + ? [] + : [ + { + ...toItem(node, null, toLastCommitAt(node)), + linkedIssues: toLinkedIssues(node), + state: node.state, + prState: node.state, + isDraft: node.isDraft, + }, + ], + ); + const eligible = items.filter((item) => item.state === "OPEN" && !item.isDraft); + const checked = eligible.length === 0 ? [] : await checks(eligible); + const byId = new Map(checked.map((item) => [item.id, item.checks])); + return { + items: items.map((item) => ({ ...item, checks: byId.get(item.id) ?? null })), + total: connection.issueCount, + hasNextPage: connection.pageInfo.hasNextPage, + endCursor: connection.pageInfo.endCursor, + }; +} diff --git a/server/search/handler.ts b/server/search/handler.ts new file mode 100644 index 0000000..07a757d --- /dev/null +++ b/server/search/handler.ts @@ -0,0 +1,42 @@ +import type { z } from "zod"; +import type { PluginHandlerContext } from "@getpaseo/plugin/server"; +import type { searchPullRequests } from "../../shared/saved-views"; +import { resolveSearchQuery } from "../../shared/search-query"; +import { resolveViewerLogin } from "../github/gh"; +import { describeRepositoryProjects } from "../board/handler"; +import { fetchSearchPage } from "./fetch"; +import { searchCache } from "./cache"; + +export async function searchPullRequestsHandler( + input: z.output, + { paseo }: PluginHandlerContext, +) { + const login = await resolveViewerLogin(); + const effectiveDate = input.effectiveDate ?? new Date().toISOString().slice(0, 10); + const resolvedQuery = resolveSearchQuery(input.query, effectiveDate); + const key = JSON.stringify([login, resolvedQuery, input.cursor ?? null]); + return searchCache.get( + key, + 5 * 60_000, + async () => { + const page = await fetchSearchPage(resolvedQuery, input.cursor); + const projects = await describeRepositoryProjects(paseo, [ + { + id: "open-prs", + title: "Search", + items: page.items, + error: null, + }, + ]); + return { + ...page, + ...projects, + login, + effectiveDate, + resolvedQuery, + fetchedAt: new Date().toISOString(), + }; + }, + { force: input.force }, + ); +} diff --git a/shared/board.ts b/shared/board.ts index 54a63e1..728ea1e 100644 --- a/shared/board.ts +++ b/shared/board.ts @@ -58,6 +58,8 @@ export const RelationSchema = z.enum(RELATION_IDS); export type Relation = z.output; export const BoardItemSchema = z.object({ + /** Saved searches can include closed and merged pull requests. */ + prState: z.enum(["OPEN", "CLOSED", "MERGED"]).optional(), id: z.string(), number: z.number().int(), title: z.string(), @@ -88,8 +90,7 @@ export const BoardItemSchema = z.object({ /** The owner half of `repository`, for the owner filter. */ owner: z.string(), /** - * Every relationship the viewer has to this item, deduplicated. Empty is - * impossible: an item is on the board because some search matched it. + * Every relationship the viewer has to this item, deduplicated. Empty for a saved search hit without a known personal relationship. */ relations: z.array(RelationSchema), /** diff --git a/shared/saved-views.test.ts b/shared/saved-views.test.ts new file mode 100644 index 0000000..44e4c86 --- /dev/null +++ b/shared/saved-views.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { SavedViewsSchema, SavedViewSchema, selectedView, searchPullRequests } from "./saved-views"; + +describe("saved view settings", () => { + const first = { id: "first", name: "Team reviews", query: "org:example review:required" }; + const second = { id: "second", name: "Mine", query: "author:@me" }; + + it("starts empty and falls back when the selected view has been deleted", () => { + expect(selectedView(SavedViewsSchema.parse({}))).toBeNull(); + expect(selectedView({ views: [first, second], selectedId: "second" })).toEqual(second); + expect(selectedView({ views: [first], selectedId: "second" })).toEqual(first); + }); + + it("keeps editable query syntax intact and validates names and limits", () => { + expect( + SavedViewSchema.parse({ ...first, query: 'created:>@today-30d label:"needs review"' }).query, + ).toBe('created:>@today-30d label:"needs review"'); + expect(SavedViewSchema.safeParse({ ...first, name: " " }).success).toBe(false); + expect(SavedViewSchema.safeParse({ ...first, query: "a".repeat(2001) }).success).toBe(false); + expect(SavedViewsSchema.safeParse({ views: [first, first] }).success).toBe(false); + expect( + SavedViewsSchema.safeParse({ + views: Array.from({ length: 51 }, (_, id) => ({ ...first, id: String(id) })), + }).success, + ).toBe(false); + }); + + it("validates search input at the RPC boundary", () => { + expect(searchPullRequests.input.safeParse({ query: "" }).success).toBe(false); + expect( + searchPullRequests.input.safeParse({ query: first.query, effectiveDate: "yesterday" }) + .success, + ).toBe(false); + }); +}); diff --git a/shared/saved-views.ts b/shared/saved-views.ts new file mode 100644 index 0000000..901f878 --- /dev/null +++ b/shared/saved-views.ts @@ -0,0 +1,63 @@ +import { defineRpc, defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +import { BoardItemSchema } from "./board"; + +export const SearchQuerySchema = z.string().trim().min(1).max(2000); +export const SavedViewSchema = z.object({ + id: z.string().min(1).max(100), + name: z.string().trim().min(1).max(100), + query: SearchQuerySchema, +}); +export type SavedView = z.output; + +export const SavedViewsSchema = z + .object({ + views: z.array(SavedViewSchema).max(50).default([]), + selectedId: z.string().nullable().default(null), + }) + .refine((value) => new Set(value.views.map((view) => view.id)).size === value.views.length, { + message: "View IDs must be unique.", + }); + +export const savedViewsSettings = defineSettings({ + id: "saved-views", + scope: "host", + version: 1, + schema: SavedViewsSchema, +}); + +export const SearchItemSchema = BoardItemSchema.extend({ + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + isDraft: z.boolean(), +}); + +export const SearchPageSchema = z.object({ + items: z.array(SearchItemSchema), + login: z.string(), + total: z.number().int().nonnegative(), + hasNextPage: z.boolean(), + endCursor: z.string().nullable(), + resolvedQuery: z.string(), + effectiveDate: z.string(), + fetchedAt: z.string(), + repositoryProjects: z.record(z.string(), z.string()), +}); +export type SearchPage = z.output; + +export const searchPullRequests = defineRpc({ + name: "search.pull-requests", + input: z.object({ + query: SearchQuerySchema, + cursor: z.string().max(1000).optional(), + effectiveDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + force: z.boolean().default(false), + }), + output: SearchPageSchema, +}); + +export function selectedView(values: z.output): SavedView | null { + return values.views.find((view) => view.id === values.selectedId) ?? values.views[0] ?? null; +} diff --git a/shared/search-query.test.ts b/shared/search-query.test.ts new file mode 100644 index 0000000..d7cab7d --- /dev/null +++ b/shared/search-query.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { resolveSearchQuery } from "./search-query"; + +describe("saved PR search queries", () => { + it("scopes both OR branches to pull requests without changing their sort", () => { + const query = "(org:alpha OR org:beta) (author:alice OR author:bob) sort:created-asc"; + expect(resolveSearchQuery(query, "2026-09-16")).toBe(`is:pr AND (${query})`); + }); + + it("resolves relative date comparisons and ranges using UTC calendar days", () => { + expect(resolveSearchQuery("created:>@today-90d updated:@today-2d..@today", "2026-09-16")).toBe( + "is:pr AND (created:>2026-06-18 updated:2026-09-14..2026-09-16)", + ); + expect(resolveSearchQuery("closed:<=@today-1d", "2024-03-01")).toBe( + "is:pr AND (closed:<=2024-02-29)", + ); + expect(resolveSearchQuery("merged:@today-1d", "2026-01-01")).toBe( + "is:pr AND (merged:2025-12-31)", + ); + }); + + it("leaves quoted examples, escaped quotes, and unrelated terms untouched", () => { + const query = '"created:>@today-90d (example)" label:"say \\"hi\\"" @today'; + expect(resolveSearchQuery(query, "2026-09-16")).toBe(`is:pr AND (${query})`); + }); + + it.each([ + "created:@today-2w", + "updated:@today+1d", + "closed:@today-nope", + "merged:@today-999999999d", + ])("rejects unsupported relative date syntax %s", (query) => { + expect(() => resolveSearchQuery(query, "2026-09-16")).toThrow(); + }); + + it.each(['"unclosed', "(org:alpha", "org:alpha)", ")(org:alpha"])( + "rejects unbalanced search syntax %s", + (query) => { + expect(() => resolveSearchQuery(query, "2026-09-16")).toThrow(); + }, + ); + + it("rejects impossible dates and moves the resolved query at UTC midnight", () => { + expect(() => resolveSearchQuery("created:@today", "2026-02-30")).toThrow(); + expect(resolveSearchQuery("created:@today", "2026-09-16")).not.toBe( + resolveSearchQuery("created:@today", "2026-09-17"), + ); + }); +}); diff --git a/shared/search-query.ts b/shared/search-query.ts new file mode 100644 index 0000000..4da6ea3 --- /dev/null +++ b/shared/search-query.ts @@ -0,0 +1,36 @@ +/** Resolve only unquoted date qualifiers; text searches can contain literal macro examples. */ +export function resolveSearchQuery(query: string, effectiveDate: string): string { + const today = new Date(`${effectiveDate}T00:00:00.000Z`); + if (!Number.isFinite(today.getTime()) || today.toISOString().slice(0, 10) !== effectiveDate) { + throw new Error("Invalid search date."); + } + const parts = query.match(/"(?:\\.|[^"\\])*"|[^"\s()]+|[\s()]+|"/g) ?? []; + let depth = 0; + for (const part of parts) { + if (part === '"') throw new Error("Close the quoted text in the search query."); + if (part.startsWith('"')) continue; + for (const char of part) { + if (char === "(") depth++; + if (char === ")") depth--; + if (depth < 0) throw new Error("Unbalanced search parentheses."); + } + } + if (depth !== 0) throw new Error("Unbalanced search parentheses."); + const resolved = parts + .map((part) => { + if (!/^(created|updated|closed|merged):/.test(part) || !part.includes("@today")) return part; + return part.replace(/@today[^.<>\s]*/g, (macro) => { + const match = /^@today(?:-(\d+)d)?$/.exec(macro); + if (match === null) throw new Error("Use @today or @today-Nd in date filters."); + const days = Number(match[1] ?? 0); + if (!Number.isSafeInteger(days) || days > 365000) + throw new Error("Relative date is out of range."); + const date = new Date(today); + date.setUTCDate(date.getUTCDate() - days); + return date.toISOString().slice(0, 10); + }); + }) + .join(""); + // Scope the entire expression, so an OR branch cannot admit issues. + return `is:pr AND (${resolved})`; +} diff --git a/shared/settings.ts b/shared/settings.ts index db879f5..ccd7280 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -42,6 +42,7 @@ export const displaySettings = defineSettings({ version: 1, schema: z.object({ /** The repository filter, stored as the repositories to hide. */ + mode: z.string().default("pull-requests"), hiddenRepositories: z.array(z.string()).default([]), /** * The detail panel's width as a share of the board's body, or null for the