@omercnet/paseo-pr-radar 0.3.4-next.72.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/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@omercnet/paseo-pr-radar",
3
+ "version": "0.3.4-next.72.1",
4
+ "type": "module",
5
+ "description": "A viewer-aware delivery queue for pull requests linked to Paseo workspaces.",
6
+ "license": "MIT",
7
+ "author": "Omer Cohen",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/omercnet/paseo-plugins.git",
11
+ "directory": "pr-radar"
12
+ },
13
+ "homepage": "https://github.com/omercnet/paseo-plugins/tree/main/pr-radar#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/omercnet/paseo-plugins/issues"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "packageManager": "npm@11.19.1",
21
+ "engines": {
22
+ "node": ">=24"
23
+ },
24
+ "keywords": [
25
+ "paseo",
26
+ "paseo-plugin",
27
+ "pull-requests",
28
+ "coding-agents"
29
+ ],
30
+ "files": [
31
+ "index.client.tsx",
32
+ "index.server.ts",
33
+ "client",
34
+ "server",
35
+ "shared",
36
+ "docs",
37
+ "paseo-plugin.json",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "check": "biome check .",
43
+ "check:write": "biome check --write .",
44
+ "test": "vitest run",
45
+ "test:coverage": "vitest run --coverage --coverage.reporter=text --coverage.reporter=lcov --coverage.thresholds.lines=90 --coverage.thresholds.functions=85",
46
+ "typecheck": "tsc --noEmit",
47
+ "package:release": "node scripts/package-release.ts"
48
+ },
49
+ "devDependencies": {
50
+ "@biomejs/biome": "^2.5.10",
51
+ "@getpaseo/cli": "0.8.0",
52
+ "@getpaseo/client": "0.8.0",
53
+ "@getpaseo/plugin": "0.8.0",
54
+ "@getpaseo/protocol": "0.8.0",
55
+ "@tanstack/react-query": "^5.102.3",
56
+ "@types/node": "^24.5.2",
57
+ "@types/react": "~19.2.0",
58
+ "@vitest/coverage-v8": "^5.0.0",
59
+ "fflate": "^0.8.3",
60
+ "react": "19.1.0",
61
+ "react-native": "0.81.5",
62
+ "typescript": "^7.0.0",
63
+ "vitest": "^5.0.0",
64
+ "zod": "^4.4.3"
65
+ }
66
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "id": "pr-radar",
3
+ "requirements": { "paseo": "^0.8.0" }
4
+ }
@@ -0,0 +1,402 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import type { z } from "zod";
7
+ import type { acknowledgeViewerScope, GitHubInboxItem, viewerScope } from "../shared/viewer-scope";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const SEARCH_LIMIT = 100;
11
+ const ENRICHMENT_BATCH_SIZE = 20;
12
+ const COMMAND_TIMEOUT_MS = 15_000;
13
+ const MAX_BUFFER_BYTES = 16 * 1024 * 1024;
14
+ const statePath = join(homedir(), ".paseo", "plugin-data", "pr-radar", "inbox-state.json");
15
+ const SCOPE_SEARCH_LIMIT = 1000;
16
+
17
+ interface SearchRecord {
18
+ id: string;
19
+ number: number;
20
+ title: string;
21
+ url: string;
22
+ isDraft: boolean;
23
+ createdAt: string;
24
+ updatedAt: string;
25
+ author: { login: string; is_bot?: boolean; type?: string } | null;
26
+ repository: { nameWithOwner: string };
27
+ commentsCount: number;
28
+ labels: Array<{ name: string }>;
29
+ }
30
+
31
+ interface Enrichment {
32
+ id: string;
33
+ baseRefName: string;
34
+ headRefName: string;
35
+ reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
36
+ mergeable: "UNKNOWN" | "MERGEABLE" | "CONFLICTING";
37
+ mergeStateStatus: string;
38
+ statusCheckRollup: { state: "SUCCESS" | "FAILURE" | "ERROR" | "PENDING" | "EXPECTED" } | null;
39
+ }
40
+
41
+ interface StoredItem {
42
+ updatedAt: string;
43
+ checksStatus: GitHubInboxItem["checksStatus"];
44
+ reviewDecision: GitHubInboxItem["reviewDecision"];
45
+ mergeable: GitHubInboxItem["mergeable"];
46
+ mergeStateStatus: string | null;
47
+ }
48
+
49
+ interface StoredWindow {
50
+ items: Record<string, StoredItem>;
51
+ pendingChanges: Record<string, string[]>;
52
+ acknowledgedAt: string | null;
53
+ }
54
+
55
+ interface StoredState {
56
+ version: 2;
57
+ windows: Record<string, StoredWindow>;
58
+ }
59
+
60
+ async function runGh(args: string[], timeout = COMMAND_TIMEOUT_MS): Promise<string> {
61
+ const { stdout } = await execFileAsync("gh", args, {
62
+ timeout,
63
+ maxBuffer: MAX_BUFFER_BYTES,
64
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
65
+ });
66
+ return stdout.trim();
67
+ }
68
+
69
+ function delay(milliseconds: number): Promise<void> {
70
+ const { promise, resolve } = Promise.withResolvers<void>();
71
+ setTimeout(resolve, milliseconds);
72
+ return promise;
73
+ }
74
+
75
+ async function viewerLogin(): Promise<string> {
76
+ let lastError: unknown;
77
+ for (let attempt = 0; attempt < 2; attempt += 1) {
78
+ try {
79
+ const login = await runGh(["api", "user", "--jq", ".login"]);
80
+ if (login) return login;
81
+ } catch (error) {
82
+ lastError = error;
83
+ if (attempt === 0) await delay(250);
84
+ }
85
+ }
86
+ throw lastError instanceof Error
87
+ ? lastError
88
+ : new Error("GitHub CLI is not authenticated on this Paseo host.");
89
+ }
90
+
91
+ async function searchPullRequests(
92
+ filter: "author" | "review-requested",
93
+ viewer: string,
94
+ since: string,
95
+ ): Promise<SearchRecord[]> {
96
+ const fields = [
97
+ "id",
98
+ "number",
99
+ "title",
100
+ "url",
101
+ "isDraft",
102
+ "createdAt",
103
+ "updatedAt",
104
+ "author",
105
+ "repository",
106
+ "commentsCount",
107
+ "labels",
108
+ ].join(",");
109
+ const output = await runGh([
110
+ "search",
111
+ "prs",
112
+ `--${filter}=${viewer}`,
113
+ "--state=open",
114
+ "--archived=false",
115
+ `--updated=>=${since}`,
116
+ `--limit=${SEARCH_LIMIT}`,
117
+ "--sort=updated",
118
+ "--order=desc",
119
+ `--json=${fields}`,
120
+ ]);
121
+ return JSON.parse(output) as SearchRecord[];
122
+ }
123
+
124
+ async function searchScopeUrls(filter: "author" | "review-requested"): Promise<string[]> {
125
+ const output = await runGh([
126
+ "search",
127
+ "prs",
128
+ `--${filter}=@me`,
129
+ "--state=open",
130
+ "--archived=false",
131
+ `--limit=${SCOPE_SEARCH_LIMIT}`,
132
+ "--json=url",
133
+ ]);
134
+ return (JSON.parse(output) as Array<{ url: string }>).map(({ url }) => url);
135
+ }
136
+
137
+ const enrichmentQuery = `
138
+ query($ids: [ID!]!) {
139
+ nodes(ids: $ids) {
140
+ ... on PullRequest {
141
+ id
142
+ baseRefName
143
+ headRefName
144
+ reviewDecision
145
+ mergeable
146
+ mergeStateStatus
147
+ statusCheckRollup { state }
148
+ }
149
+ }
150
+ }
151
+ `;
152
+
153
+ function chunks<T>(items: T[], size: number): T[][] {
154
+ const result: T[][] = [];
155
+ for (let index = 0; index < items.length; index += size) {
156
+ result.push(items.slice(index, index + size));
157
+ }
158
+ return result;
159
+ }
160
+
161
+ async function enrich(
162
+ ids: string[],
163
+ ): Promise<{ states: Map<string, Enrichment>; failures: number }> {
164
+ const results = await Promise.allSettled(
165
+ chunks(ids, ENRICHMENT_BATCH_SIZE).map(async (batch) => {
166
+ const args = ["api", "graphql", "-f", `query=${enrichmentQuery}`];
167
+ for (const id of batch) args.push("-F", `ids[]=${id}`);
168
+ const payload = JSON.parse(await runGh(args, 10_000)) as {
169
+ data?: { nodes?: Array<Enrichment | null> };
170
+ errors?: Array<{ message: string }>;
171
+ };
172
+ if (!payload.data?.nodes) {
173
+ throw new Error(payload.errors?.map(({ message }) => message).join("; ") || "No data");
174
+ }
175
+ return payload.data.nodes.filter((item): item is Enrichment => item !== null);
176
+ }),
177
+ );
178
+ const states = new Map<string, Enrichment>();
179
+ let failures = 0;
180
+ for (const result of results) {
181
+ if (result.status === "rejected") {
182
+ failures += 1;
183
+ continue;
184
+ }
185
+ for (const item of result.value) states.set(item.id, item);
186
+ }
187
+ return { states, failures };
188
+ }
189
+
190
+ function checksStatus(state: Enrichment | undefined): GitHubInboxItem["checksStatus"] {
191
+ switch (state?.statusCheckRollup?.state) {
192
+ case "SUCCESS":
193
+ return "success";
194
+ case "FAILURE":
195
+ case "ERROR":
196
+ return "failure";
197
+ case "PENDING":
198
+ case "EXPECTED":
199
+ return "pending";
200
+ default:
201
+ return "none";
202
+ }
203
+ }
204
+
205
+ function reviewDecision(state: Enrichment | undefined): GitHubInboxItem["reviewDecision"] {
206
+ switch (state?.reviewDecision) {
207
+ case "APPROVED":
208
+ return "approved";
209
+ case "CHANGES_REQUESTED":
210
+ return "changes_requested";
211
+ case "REVIEW_REQUIRED":
212
+ return "pending";
213
+ default:
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function toInboxItem(
219
+ record: SearchRecord,
220
+ role: GitHubInboxItem["role"],
221
+ state: Enrichment | undefined,
222
+ ): GitHubInboxItem {
223
+ const labels = record.labels.map(({ name }) => name);
224
+ const authorKind =
225
+ record.author?.is_bot || record.author?.type === "Bot" || record.author?.login.endsWith("[bot]")
226
+ ? "bot"
227
+ : "human";
228
+ return {
229
+ id: record.id,
230
+ number: record.number,
231
+ url: record.url,
232
+ title: record.title,
233
+ repository: record.repository.nameWithOwner,
234
+ author: record.author?.login ?? null,
235
+ authorKind,
236
+ createdAt: record.createdAt,
237
+ updatedAt: record.updatedAt,
238
+ baseRefName: state?.baseRefName ?? "",
239
+ headRefName: state?.headRefName ?? "",
240
+ isDraft: record.isDraft,
241
+ isSecurity:
242
+ labels.some((label) => /security|vulnerability|cve/i.test(label)) ||
243
+ /security|vulnerabilit|\bcve\b/i.test(record.title),
244
+ comments: record.commentsCount,
245
+ labels,
246
+ mergeable: state?.mergeable ?? "UNKNOWN",
247
+ mergeStateStatus: state?.mergeStateStatus ?? null,
248
+ checksStatus: checksStatus(state),
249
+ reviewDecision: reviewDecision(state),
250
+ role,
251
+ changes: [],
252
+ };
253
+ }
254
+
255
+ async function readState(): Promise<StoredState> {
256
+ try {
257
+ const state = JSON.parse(await readFile(statePath, "utf8")) as StoredState;
258
+ if (state.version === 2 && state.windows && typeof state.windows === "object") return state;
259
+ } catch (error) {
260
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
261
+ console.error("PR Radar ignored unreadable inbox state", error);
262
+ }
263
+ }
264
+ return { version: 2, windows: {} };
265
+ }
266
+
267
+ async function writeState(state: StoredState): Promise<void> {
268
+ await mkdir(dirname(statePath), { recursive: true });
269
+ const temporaryPath = `${statePath}.${process.pid}.tmp`;
270
+ await writeFile(temporaryPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
271
+ await rename(temporaryPath, statePath);
272
+ }
273
+
274
+ function storedItem(item: GitHubInboxItem): StoredItem {
275
+ return {
276
+ updatedAt: item.updatedAt,
277
+ checksStatus: item.checksStatus,
278
+ reviewDecision: item.reviewDecision,
279
+ mergeable: item.mergeable,
280
+ mergeStateStatus: item.mergeStateStatus,
281
+ };
282
+ }
283
+
284
+ function detectChanges(previous: StoredItem | undefined, item: GitHubInboxItem): string[] {
285
+ if (!previous) return ["New PR"];
286
+ const changes = new Set<string>();
287
+ if (previous.updatedAt !== item.updatedAt) changes.add("New activity");
288
+ if (previous.checksStatus !== item.checksStatus) {
289
+ changes.add(`Checks: ${previous.checksStatus} → ${item.checksStatus}`);
290
+ }
291
+ if (previous.reviewDecision !== item.reviewDecision) {
292
+ changes.add(`Review: ${previous.reviewDecision ?? "none"} → ${item.reviewDecision ?? "none"}`);
293
+ }
294
+ if (previous.mergeable !== item.mergeable) {
295
+ changes.add(`Mergeable: ${previous.mergeable.toLowerCase()} → ${item.mergeable.toLowerCase()}`);
296
+ }
297
+ if (previous.mergeStateStatus !== item.mergeStateStatus) {
298
+ changes.add(
299
+ `Merge state: ${previous.mergeStateStatus ?? "unknown"} → ${item.mergeStateStatus ?? "unknown"}`,
300
+ );
301
+ }
302
+ return [...changes];
303
+ }
304
+
305
+ export async function resolveViewerScope({
306
+ urls,
307
+ windowDays,
308
+ }: z.output<typeof viewerScope.input>): Promise<z.input<typeof viewerScope.output>> {
309
+ try {
310
+ const viewer = await viewerLogin();
311
+ const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1_000)
312
+ .toISOString()
313
+ .slice(0, 10);
314
+ const [authored, reviewRequested, authoredScope, reviewRequestedScope, stored] =
315
+ await Promise.all([
316
+ searchPullRequests("author", viewer, since),
317
+ searchPullRequests("review-requested", viewer, since),
318
+ searchScopeUrls("author"),
319
+ searchScopeUrls("review-requested"),
320
+ readState(),
321
+ ]);
322
+ const records = new Map<string, { record: SearchRecord; role: GitHubInboxItem["role"] }>();
323
+ for (const record of reviewRequested) records.set(record.id, { record, role: "reviewer" });
324
+ for (const record of authored) records.set(record.id, { record, role: "author" });
325
+ const enrichment = await enrich([...records.keys()]);
326
+ const inboxItems = [...records.values()].map(({ record, role }) =>
327
+ toInboxItem(record, role, enrichment.states.get(record.id)),
328
+ );
329
+
330
+ const key = String(windowDays);
331
+ const previous = stored.windows[key] ?? { items: {}, pendingChanges: {}, acknowledgedAt: null };
332
+ const initialized = Object.keys(previous.items).length > 0;
333
+ const nextItems: Record<string, StoredItem> = {};
334
+ const nextPending: Record<string, string[]> = {};
335
+ for (const item of inboxItems) {
336
+ const prior = previous.items[item.id];
337
+ const detected =
338
+ initialized && (!prior || enrichment.states.has(item.id)) ? detectChanges(prior, item) : [];
339
+ item.changes = [...new Set([...(previous.pendingChanges[item.id] ?? []), ...detected])];
340
+ nextItems[item.id] = prior && !enrichment.states.has(item.id) ? prior : storedItem(item);
341
+ if (item.changes.length > 0) nextPending[item.id] = item.changes;
342
+ }
343
+ stored.windows[key] = {
344
+ items: nextItems,
345
+ pendingChanges: nextPending,
346
+ acknowledgedAt: previous.acknowledgedAt,
347
+ };
348
+ await writeState(stored);
349
+
350
+ const requestedUrls = new Set(urls.map((url) => url.toLowerCase()));
351
+ const authoredUrls = new Set([
352
+ ...authored.map(({ url }) => url),
353
+ ...authoredScope.filter((url) => requestedUrls.has(url.toLowerCase())),
354
+ ]);
355
+ const reviewRequestedUrls = new Set([
356
+ ...reviewRequested.map(({ url }) => url),
357
+ ...reviewRequestedScope.filter((url) => requestedUrls.has(url.toLowerCase())),
358
+ ]);
359
+ const coverageNote =
360
+ enrichment.failures > 0
361
+ ? `${enrichment.failures} GitHub detail request${enrichment.failures === 1 ? "" : "s"} failed; affected PRs use conservative states.`
362
+ : `Open PRs visible to the GitHub CLI. Organization SSO restrictions may omit results.`;
363
+ return {
364
+ viewer,
365
+ authoredUrls: [...authoredUrls],
366
+ reviewRequestedUrls: [...reviewRequestedUrls],
367
+ inboxItems,
368
+ truncated: authored.length === SEARCH_LIMIT || reviewRequested.length === SEARCH_LIMIT,
369
+ coverageNote,
370
+ updates: inboxItems.filter(({ changes }) => changes.length > 0).length,
371
+ acknowledgedAt: previous.acknowledgedAt,
372
+ error: null,
373
+ };
374
+ } catch (error) {
375
+ console.error("PR Radar GitHub inbox refresh failed", error);
376
+ return {
377
+ viewer: null,
378
+ authoredUrls: [],
379
+ reviewRequestedUrls: [],
380
+ inboxItems: [],
381
+ truncated: false,
382
+ coverageNote: "GitHub inbox data is unavailable.",
383
+ updates: 0,
384
+ acknowledgedAt: null,
385
+ error: error instanceof Error ? error.message : "GitHub viewer scope is unavailable.",
386
+ };
387
+ }
388
+ }
389
+
390
+ export async function acknowledgeViewerUpdates({
391
+ windowDays,
392
+ }: z.output<typeof acknowledgeViewerScope.input>): Promise<
393
+ z.input<typeof acknowledgeViewerScope.output>
394
+ > {
395
+ const state = await readState();
396
+ const key = String(windowDays);
397
+ const acknowledgedAt = new Date().toISOString();
398
+ const current = state.windows[key] ?? { items: {}, pendingChanges: {}, acknowledgedAt: null };
399
+ state.windows[key] = { ...current, pendingChanges: {}, acknowledgedAt };
400
+ await writeState(state);
401
+ return { acknowledgedAt };
402
+ }
@@ -0,0 +1,53 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const GitHubInboxItemSchema = z.object({
5
+ id: z.string(),
6
+ number: z.number().int().positive(),
7
+ url: z.url(),
8
+ title: z.string(),
9
+ repository: z.string(),
10
+ author: z.string().nullable(),
11
+ authorKind: z.enum(["human", "bot"]),
12
+ createdAt: z.string(),
13
+ updatedAt: z.string(),
14
+ baseRefName: z.string(),
15
+ headRefName: z.string(),
16
+ isDraft: z.boolean(),
17
+ isSecurity: z.boolean(),
18
+ comments: z.number().int().nonnegative(),
19
+ labels: z.array(z.string()),
20
+ mergeable: z.enum(["UNKNOWN", "MERGEABLE", "CONFLICTING"]),
21
+ mergeStateStatus: z.string().nullable(),
22
+ checksStatus: z.enum(["success", "pending", "none", "failure"]),
23
+ reviewDecision: z.enum(["pending", "approved", "changes_requested"]).nullable(),
24
+ role: z.enum(["author", "reviewer"]),
25
+ changes: z.array(z.string()),
26
+ });
27
+
28
+ export type GitHubInboxItem = z.infer<typeof GitHubInboxItemSchema>;
29
+
30
+ export const viewerScope = defineRpc({
31
+ name: "pr-radar.viewer-scope",
32
+ input: z.object({
33
+ urls: z.array(z.url()).max(200),
34
+ windowDays: z.number().int().min(1).max(365).default(30),
35
+ }),
36
+ output: z.object({
37
+ viewer: z.string().nullable(),
38
+ authoredUrls: z.array(z.url()),
39
+ reviewRequestedUrls: z.array(z.url()),
40
+ inboxItems: z.array(GitHubInboxItemSchema),
41
+ truncated: z.boolean(),
42
+ coverageNote: z.string(),
43
+ updates: z.number().int().nonnegative(),
44
+ acknowledgedAt: z.string().nullable(),
45
+ error: z.string().nullable(),
46
+ }),
47
+ });
48
+
49
+ export const acknowledgeViewerScope = defineRpc({
50
+ name: "pr-radar.acknowledge-updates",
51
+ input: z.object({ windowDays: z.number().int().min(1).max(365) }),
52
+ output: z.object({ acknowledgedAt: z.string() }),
53
+ });