@omercnet/paseo-pr-radar 1.1.0 → 1.2.0-next.223.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/client/pr-radar.tsx +70 -135
- package/client/screen-state.ts +64 -0
- package/client/sidebar.tsx +99 -0
- package/client/use-radar.ts +130 -0
- package/index.client.tsx +24 -9
- package/package.json +5 -5
- package/paseo-plugin.json +1 -1
- package/server/viewer-scope.ts +10 -5
package/README.md
CHANGED
|
@@ -32,6 +32,10 @@ DevTools before capture. Both PNGs come from an isolated Paseo test daemon at 2
|
|
|
32
32
|
|
|
33
33
|
Paseo supplies normalized workspace pull request status. A daemon-side plugin handler uses the authenticated `gh` CLI to distinguish authored pull requests from review requests. If viewer lookup fails, PR Radar falls back conservatively and does not claim that a row needs the user.
|
|
34
34
|
|
|
35
|
+
## Paseo 0.11
|
|
36
|
+
|
|
37
|
+
On 0.11 the sidebar row shows a "needs you" count; pressing the count opens a popover of those pull requests. Pressing one opens PR Radar focused on it (`param.pr`), and the needs-you link filters the queue (`param.filter`), so both survive reload and can be linked. A trailing `+` marks a count that may be incomplete: viewer lookup failed, a pagination or inbox cap was hit, or workspace status was unavailable. Paseo 0.9 and 0.10 keep the static surface and sidebar row.
|
|
38
|
+
|
|
35
39
|
## Install
|
|
36
40
|
|
|
37
41
|
### Paseo 0.9 and 0.10
|
|
@@ -66,7 +70,7 @@ bun run typecheck
|
|
|
66
70
|
Release Please maintains the version, changelog, component tag, and GitHub release from
|
|
67
71
|
Conventional Commits in the monorepo.
|
|
68
72
|
|
|
69
|
-
The manifest supports Paseo `^0.9.0 || ^0.10.0`. Development uses the stable 0.9 CLI, client, plugin SDK,
|
|
73
|
+
The manifest supports Paseo `^0.9.0 || ^0.10.0 || ^0.11.0`. Development uses the stable 0.9 CLI, client, plugin SDK,
|
|
70
74
|
and protocol packages together. Host-owned navigation opens linked agents and workspaces without private routes or
|
|
71
75
|
page reloads on web, desktop, iOS, and Android. React `19.1` and React Native `0.81` match the versions supplied by
|
|
72
76
|
the plugin host.
|
package/client/pr-radar.tsx
CHANGED
|
@@ -1,38 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type PluginSurfaceProps,
|
|
4
|
-
usePaseo,
|
|
5
|
-
useRpc,
|
|
6
|
-
} from "@getpaseo/plugin/client";
|
|
7
|
-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
1
|
+
import { openExternalUrl, type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
8
3
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
9
4
|
import { ActivityIndicator, FlatList, Pressable, Text, TextInput, View } from "react-native";
|
|
10
|
-
import { acknowledgeViewerScope
|
|
11
|
-
import { observeDirectoryInvalidation } from "./directory-observation";
|
|
5
|
+
import { acknowledgeViewerScope } from "../shared/viewer-scope";
|
|
12
6
|
import {
|
|
13
|
-
type AgentEntry,
|
|
14
7
|
agentActionFor,
|
|
15
|
-
applyViewerScope,
|
|
16
8
|
BUCKET_TITLES,
|
|
17
9
|
BUCKETS,
|
|
18
10
|
buildAgentPrompt,
|
|
19
|
-
buildRadarSnapshot,
|
|
20
11
|
checkSummary,
|
|
21
12
|
formatAge,
|
|
22
13
|
hasActiveAgent,
|
|
23
14
|
matchesRow,
|
|
24
|
-
mergeInboxRows,
|
|
25
15
|
openPullRequestUrl,
|
|
26
|
-
type PaseoApi,
|
|
27
|
-
type PaseoWorkspace,
|
|
28
16
|
type RadarBucket,
|
|
29
17
|
type RadarRow,
|
|
30
18
|
} from "./radar";
|
|
19
|
+
import {
|
|
20
|
+
DIRECTORY_PARTIAL,
|
|
21
|
+
LOOKUP_CAPPED,
|
|
22
|
+
parseRadarParams,
|
|
23
|
+
type RadarFilter,
|
|
24
|
+
VIEWER_URL_LIMIT,
|
|
25
|
+
} from "./screen-state";
|
|
26
|
+
import { useRadar } from "./use-radar";
|
|
31
27
|
|
|
32
|
-
const PAGE_LIMIT = 200;
|
|
33
|
-
const MAX_PAGES = 10;
|
|
34
|
-
const BACKSTOP_REFRESH_MS = 60_000;
|
|
35
|
-
const EVENT_DEBOUNCE_MS = 500;
|
|
36
28
|
const CLOCK_TICK_MS = 30_000;
|
|
37
29
|
|
|
38
30
|
type SavedView = "security" | "updated" | "stale" | "automation";
|
|
@@ -46,35 +38,6 @@ const SAVED_VIEW_TITLES: Record<SavedView, string> = {
|
|
|
46
38
|
|
|
47
39
|
const STALE_AFTER_MS = 3 * 24 * 60 * 60 * 1_000;
|
|
48
40
|
|
|
49
|
-
async function loadAgents(paseo: PaseoApi): Promise<AgentEntry[]> {
|
|
50
|
-
const entries: AgentEntry[] = [];
|
|
51
|
-
let cursor: string | undefined;
|
|
52
|
-
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
53
|
-
const result = await paseo.agents.list({
|
|
54
|
-
sort: [{ key: "updated_at", direction: "desc" }],
|
|
55
|
-
page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
|
56
|
-
});
|
|
57
|
-
entries.push(...result.entries);
|
|
58
|
-
cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
|
|
59
|
-
if (!cursor) break;
|
|
60
|
-
}
|
|
61
|
-
return entries;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function loadWorkspaces(paseo: PaseoApi): Promise<PaseoWorkspace[]> {
|
|
65
|
-
const workspaces: PaseoWorkspace[] = [];
|
|
66
|
-
let cursor: string | undefined;
|
|
67
|
-
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
68
|
-
const result = await paseo.workspaces.list({
|
|
69
|
-
page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
|
70
|
-
});
|
|
71
|
-
workspaces.push(...result.entries);
|
|
72
|
-
cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
|
|
73
|
-
if (!cursor) break;
|
|
74
|
-
}
|
|
75
|
-
return workspaces;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
41
|
function bucketColor(bucket: RadarBucket, colors: PluginSurfaceProps["theme"]["colors"]): string {
|
|
79
42
|
if (bucket === "needs-you") return colors.statusDanger;
|
|
80
43
|
if (bucket === "ready") return colors.statusSuccess;
|
|
@@ -93,15 +56,29 @@ function agentState(row: RadarRow): string {
|
|
|
93
56
|
return `${agent.title} · ${agent.status}${extra}`;
|
|
94
57
|
}
|
|
95
58
|
|
|
96
|
-
export function PrRadar({
|
|
97
|
-
|
|
59
|
+
export function PrRadar({
|
|
60
|
+
theme,
|
|
61
|
+
layout,
|
|
62
|
+
host,
|
|
63
|
+
navigation,
|
|
64
|
+
params = {},
|
|
65
|
+
}: PluginSurfaceProps & { params?: Record<string, string> }) {
|
|
98
66
|
const queryClient = useQueryClient();
|
|
99
|
-
const queryKey = useMemo(() => ["pr-radar", host.id], [host.id]);
|
|
100
|
-
const resolveViewerScope = useRpc(viewerScope);
|
|
101
67
|
const acknowledgeUpdates = useRpc(acknowledgeViewerScope);
|
|
102
|
-
const
|
|
103
|
-
const [
|
|
104
|
-
const [
|
|
68
|
+
const parsed = parseRadarParams(params);
|
|
69
|
+
const [filter, setFilterState] = useState<RadarFilter | null>(parsed.filter);
|
|
70
|
+
const [focusedPr, setFocusedPr] = useState<string | null>(parsed.pr);
|
|
71
|
+
// Chips are local state: openScreen would push a new screen per tap. A chip press also ends PR focus.
|
|
72
|
+
const setFilter = (value: RadarFilter | null) => {
|
|
73
|
+
setFilterState(value);
|
|
74
|
+
setFocusedPr(null);
|
|
75
|
+
};
|
|
76
|
+
const selected = BUCKETS.includes(filter as RadarBucket) ? (filter as RadarBucket) : null;
|
|
77
|
+
const activeOnly = filter === "active";
|
|
78
|
+
const savedView =
|
|
79
|
+
filter && ["security", "updated", "stale", "automation"].includes(filter)
|
|
80
|
+
? (filter as SavedView)
|
|
81
|
+
: null;
|
|
105
82
|
const [windowDays, setWindowDays] = useState(30);
|
|
106
83
|
const [search, setSearch] = useState("");
|
|
107
84
|
const [now, setNow] = useState(() => Date.now());
|
|
@@ -109,64 +86,25 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
|
|
|
109
86
|
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
|
110
87
|
|
|
111
88
|
const {
|
|
89
|
+
paseo,
|
|
90
|
+
queryKey,
|
|
112
91
|
data,
|
|
113
92
|
error,
|
|
114
93
|
isPending,
|
|
115
|
-
isFetching: isDirectoryFetching,
|
|
116
94
|
refetch,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
});
|
|
95
|
+
rawRows,
|
|
96
|
+
rows,
|
|
97
|
+
viewerData,
|
|
98
|
+
viewerError,
|
|
99
|
+
isFetching,
|
|
100
|
+
refetchViewer,
|
|
101
|
+
} = useRadar(host.id, windowDays);
|
|
125
102
|
|
|
126
103
|
useEffect(() => {
|
|
127
104
|
const clock = setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
|
|
128
105
|
return () => clearInterval(clock);
|
|
129
106
|
}, []);
|
|
130
107
|
|
|
131
|
-
useEffect(
|
|
132
|
-
() =>
|
|
133
|
-
observeDirectoryInvalidation(
|
|
134
|
-
paseo,
|
|
135
|
-
() => void queryClient.invalidateQueries({ queryKey }),
|
|
136
|
-
EVENT_DEBOUNCE_MS,
|
|
137
|
-
),
|
|
138
|
-
[paseo, queryClient, queryKey],
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
const rawRows = data?.rows ?? [];
|
|
142
|
-
const scopeUrls = useMemo(() => rawRows.map((row) => row.url), [rawRows]);
|
|
143
|
-
const {
|
|
144
|
-
data: viewerData,
|
|
145
|
-
error: viewerQueryError,
|
|
146
|
-
isFetching: isViewerFetching,
|
|
147
|
-
refetch: refetchViewer,
|
|
148
|
-
} = useQuery({
|
|
149
|
-
queryKey: ["pr-radar-viewer-scope", host.id, scopeUrls, windowDays],
|
|
150
|
-
queryFn: () => resolveViewerScope({ urls: scopeUrls, windowDays }),
|
|
151
|
-
staleTime: 5 * 60_000,
|
|
152
|
-
refetchInterval: 5 * 60_000,
|
|
153
|
-
});
|
|
154
|
-
const mergedRows = useMemo(
|
|
155
|
-
() => (data ? mergeInboxRows(data, viewerData?.inboxItems ?? []) : []),
|
|
156
|
-
[data, viewerData],
|
|
157
|
-
);
|
|
158
|
-
const rows = useMemo(
|
|
159
|
-
() => applyViewerScope(mergedRows, viewerData ?? null),
|
|
160
|
-
[mergedRows, viewerData],
|
|
161
|
-
);
|
|
162
|
-
const isFetching = isDirectoryFetching || isViewerFetching;
|
|
163
|
-
const viewerError =
|
|
164
|
-
viewerData?.error ??
|
|
165
|
-
(viewerQueryError instanceof Error
|
|
166
|
-
? viewerQueryError.message
|
|
167
|
-
: viewerQueryError
|
|
168
|
-
? "error"
|
|
169
|
-
: null);
|
|
170
108
|
const acknowledgeMutation = useMutation({
|
|
171
109
|
mutationFn: () => acknowledgeUpdates({ windowDays }),
|
|
172
110
|
onSuccess: async () => {
|
|
@@ -282,13 +220,14 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
|
|
|
282
220
|
Boolean(row.activityAt && now - Date.parse(row.activityAt) >= STALE_AFTER_MS)) ||
|
|
283
221
|
(savedView === "automation" && row.authorKind === "bot");
|
|
284
222
|
return (
|
|
223
|
+
(!focusedPr || row.id === focusedPr) &&
|
|
285
224
|
(!selected || row.bucket === selected) &&
|
|
286
225
|
(!activeOnly || hasActiveAgent(row.agents)) &&
|
|
287
226
|
matchesSavedView &&
|
|
288
227
|
matchesRow(row, search)
|
|
289
228
|
);
|
|
290
229
|
}),
|
|
291
|
-
[activeOnly, now, rows, savedView, search, selected],
|
|
230
|
+
[activeOnly, now, rows, savedView, search, selected, focusedPr],
|
|
292
231
|
);
|
|
293
232
|
|
|
294
233
|
const styles = useMemo(() => {
|
|
@@ -642,15 +581,17 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
|
|
|
642
581
|
};
|
|
643
582
|
|
|
644
583
|
const totalCopy = `${rows.length} open ${rows.length === 1 ? "pull request" : "pull requests"}; ${rawRows.length} linked to ${data?.workspaceCount ?? 0} workspaces`;
|
|
645
|
-
const emptyCopy =
|
|
646
|
-
? "
|
|
647
|
-
:
|
|
648
|
-
? "No pull requests
|
|
649
|
-
:
|
|
650
|
-
?
|
|
651
|
-
:
|
|
652
|
-
? `No pull requests
|
|
653
|
-
:
|
|
584
|
+
const emptyCopy = focusedPr
|
|
585
|
+
? "This pull request is not in the current queue."
|
|
586
|
+
: search
|
|
587
|
+
? "No pull requests match this search."
|
|
588
|
+
: activeOnly
|
|
589
|
+
? "No pull requests have a running or initializing agent."
|
|
590
|
+
: savedView
|
|
591
|
+
? `No pull requests match the ${SAVED_VIEW_TITLES[savedView].toLowerCase()} view.`
|
|
592
|
+
: selected
|
|
593
|
+
? "No pull requests are in this queue."
|
|
594
|
+
: "No open pull requests are visible to GitHub or linked to a Paseo workspace.";
|
|
654
595
|
const summaryMetrics = [
|
|
655
596
|
{ label: "Action now", value: counts["needs-you"] },
|
|
656
597
|
{ label: "Ready", value: counts.ready },
|
|
@@ -680,6 +621,11 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
|
|
|
680
621
|
</View>
|
|
681
622
|
<Text style={styles.heroTitle}>Know what moves next.</Text>
|
|
682
623
|
<Text style={styles.heroDetail}>{totalCopy}</Text>
|
|
624
|
+
{focusedPr ? <Text style={styles.heroDetail}>Focused PR: {focusedPr}</Text> : null}
|
|
625
|
+
{data?.truncated ? <Text style={styles.warningText}>{DIRECTORY_PARTIAL}</Text> : null}
|
|
626
|
+
{rawRows.length > VIEWER_URL_LIMIT ? (
|
|
627
|
+
<Text style={styles.warningText}>{LOOKUP_CAPPED}</Text>
|
|
628
|
+
) : null}
|
|
683
629
|
<View accessibilityRole="summary" style={styles.summary}>
|
|
684
630
|
{summaryMetrics.map(({ label, value }) => (
|
|
685
631
|
<View key={label} style={styles.metric}>
|
|
@@ -691,18 +637,19 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
|
|
|
691
637
|
<View accessibilityRole="tablist" style={styles.chips}>
|
|
692
638
|
<Pressable
|
|
693
639
|
accessibilityRole="tab"
|
|
694
|
-
accessibilityState={{
|
|
695
|
-
|
|
696
|
-
setSelected(null);
|
|
697
|
-
setActiveOnly(false);
|
|
698
|
-
setSavedView(null);
|
|
640
|
+
accessibilityState={{
|
|
641
|
+
selected: selected === null && !activeOnly && !savedView && !focusedPr,
|
|
699
642
|
}}
|
|
700
|
-
|
|
643
|
+
onPress={() => setFilter(null)}
|
|
644
|
+
style={[
|
|
645
|
+
styles.chip,
|
|
646
|
+
selected === null && !activeOnly && !savedView && !focusedPr && styles.chipActive,
|
|
647
|
+
]}
|
|
701
648
|
>
|
|
702
649
|
<Text
|
|
703
650
|
style={[
|
|
704
651
|
styles.chipText,
|
|
705
|
-
selected === null && !activeOnly && !savedView && styles.chipTextActive,
|
|
652
|
+
selected === null && !activeOnly && !savedView && !focusedPr && styles.chipTextActive,
|
|
706
653
|
]}
|
|
707
654
|
>
|
|
708
655
|
All {rows.length}
|
|
@@ -713,11 +660,7 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
|
|
|
713
660
|
accessibilityRole="tab"
|
|
714
661
|
accessibilityState={{ selected: selected === bucket }}
|
|
715
662
|
key={bucket}
|
|
716
|
-
onPress={() =>
|
|
717
|
-
setSelected(bucket);
|
|
718
|
-
setActiveOnly(false);
|
|
719
|
-
setSavedView(null);
|
|
720
|
-
}}
|
|
663
|
+
onPress={() => setFilter(bucket)}
|
|
721
664
|
style={[styles.chip, selected === bucket && styles.chipActive]}
|
|
722
665
|
>
|
|
723
666
|
<Text style={[styles.chipText, selected === bucket && styles.chipTextActive]}>
|
|
@@ -728,11 +671,7 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
|
|
|
728
671
|
<Pressable
|
|
729
672
|
accessibilityRole="tab"
|
|
730
673
|
accessibilityState={{ selected: activeOnly }}
|
|
731
|
-
onPress={() =>
|
|
732
|
-
setSelected(null);
|
|
733
|
-
setActiveOnly(true);
|
|
734
|
-
setSavedView(null);
|
|
735
|
-
}}
|
|
674
|
+
onPress={() => setFilter("active")}
|
|
736
675
|
style={[styles.chip, activeOnly && styles.chipActive]}
|
|
737
676
|
>
|
|
738
677
|
<Text style={[styles.chipText, activeOnly && styles.chipTextActive]}>
|
|
@@ -744,11 +683,7 @@ export function PrRadar({ theme, layout, host, navigation }: PluginSurfaceProps)
|
|
|
744
683
|
accessibilityRole="tab"
|
|
745
684
|
accessibilityState={{ selected: savedView === view }}
|
|
746
685
|
key={view}
|
|
747
|
-
onPress={() =>
|
|
748
|
-
setSelected(null);
|
|
749
|
-
setActiveOnly(false);
|
|
750
|
-
setSavedView(view);
|
|
751
|
-
}}
|
|
686
|
+
onPress={() => setFilter(view)}
|
|
752
687
|
style={[styles.chip, savedView === view && styles.chipActive]}
|
|
753
688
|
>
|
|
754
689
|
<Text style={[styles.chipText, savedView === view && styles.chipTextActive]}>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { BUCKETS, type RadarBucket, type RadarRow } from "./radar";
|
|
2
|
+
|
|
3
|
+
export type RadarFilter = RadarBucket | "active" | "security" | "updated" | "stale" | "automation";
|
|
4
|
+
const FILTERS: readonly string[] = [
|
|
5
|
+
...BUCKETS,
|
|
6
|
+
"active",
|
|
7
|
+
"security",
|
|
8
|
+
"updated",
|
|
9
|
+
"stale",
|
|
10
|
+
"automation",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function parseRadarParams(params: Record<string, string>) {
|
|
14
|
+
return {
|
|
15
|
+
filter: FILTERS.includes(params.filter) ? (params.filter as RadarFilter) : null,
|
|
16
|
+
pr: params.pr?.toLowerCase() || null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const VIEWER_URL_LIMIT = 200;
|
|
21
|
+
export const DIRECTORY_PARTIAL = "Directory pagination limit reached; results are partial.";
|
|
22
|
+
export const LOOKUP_CAPPED = "Viewer lookup is limited to 200 linked pull requests.";
|
|
23
|
+
|
|
24
|
+
/** Reasons the queue may be incomplete. Any entry makes the sidebar count a lower bound ("N+"). */
|
|
25
|
+
export function radarWarnings(input: {
|
|
26
|
+
directoryError: boolean;
|
|
27
|
+
directoryTruncated: boolean;
|
|
28
|
+
workspaceWarnings: number;
|
|
29
|
+
viewerKnown: boolean;
|
|
30
|
+
viewerTruncated: boolean;
|
|
31
|
+
urlCount: number;
|
|
32
|
+
}) {
|
|
33
|
+
const warnings: string[] = [];
|
|
34
|
+
if (input.directoryError) warnings.push("Could not load the delivery queue.");
|
|
35
|
+
if (input.directoryTruncated) warnings.push(DIRECTORY_PARTIAL);
|
|
36
|
+
if (input.workspaceWarnings > 0)
|
|
37
|
+
warnings.push("Some workspaces have unavailable pull request status.");
|
|
38
|
+
if (!input.viewerKnown)
|
|
39
|
+
warnings.push("GitHub viewer identity is unavailable. Action buckets are conservative.");
|
|
40
|
+
if (input.urlCount > VIEWER_URL_LIMIT) warnings.push(LOOKUP_CAPPED);
|
|
41
|
+
if (input.viewerTruncated) warnings.push("Results reached the 100-item inbox cap.");
|
|
42
|
+
return warnings;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function needsYouSummary(
|
|
46
|
+
rows: readonly RadarRow[],
|
|
47
|
+
loading: boolean,
|
|
48
|
+
warnings: readonly string[],
|
|
49
|
+
) {
|
|
50
|
+
const items = rows.filter((row) => row.bucket === "needs-you");
|
|
51
|
+
return { items, label: loading ? "…" : `${items.length}${warnings.length ? "+" : ""}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function supportsRadarScreen(
|
|
55
|
+
client: { addScreen?: unknown; addSidebarHeaderItem?: unknown; openScreen?: unknown },
|
|
56
|
+
sidebarRow: unknown,
|
|
57
|
+
) {
|
|
58
|
+
return (
|
|
59
|
+
typeof client.addScreen === "function" &&
|
|
60
|
+
typeof client.addSidebarHeaderItem === "function" &&
|
|
61
|
+
typeof client.openScreen === "function" &&
|
|
62
|
+
typeof sidebarRow === "function"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { PluginPopoverProps, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
3
|
+
import { Pressable, ScrollView, Text } from "react-native";
|
|
4
|
+
import { needsYouSummary } from "./screen-state";
|
|
5
|
+
import { useRadar } from "./use-radar";
|
|
6
|
+
|
|
7
|
+
function NeedsYouPopover({ theme, layout, host, openScreen }: PluginPopoverProps) {
|
|
8
|
+
const queue = useRadar(host.id, 30, false);
|
|
9
|
+
const { items } = needsYouSummary(queue.rows, queue.loading, queue.warnings);
|
|
10
|
+
return (
|
|
11
|
+
<ScrollView
|
|
12
|
+
style={{ maxHeight: layout.compact ? 460 : 400 }}
|
|
13
|
+
contentContainerStyle={{ padding: 16, gap: 12 }}
|
|
14
|
+
>
|
|
15
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 18, fontWeight: "700" }}>
|
|
16
|
+
Needs you
|
|
17
|
+
</Text>
|
|
18
|
+
{queue.viewerData ? (
|
|
19
|
+
<Text style={{ color: theme.colors.foregroundMuted }}>{queue.viewerData.coverageNote}</Text>
|
|
20
|
+
) : null}
|
|
21
|
+
{queue.warnings.map((warning) => (
|
|
22
|
+
<Text key={warning} style={{ color: theme.colors.statusWarning }}>
|
|
23
|
+
{warning}
|
|
24
|
+
</Text>
|
|
25
|
+
))}
|
|
26
|
+
{queue.loading ? (
|
|
27
|
+
<Text style={{ color: theme.colors.foregroundMuted }}>Scanning pull requests…</Text>
|
|
28
|
+
) : null}
|
|
29
|
+
{!queue.loading && !items.length ? (
|
|
30
|
+
<Text style={{ color: theme.colors.foregroundMuted }}>
|
|
31
|
+
{queue.warnings.length
|
|
32
|
+
? "No known items need you in the available results."
|
|
33
|
+
: "No pull requests need you in this scope."}
|
|
34
|
+
</Text>
|
|
35
|
+
) : null}
|
|
36
|
+
{!queue.loading &&
|
|
37
|
+
items.map((row) => (
|
|
38
|
+
<Pressable
|
|
39
|
+
key={row.id}
|
|
40
|
+
accessibilityRole="button"
|
|
41
|
+
onPress={() => openScreen({ screenId: "radar", params: { pr: row.id } })}
|
|
42
|
+
style={{
|
|
43
|
+
paddingVertical: 10,
|
|
44
|
+
borderTopWidth: 1,
|
|
45
|
+
borderColor: theme.colors.border,
|
|
46
|
+
gap: 4,
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<Text style={{ color: theme.colors.foreground, fontWeight: "600" }}>
|
|
50
|
+
{row.repository}#{row.number} · {row.title}
|
|
51
|
+
</Text>
|
|
52
|
+
<Text style={{ color: theme.colors.foregroundMuted }}>{row.reason}</Text>
|
|
53
|
+
</Pressable>
|
|
54
|
+
))}
|
|
55
|
+
<Pressable
|
|
56
|
+
accessibilityRole="button"
|
|
57
|
+
onPress={() => openScreen({ screenId: "radar", params: { filter: "needs-you" } })}
|
|
58
|
+
style={{ paddingVertical: 10 }}
|
|
59
|
+
>
|
|
60
|
+
<Text style={{ color: theme.colors.accent }}>Open needs-you queue</Text>
|
|
61
|
+
</Pressable>
|
|
62
|
+
</ScrollView>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function RadarSidebar({
|
|
67
|
+
theme,
|
|
68
|
+
host,
|
|
69
|
+
currentScreen,
|
|
70
|
+
openScreen,
|
|
71
|
+
openPopover,
|
|
72
|
+
}: PluginSidebarItemProps) {
|
|
73
|
+
const queue = useRadar(host.id, 30, currentScreen?.screenId !== "radar");
|
|
74
|
+
const summary = needsYouSummary(queue.rows, queue.loading, queue.warnings);
|
|
75
|
+
return (
|
|
76
|
+
<SidebarRow
|
|
77
|
+
icon="GitPullRequest"
|
|
78
|
+
active={currentScreen?.screenId === "radar"}
|
|
79
|
+
onPress={() => openScreen({ screenId: "radar" })}
|
|
80
|
+
trailing={
|
|
81
|
+
<Pressable
|
|
82
|
+
accessibilityRole="button"
|
|
83
|
+
accessibilityLabel={`Needs you: ${summary.label}${queue.warnings.length ? "; incomplete results" : ""}`}
|
|
84
|
+
onPress={() => openPopover(NeedsYouPopover)}
|
|
85
|
+
style={{ paddingHorizontal: 8, paddingVertical: 6 }}
|
|
86
|
+
>
|
|
87
|
+
<Text
|
|
88
|
+
style={{
|
|
89
|
+
color: queue.warnings.length ? theme.colors.statusWarning : theme.colors.foreground,
|
|
90
|
+
fontSize: 12,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{summary.label} needs you
|
|
94
|
+
</Text>
|
|
95
|
+
</Pressable>
|
|
96
|
+
}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useEffect, useMemo } from "react";
|
|
4
|
+
import { viewerScope } from "../shared/viewer-scope";
|
|
5
|
+
import { observeDirectoryInvalidation } from "./directory-observation";
|
|
6
|
+
import {
|
|
7
|
+
type AgentEntry,
|
|
8
|
+
applyViewerScope,
|
|
9
|
+
buildRadarSnapshot,
|
|
10
|
+
mergeInboxRows,
|
|
11
|
+
type PaseoApi,
|
|
12
|
+
type PaseoWorkspace,
|
|
13
|
+
} from "./radar";
|
|
14
|
+
import { radarWarnings, VIEWER_URL_LIMIT } from "./screen-state";
|
|
15
|
+
|
|
16
|
+
const PAGE_LIMIT = 200;
|
|
17
|
+
const MAX_PAGES = 10;
|
|
18
|
+
|
|
19
|
+
async function loadAgents(paseo: PaseoApi) {
|
|
20
|
+
const entries: AgentEntry[] = [];
|
|
21
|
+
let cursor: string | undefined;
|
|
22
|
+
let truncated = false;
|
|
23
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
24
|
+
const result = await paseo.agents.list({
|
|
25
|
+
sort: [{ key: "updated_at", direction: "desc" }],
|
|
26
|
+
page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
|
27
|
+
});
|
|
28
|
+
entries.push(...result.entries);
|
|
29
|
+
truncated = result.pageInfo.hasMore;
|
|
30
|
+
cursor = result.pageInfo.nextCursor ?? undefined;
|
|
31
|
+
if (!truncated || !cursor) break;
|
|
32
|
+
}
|
|
33
|
+
return { entries, truncated };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function loadWorkspaces(paseo: PaseoApi) {
|
|
37
|
+
const entries: PaseoWorkspace[] = [];
|
|
38
|
+
let cursor: string | undefined;
|
|
39
|
+
let truncated = false;
|
|
40
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
41
|
+
const result = await paseo.workspaces.list({
|
|
42
|
+
page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
|
43
|
+
});
|
|
44
|
+
entries.push(...result.entries);
|
|
45
|
+
truncated = result.pageInfo.hasMore;
|
|
46
|
+
cursor = result.pageInfo.nextCursor ?? undefined;
|
|
47
|
+
if (!truncated || !cursor) break;
|
|
48
|
+
}
|
|
49
|
+
return { entries, truncated };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `live` owns the refresh timers. The sidebar is live unless the screen is open (the screen is
|
|
53
|
+
// then live); popovers only observe the cache.
|
|
54
|
+
export function useRadar(hostId: string, windowDays = 30, live = true) {
|
|
55
|
+
const paseo = usePaseo();
|
|
56
|
+
const queryClient = useQueryClient();
|
|
57
|
+
const resolveViewerScope = useRpc(viewerScope);
|
|
58
|
+
const queryKey = useMemo(() => ["pr-radar", hostId], [hostId]);
|
|
59
|
+
const directory = useQuery({
|
|
60
|
+
queryKey,
|
|
61
|
+
queryFn: async () => {
|
|
62
|
+
const [workspaces, agents] = await Promise.all([loadWorkspaces(paseo), loadAgents(paseo)]);
|
|
63
|
+
return {
|
|
64
|
+
...buildRadarSnapshot(workspaces.entries, agents.entries),
|
|
65
|
+
truncated: workspaces.truncated || agents.truncated,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
staleTime: 60_000,
|
|
69
|
+
refetchInterval: live ? 60_000 : false,
|
|
70
|
+
});
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!live) return;
|
|
73
|
+
return observeDirectoryInvalidation(
|
|
74
|
+
paseo,
|
|
75
|
+
() => void queryClient.invalidateQueries({ queryKey }),
|
|
76
|
+
500,
|
|
77
|
+
);
|
|
78
|
+
}, [live, paseo, queryClient, queryKey]);
|
|
79
|
+
const rawRows = directory.data?.rows ?? [];
|
|
80
|
+
// One deduped, ordered, capped list feeds both the query key and the request.
|
|
81
|
+
const scopeUrls = useMemo(
|
|
82
|
+
() => [...new Set(rawRows.map((row) => row.url))].sort().slice(0, VIEWER_URL_LIMIT),
|
|
83
|
+
[rawRows],
|
|
84
|
+
);
|
|
85
|
+
const viewer = useQuery({
|
|
86
|
+
queryKey: ["pr-radar-viewer-scope", hostId, scopeUrls, windowDays],
|
|
87
|
+
queryFn: () => resolveViewerScope({ urls: scopeUrls, windowDays }),
|
|
88
|
+
enabled: Boolean(directory.data),
|
|
89
|
+
staleTime: 5 * 60_000,
|
|
90
|
+
refetchInterval: live ? 5 * 60_000 : false,
|
|
91
|
+
});
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!live) return;
|
|
94
|
+
// Timers pause while not live; catch up on resume instead of waiting out the interval.
|
|
95
|
+
void queryClient.refetchQueries({ queryKey, stale: true });
|
|
96
|
+
void queryClient.refetchQueries({ queryKey: ["pr-radar-viewer-scope", hostId], stale: true });
|
|
97
|
+
}, [live, queryClient, queryKey, hostId]);
|
|
98
|
+
const rows = useMemo(
|
|
99
|
+
() =>
|
|
100
|
+
applyViewerScope(
|
|
101
|
+
directory.data ? mergeInboxRows(directory.data, viewer.data?.inboxItems ?? []) : [],
|
|
102
|
+
viewer.data ?? null,
|
|
103
|
+
),
|
|
104
|
+
[directory.data, viewer.data],
|
|
105
|
+
);
|
|
106
|
+
const warnings = radarWarnings({
|
|
107
|
+
directoryError: Boolean(directory.error),
|
|
108
|
+
directoryTruncated: Boolean(directory.data?.truncated),
|
|
109
|
+
workspaceWarnings: directory.data?.warnings.length ?? 0,
|
|
110
|
+
viewerKnown: Boolean(viewer.data?.viewer) && !viewer.data?.error && !viewer.error,
|
|
111
|
+
viewerTruncated: Boolean(viewer.data?.truncated),
|
|
112
|
+
urlCount: new Set(rawRows.map((row) => row.url)).size,
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
paseo,
|
|
116
|
+
queryKey,
|
|
117
|
+
rows,
|
|
118
|
+
rawRows,
|
|
119
|
+
warnings,
|
|
120
|
+
data: directory.data,
|
|
121
|
+
error: directory.error,
|
|
122
|
+
isPending: directory.isPending,
|
|
123
|
+
isFetching: directory.isFetching || viewer.isFetching,
|
|
124
|
+
refetch: directory.refetch,
|
|
125
|
+
viewerData: viewer.data,
|
|
126
|
+
viewerError: viewer.data?.error ?? viewer.error,
|
|
127
|
+
refetchViewer: viewer.refetch,
|
|
128
|
+
loading: directory.isPending || (Boolean(directory.data) && viewer.isPending),
|
|
129
|
+
};
|
|
130
|
+
}
|
package/index.client.tsx
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
2
3
|
import { PrRadar } from "./client/pr-radar";
|
|
4
|
+
import { supportsRadarScreen } from "./client/screen-state";
|
|
5
|
+
import { RadarSidebar } from "./client/sidebar";
|
|
3
6
|
|
|
4
7
|
export default function contribute(client: PluginClientContext) {
|
|
5
|
-
client
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
const screens = supportsRadarScreen(client, SidebarRow);
|
|
9
|
+
if (screens) {
|
|
10
|
+
client.addScreen({
|
|
11
|
+
id: "radar",
|
|
12
|
+
title: "PR Radar",
|
|
13
|
+
Component: PrRadar,
|
|
14
|
+
});
|
|
15
|
+
client.addSidebarHeaderItem({ id: "radar", title: "PR Radar", Component: RadarSidebar });
|
|
16
|
+
} else {
|
|
17
|
+
client.addSurface("radar", PrRadar);
|
|
18
|
+
client.addSidebarItem({
|
|
19
|
+
id: "radar",
|
|
20
|
+
title: "PR Radar",
|
|
21
|
+
icon: "GitPullRequest",
|
|
22
|
+
surface: "radar",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
12
25
|
client.addCommandCenterItem({
|
|
13
26
|
id: "open-radar",
|
|
14
27
|
title: "Open PR Radar",
|
|
15
28
|
icon: "GitPullRequest",
|
|
16
29
|
keywords: ["pull requests", "delivery", "merge", "agents"],
|
|
17
30
|
context: "global",
|
|
18
|
-
onSelect(
|
|
19
|
-
|
|
31
|
+
onSelect(capabilities) {
|
|
32
|
+
if (screens && typeof capabilities.openScreen === "function")
|
|
33
|
+
capabilities.openScreen({ screenId: "radar" });
|
|
34
|
+
else capabilities.openSurface("radar");
|
|
20
35
|
},
|
|
21
36
|
});
|
|
22
37
|
return () => {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-pr-radar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0-next.223.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A viewer-aware delivery queue for pull requests linked to Paseo workspaces.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@biomejs/biome": "^2.5.10",
|
|
49
|
-
"@getpaseo/cli": "0.
|
|
50
|
-
"@getpaseo/client": "0.
|
|
51
|
-
"@getpaseo/plugin": "0.
|
|
52
|
-
"@getpaseo/protocol": "0.
|
|
49
|
+
"@getpaseo/cli": "0.11.0-beta.5",
|
|
50
|
+
"@getpaseo/client": "0.11.0-beta.5",
|
|
51
|
+
"@getpaseo/plugin": "0.11.0-beta.5",
|
|
52
|
+
"@getpaseo/protocol": "0.11.0-beta.5",
|
|
53
53
|
"@tanstack/react-query": "^5.102.3",
|
|
54
54
|
"@types/node": "^24.10.1",
|
|
55
55
|
"@types/react": "^19.2.18",
|
package/paseo-plugin.json
CHANGED
package/server/viewer-scope.ts
CHANGED
|
@@ -11,9 +11,13 @@ const SEARCH_LIMIT = 100;
|
|
|
11
11
|
const ENRICHMENT_BATCH_SIZE = 20;
|
|
12
12
|
const COMMAND_TIMEOUT_MS = 15_000;
|
|
13
13
|
const MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
14
|
-
const statePath = join(homedir(), ".paseo", "plugin-data", "pr-radar", "inbox-state.json");
|
|
15
14
|
const SCOPE_SEARCH_LIMIT = 1000;
|
|
16
15
|
|
|
16
|
+
function statePath(): string {
|
|
17
|
+
const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
18
|
+
return join(home, "plugin-data", "pr-radar", "inbox-state.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
interface SearchRecord {
|
|
18
22
|
id: string;
|
|
19
23
|
number: number;
|
|
@@ -254,7 +258,7 @@ function toInboxItem(
|
|
|
254
258
|
|
|
255
259
|
async function readState(): Promise<StoredState> {
|
|
256
260
|
try {
|
|
257
|
-
const state = JSON.parse(await readFile(statePath, "utf8")) as StoredState;
|
|
261
|
+
const state = JSON.parse(await readFile(statePath(), "utf8")) as StoredState;
|
|
258
262
|
if (state.version === 2 && state.windows && typeof state.windows === "object") return state;
|
|
259
263
|
} catch (error) {
|
|
260
264
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
@@ -265,10 +269,11 @@ async function readState(): Promise<StoredState> {
|
|
|
265
269
|
}
|
|
266
270
|
|
|
267
271
|
async function writeState(state: StoredState): Promise<void> {
|
|
268
|
-
|
|
269
|
-
|
|
272
|
+
const path = statePath();
|
|
273
|
+
await mkdir(dirname(path), { recursive: true });
|
|
274
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
270
275
|
await writeFile(temporaryPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
271
|
-
await rename(temporaryPath,
|
|
276
|
+
await rename(temporaryPath, path);
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
function storedItem(item: GitHubInboxItem): StoredItem {
|