@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/LICENSE +21 -0
- package/README.md +64 -0
- package/client/pr-radar.tsx +860 -0
- package/client/radar.ts +601 -0
- package/docs/images/pr-radar-compact.png +0 -0
- package/docs/images/pr-radar-github-inbox-compact.png +0 -0
- package/docs/images/pr-radar-github-inbox-wide.png +0 -0
- package/docs/images/pr-radar-wide.png +0 -0
- package/index.client.tsx +23 -0
- package/index.server.ts +9 -0
- package/package.json +66 -0
- package/paseo-plugin.json +4 -0
- package/server/viewer-scope.ts +402 -0
- package/shared/viewer-scope.ts +53 -0
package/client/radar.ts
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import type { usePaseo } from "@getpaseo/plugin/client";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { GitHubInboxItem } from "../shared/viewer-scope";
|
|
4
|
+
|
|
5
|
+
export type PaseoApi = ReturnType<typeof usePaseo>;
|
|
6
|
+
export type PaseoWorkspace = Awaited<ReturnType<PaseoApi["workspaces"]["list"]>>["entries"][number];
|
|
7
|
+
export type AgentEntry = Awaited<ReturnType<PaseoApi["agents"]["list"]>>["entries"][number];
|
|
8
|
+
export type RadarBucket = "needs-you" | "being-handled" | "waiting" | "ready";
|
|
9
|
+
|
|
10
|
+
export const BUCKETS: readonly RadarBucket[] = ["needs-you", "ready", "being-handled", "waiting"];
|
|
11
|
+
|
|
12
|
+
export const BUCKET_TITLES: Record<RadarBucket, string> = {
|
|
13
|
+
"needs-you": "Needs you",
|
|
14
|
+
ready: "Ready",
|
|
15
|
+
"being-handled": "Being handled",
|
|
16
|
+
waiting: "Waiting externally",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const BUCKET_ORDER: Record<RadarBucket, number> = {
|
|
20
|
+
"needs-you": 0,
|
|
21
|
+
ready: 1,
|
|
22
|
+
"being-handled": 2,
|
|
23
|
+
waiting: 3,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const GitHubFactsSchema = z.object({
|
|
27
|
+
mergeStateStatus: z.string().nullable().optional(),
|
|
28
|
+
isInMergeQueue: z.boolean().optional(),
|
|
29
|
+
});
|
|
30
|
+
const PullRequestFactsSchema = z.object({
|
|
31
|
+
github: GitHubFactsSchema.optional(),
|
|
32
|
+
forgeSpecific: GitHubFactsSchema.extend({ forge: z.literal("github") }).optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export interface RadarAgent {
|
|
36
|
+
id: string;
|
|
37
|
+
title: string;
|
|
38
|
+
status: AgentEntry["agent"]["status"];
|
|
39
|
+
requiresAttention: boolean;
|
|
40
|
+
attentionReason: string | null;
|
|
41
|
+
pendingPermissions: number;
|
|
42
|
+
updatedAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RadarCheck {
|
|
46
|
+
name: string;
|
|
47
|
+
status: "success" | "pending" | "failure" | "skipped" | "cancelled";
|
|
48
|
+
url: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ViewerOwnership = "mine" | "external" | "unknown";
|
|
52
|
+
|
|
53
|
+
export interface RadarRow {
|
|
54
|
+
id: string;
|
|
55
|
+
number: number | null;
|
|
56
|
+
url: string;
|
|
57
|
+
title: string;
|
|
58
|
+
repository: string;
|
|
59
|
+
baseRefName: string;
|
|
60
|
+
headRefName: string;
|
|
61
|
+
isDraft: boolean;
|
|
62
|
+
author: string | null;
|
|
63
|
+
authorKind: "human" | "bot";
|
|
64
|
+
isSecurity: boolean;
|
|
65
|
+
comments: number;
|
|
66
|
+
labels: string[];
|
|
67
|
+
changes: string[];
|
|
68
|
+
mergeable: "UNKNOWN" | "MERGEABLE" | "CONFLICTING";
|
|
69
|
+
mergeStateStatus: string | null;
|
|
70
|
+
checksStatus: "success" | "pending" | "none" | "failure";
|
|
71
|
+
reviewDecision: "pending" | "approved" | "changes_requested" | null;
|
|
72
|
+
checks: RadarCheck[];
|
|
73
|
+
workspaceIds: string[];
|
|
74
|
+
workspaceNames: string[];
|
|
75
|
+
localProjectRoot: string | null;
|
|
76
|
+
agents: RadarAgent[];
|
|
77
|
+
ownership: ViewerOwnership;
|
|
78
|
+
reviewRequestedFromMe: boolean;
|
|
79
|
+
bucket: RadarBucket;
|
|
80
|
+
reason: string;
|
|
81
|
+
activityAt: string | null;
|
|
82
|
+
refreshedAt: string | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface RadarWarning {
|
|
86
|
+
workspaceId: string;
|
|
87
|
+
workspaceName: string;
|
|
88
|
+
message: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RadarSnapshot {
|
|
92
|
+
rows: RadarRow[];
|
|
93
|
+
warnings: RadarWarning[];
|
|
94
|
+
workspaceCount: number;
|
|
95
|
+
refreshedAt: string;
|
|
96
|
+
repositoryRoots: Record<string, string>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ViewerScopeData {
|
|
100
|
+
authoredUrls: readonly string[];
|
|
101
|
+
reviewRequestedUrls: readonly string[];
|
|
102
|
+
error: string | null;
|
|
103
|
+
inboxItems: readonly GitHubInboxItem[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type RadarAgentAction =
|
|
107
|
+
| { kind: "ask"; agentId: string }
|
|
108
|
+
| { kind: "start"; workspaceId: string }
|
|
109
|
+
| { kind: "checkout"; cwd: string; number: number; repository: string }
|
|
110
|
+
| null;
|
|
111
|
+
|
|
112
|
+
function isOpenPullRequest(state: string, isMerged: boolean): boolean {
|
|
113
|
+
return !isMerged && state.toLowerCase() === "open";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function agentTitle(entry: AgentEntry): string {
|
|
117
|
+
return entry.agent.title?.trim() || entry.agent.id.slice(0, 7);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toRadarAgent(entry: AgentEntry): RadarAgent {
|
|
121
|
+
return {
|
|
122
|
+
id: entry.agent.id,
|
|
123
|
+
title: agentTitle(entry),
|
|
124
|
+
status: entry.agent.status,
|
|
125
|
+
requiresAttention: entry.agent.requiresAttention ?? false,
|
|
126
|
+
attentionReason: entry.agent.attentionReason ?? null,
|
|
127
|
+
pendingPermissions: entry.agent.pendingPermissions.length,
|
|
128
|
+
updatedAt: entry.agent.updatedAt,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function agentPriority(agent: RadarAgent): number {
|
|
133
|
+
if (agent.pendingPermissions > 0 || agent.requiresAttention || agent.status === "error") return 0;
|
|
134
|
+
if (agent.status === "running" || agent.status === "initializing") return 1;
|
|
135
|
+
if (agent.status === "idle") return 2;
|
|
136
|
+
return 3;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function sortAgents(agents: RadarAgent[]): void {
|
|
140
|
+
agents.sort((left, right) => {
|
|
141
|
+
const byPriority = agentPriority(left) - agentPriority(right);
|
|
142
|
+
if (byPriority !== 0) return byPriority;
|
|
143
|
+
return Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseRepository(url: string): string {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = new URL(url);
|
|
150
|
+
const [owner, repo] = parsed.pathname.split("/").filter(Boolean);
|
|
151
|
+
return owner && repo ? `${owner}/${repo}` : parsed.hostname;
|
|
152
|
+
} catch {
|
|
153
|
+
return "Unknown repository";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseRemoteRepository(remoteUrl: string): string | null {
|
|
158
|
+
const match = remoteUrl.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
159
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function hasActiveAgent(agents: readonly RadarAgent[]): boolean {
|
|
163
|
+
return agents.some((agent) => agent.status === "running" || agent.status === "initializing");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function attentionReason(agents: readonly RadarAgent[]): string | null {
|
|
167
|
+
const permission = agents.find((agent) => agent.pendingPermissions > 0);
|
|
168
|
+
if (permission) {
|
|
169
|
+
return permission.pendingPermissions === 1
|
|
170
|
+
? "Agent needs permission"
|
|
171
|
+
: `Agent needs ${permission.pendingPermissions} permissions`;
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
agents.some(
|
|
175
|
+
(agent) =>
|
|
176
|
+
agent.requiresAttention &&
|
|
177
|
+
agent.attentionReason !== "finished" &&
|
|
178
|
+
agent.attentionReason !== "error",
|
|
179
|
+
)
|
|
180
|
+
) {
|
|
181
|
+
return "Agent needs input";
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function actionableBlocker(row: RadarRow): string | null {
|
|
187
|
+
if (row.mergeable === "CONFLICTING" || row.mergeStateStatus === "DIRTY") {
|
|
188
|
+
return "Merge conflict";
|
|
189
|
+
}
|
|
190
|
+
if (row.checksStatus === "failure") return "Checks failing";
|
|
191
|
+
if (row.reviewDecision === "changes_requested") return "Changes requested";
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function externalWaitingReason(row: RadarRow): string {
|
|
196
|
+
if (row.reviewDecision === "changes_requested") return "Waiting on author changes";
|
|
197
|
+
if (row.mergeable === "CONFLICTING" || row.mergeStateStatus === "DIRTY") {
|
|
198
|
+
return "Waiting on author to resolve conflicts";
|
|
199
|
+
}
|
|
200
|
+
if (row.checksStatus === "failure") return "Waiting on author to fix checks";
|
|
201
|
+
if (row.reviewDecision === "pending") return "Waiting on reviewers";
|
|
202
|
+
return "External pull request";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function classifyRow(row: RadarRow): Pick<RadarRow, "bucket" | "reason"> {
|
|
206
|
+
const humanBlocker = attentionReason(row.agents);
|
|
207
|
+
if (humanBlocker) return { bucket: "needs-you", reason: humanBlocker };
|
|
208
|
+
|
|
209
|
+
const activeAgent = hasActiveAgent(row.agents);
|
|
210
|
+
if (
|
|
211
|
+
!activeAgent &&
|
|
212
|
+
row.agents.some((agent) => agent.status === "error" || agent.attentionReason === "error")
|
|
213
|
+
) {
|
|
214
|
+
return { bucket: "needs-you", reason: "Agent failed" };
|
|
215
|
+
}
|
|
216
|
+
if (activeAgent && row.ownership !== "mine") {
|
|
217
|
+
return { bucket: "being-handled", reason: "Agent working" };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (row.checksStatus === "pending") return { bucket: "waiting", reason: "Checks running" };
|
|
221
|
+
|
|
222
|
+
if (row.ownership === "external") {
|
|
223
|
+
const waitingReason = externalWaitingReason(row);
|
|
224
|
+
if (row.reviewRequestedFromMe && waitingReason === "Waiting on reviewers") {
|
|
225
|
+
return { bucket: "needs-you", reason: "Review requested" };
|
|
226
|
+
}
|
|
227
|
+
return { bucket: "waiting", reason: waitingReason };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (row.ownership === "unknown") {
|
|
231
|
+
return { bucket: "waiting", reason: "Viewer relationship unavailable" };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const blocker = actionableBlocker(row);
|
|
235
|
+
if (blocker) {
|
|
236
|
+
return activeAgent
|
|
237
|
+
? { bucket: "being-handled", reason: `${blocker}; agent working` }
|
|
238
|
+
: { bucket: "needs-you", reason: blocker };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (row.isDraft) {
|
|
242
|
+
return activeAgent
|
|
243
|
+
? { bucket: "being-handled", reason: "Draft; agent working" }
|
|
244
|
+
: { bucket: "waiting", reason: "Draft with no active agent" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (activeAgent) return { bucket: "being-handled", reason: "Agent working" };
|
|
248
|
+
if (row.reviewDecision === "pending")
|
|
249
|
+
return { bucket: "waiting", reason: "Waiting on reviewers" };
|
|
250
|
+
if (row.mergeable === "UNKNOWN") {
|
|
251
|
+
return { bucket: "waiting", reason: "Mergeability pending" };
|
|
252
|
+
}
|
|
253
|
+
if (row.mergeStateStatus === "BLOCKED") {
|
|
254
|
+
return { bucket: "waiting", reason: "Waiting on repository requirements" };
|
|
255
|
+
}
|
|
256
|
+
if (row.mergeStateStatus === "BEHIND") {
|
|
257
|
+
return { bucket: "needs-you", reason: "Branch behind base" };
|
|
258
|
+
}
|
|
259
|
+
if (row.mergeStateStatus === "UNSTABLE") {
|
|
260
|
+
return { bucket: "needs-you", reason: "Checks unstable" };
|
|
261
|
+
}
|
|
262
|
+
if (row.mergeStateStatus && !["CLEAN", "HAS_HOOKS"].includes(row.mergeStateStatus)) {
|
|
263
|
+
return { bucket: "waiting", reason: "Waiting on GitHub" };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (row.mergeable === "MERGEABLE" && ["success", "none"].includes(row.checksStatus)) {
|
|
267
|
+
return {
|
|
268
|
+
bucket: "ready",
|
|
269
|
+
reason: row.checksStatus === "none" ? "Mergeable; no checks" : "Checks passed; mergeable",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { bucket: "waiting", reason: "Waiting on repository status" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function agentActionFor(row: RadarRow): RadarAgentAction {
|
|
277
|
+
if (row.bucket !== "needs-you") return null;
|
|
278
|
+
if (
|
|
279
|
+
row.agents.some(
|
|
280
|
+
(agent) =>
|
|
281
|
+
agent.pendingPermissions > 0 ||
|
|
282
|
+
agent.status === "error" ||
|
|
283
|
+
(agent.requiresAttention && agent.attentionReason !== "finished"),
|
|
284
|
+
)
|
|
285
|
+
) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const promptable = row.agents.find(
|
|
289
|
+
(agent) =>
|
|
290
|
+
["idle", "running", "initializing"].includes(agent.status) &&
|
|
291
|
+
agent.pendingPermissions === 0 &&
|
|
292
|
+
agent.attentionReason !== "permission",
|
|
293
|
+
);
|
|
294
|
+
if (promptable) return { kind: "ask", agentId: promptable.id };
|
|
295
|
+
const workspaceId = row.workspaceIds[0];
|
|
296
|
+
if (workspaceId) return { kind: "start", workspaceId };
|
|
297
|
+
if (row.localProjectRoot && row.number) {
|
|
298
|
+
return {
|
|
299
|
+
kind: "checkout",
|
|
300
|
+
cwd: row.localProjectRoot,
|
|
301
|
+
number: row.number,
|
|
302
|
+
repository: row.repository,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function buildAgentPrompt(row: RadarRow): string {
|
|
309
|
+
if (row.reviewRequestedFromMe) {
|
|
310
|
+
return `Review ${row.url}. CI is complete and GitHub is requesting your review. Inspect the changes, follow the repository review workflow, submit the review when justified, and report the result.`;
|
|
311
|
+
}
|
|
312
|
+
return `Continue work on ${row.url}. Current state: ${row.reason}. Inspect the pull request and workspace, resolve the actionable blocker, run relevant validation, push the fix, and report the result. Do not merge the pull request.`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function buildRadarSnapshot(
|
|
316
|
+
workspaces: readonly PaseoWorkspace[],
|
|
317
|
+
entries: readonly AgentEntry[],
|
|
318
|
+
now = new Date(),
|
|
319
|
+
): RadarSnapshot {
|
|
320
|
+
const agentsByWorkspace = new Map<string, RadarAgent[]>();
|
|
321
|
+
for (const entry of entries) {
|
|
322
|
+
const workspaceId = entry.agent.workspaceId;
|
|
323
|
+
if (!workspaceId) continue;
|
|
324
|
+
const agents = agentsByWorkspace.get(workspaceId) ?? [];
|
|
325
|
+
agents.push(toRadarAgent(entry));
|
|
326
|
+
agentsByWorkspace.set(workspaceId, agents);
|
|
327
|
+
}
|
|
328
|
+
for (const agents of agentsByWorkspace.values()) sortAgents(agents);
|
|
329
|
+
|
|
330
|
+
const rows = new Map<string, RadarRow>();
|
|
331
|
+
const warnings: RadarWarning[] = [];
|
|
332
|
+
const repositoryRoots: Record<string, string> = {};
|
|
333
|
+
|
|
334
|
+
for (const workspace of workspaces) {
|
|
335
|
+
const remoteRepository = workspace.gitRuntime?.remoteUrl
|
|
336
|
+
? parseRemoteRepository(workspace.gitRuntime.remoteUrl)
|
|
337
|
+
: null;
|
|
338
|
+
if (remoteRepository) {
|
|
339
|
+
repositoryRoots[remoteRepository.toLowerCase()] ??= workspace.projectRootPath;
|
|
340
|
+
}
|
|
341
|
+
const runtime = workspace.githubRuntime;
|
|
342
|
+
if (runtime?.error?.message) {
|
|
343
|
+
warnings.push({
|
|
344
|
+
workspaceId: workspace.id,
|
|
345
|
+
workspaceName: workspace.name,
|
|
346
|
+
message: runtime.error.message,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const pullRequest = runtime?.pullRequest;
|
|
351
|
+
if (!pullRequest || !isOpenPullRequest(pullRequest.state, pullRequest.isMerged)) continue;
|
|
352
|
+
|
|
353
|
+
const repository =
|
|
354
|
+
pullRequest.repoOwner && pullRequest.repoName
|
|
355
|
+
? `${pullRequest.repoOwner}/${pullRequest.repoName}`
|
|
356
|
+
: parseRepository(pullRequest.url);
|
|
357
|
+
const id = pullRequest.number
|
|
358
|
+
? `${repository.toLowerCase()}#${pullRequest.number}`
|
|
359
|
+
: pullRequest.url;
|
|
360
|
+
const parsedFacts = PullRequestFactsSchema.safeParse(pullRequest);
|
|
361
|
+
const facts = parsedFacts.success
|
|
362
|
+
? (parsedFacts.data.forgeSpecific ?? parsedFacts.data.github ?? null)
|
|
363
|
+
: null;
|
|
364
|
+
const agents = agentsByWorkspace.get(workspace.id) ?? [];
|
|
365
|
+
const existing = rows.get(id);
|
|
366
|
+
|
|
367
|
+
if (existing) {
|
|
368
|
+
if (!existing.workspaceIds.includes(workspace.id)) {
|
|
369
|
+
existing.workspaceIds.push(workspace.id);
|
|
370
|
+
existing.workspaceNames.push(workspace.name);
|
|
371
|
+
}
|
|
372
|
+
const knownAgentIds = new Set(existing.agents.map((agent) => agent.id));
|
|
373
|
+
for (const agent of agents) {
|
|
374
|
+
if (!knownAgentIds.has(agent.id)) existing.agents.push(agent);
|
|
375
|
+
}
|
|
376
|
+
sortAgents(existing.agents);
|
|
377
|
+
if (Date.parse(workspace.activityAt ?? "") > Date.parse(existing.activityAt ?? "")) {
|
|
378
|
+
existing.activityAt = workspace.activityAt;
|
|
379
|
+
}
|
|
380
|
+
const classification = classifyRow(existing);
|
|
381
|
+
existing.bucket = classification.bucket;
|
|
382
|
+
existing.reason = classification.reason;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const row: RadarRow = {
|
|
387
|
+
id,
|
|
388
|
+
number: pullRequest.number ?? null,
|
|
389
|
+
url: pullRequest.url,
|
|
390
|
+
title: pullRequest.title,
|
|
391
|
+
repository,
|
|
392
|
+
baseRefName: pullRequest.baseRefName,
|
|
393
|
+
headRefName: pullRequest.headRefName,
|
|
394
|
+
isDraft: pullRequest.isDraft ?? false,
|
|
395
|
+
author: null,
|
|
396
|
+
authorKind: "human",
|
|
397
|
+
isSecurity: false,
|
|
398
|
+
comments: 0,
|
|
399
|
+
labels: [],
|
|
400
|
+
changes: [],
|
|
401
|
+
mergeable: pullRequest.mergeable ?? "UNKNOWN",
|
|
402
|
+
mergeStateStatus: facts?.mergeStateStatus ?? null,
|
|
403
|
+
checksStatus: pullRequest.checksStatus ?? "none",
|
|
404
|
+
reviewDecision: pullRequest.reviewDecision ?? null,
|
|
405
|
+
checks: pullRequest.checks ?? [],
|
|
406
|
+
workspaceIds: [workspace.id],
|
|
407
|
+
workspaceNames: [workspace.name],
|
|
408
|
+
localProjectRoot: repositoryRoots[repository.toLowerCase()] ?? workspace.projectRootPath,
|
|
409
|
+
agents: [...agents],
|
|
410
|
+
ownership: "unknown",
|
|
411
|
+
reviewRequestedFromMe: false,
|
|
412
|
+
bucket: "waiting",
|
|
413
|
+
reason: "Waiting on repository status",
|
|
414
|
+
activityAt: workspace.activityAt,
|
|
415
|
+
refreshedAt: runtime?.refreshedAt ?? null,
|
|
416
|
+
};
|
|
417
|
+
const classification = classifyRow(row);
|
|
418
|
+
row.bucket = classification.bucket;
|
|
419
|
+
row.reason = classification.reason;
|
|
420
|
+
rows.set(id, row);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const sorted = [...rows.values()].sort((left, right) => {
|
|
424
|
+
const byBucket = BUCKET_ORDER[left.bucket] - BUCKET_ORDER[right.bucket];
|
|
425
|
+
if (byBucket !== 0) return byBucket;
|
|
426
|
+
const byActivity = Date.parse(left.activityAt ?? "") - Date.parse(right.activityAt ?? "");
|
|
427
|
+
if (byActivity !== 0) return byActivity;
|
|
428
|
+
return left.title.localeCompare(right.title);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
rows: sorted,
|
|
433
|
+
warnings,
|
|
434
|
+
workspaceCount: workspaces.length,
|
|
435
|
+
refreshedAt: now.toISOString(),
|
|
436
|
+
repositoryRoots,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function mergeInboxRows(
|
|
441
|
+
snapshot: RadarSnapshot,
|
|
442
|
+
inboxItems: readonly GitHubInboxItem[],
|
|
443
|
+
): RadarRow[] {
|
|
444
|
+
const rows = new Map(snapshot.rows.map((row) => [row.id, { ...row }]));
|
|
445
|
+
for (const item of inboxItems) {
|
|
446
|
+
const id = `${item.repository.toLowerCase()}#${item.number}`;
|
|
447
|
+
const existing = rows.get(id);
|
|
448
|
+
if (existing) {
|
|
449
|
+
existing.title = item.title;
|
|
450
|
+
existing.url = item.url;
|
|
451
|
+
existing.author = item.author;
|
|
452
|
+
existing.authorKind = item.authorKind;
|
|
453
|
+
existing.isSecurity = item.isSecurity;
|
|
454
|
+
existing.comments = item.comments;
|
|
455
|
+
existing.labels = [...item.labels];
|
|
456
|
+
existing.changes = [...item.changes];
|
|
457
|
+
existing.isDraft = item.isDraft;
|
|
458
|
+
existing.baseRefName = item.baseRefName || existing.baseRefName;
|
|
459
|
+
existing.headRefName = item.headRefName || existing.headRefName;
|
|
460
|
+
existing.mergeable = item.mergeable;
|
|
461
|
+
existing.mergeStateStatus = item.mergeStateStatus;
|
|
462
|
+
existing.checksStatus = item.checksStatus;
|
|
463
|
+
existing.reviewDecision = item.reviewDecision;
|
|
464
|
+
existing.ownership = item.role === "author" ? "mine" : "external";
|
|
465
|
+
existing.reviewRequestedFromMe = item.role === "reviewer";
|
|
466
|
+
existing.localProjectRoot ??= snapshot.repositoryRoots[item.repository.toLowerCase()] ?? null;
|
|
467
|
+
if (Date.parse(item.updatedAt) > Date.parse(existing.activityAt ?? "")) {
|
|
468
|
+
existing.activityAt = item.updatedAt;
|
|
469
|
+
}
|
|
470
|
+
const classification = classifyRow(existing);
|
|
471
|
+
existing.bucket = classification.bucket;
|
|
472
|
+
existing.reason = classification.reason;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const row: RadarRow = {
|
|
477
|
+
id,
|
|
478
|
+
number: item.number,
|
|
479
|
+
url: item.url,
|
|
480
|
+
title: item.title,
|
|
481
|
+
repository: item.repository,
|
|
482
|
+
baseRefName: item.baseRefName,
|
|
483
|
+
headRefName: item.headRefName,
|
|
484
|
+
isDraft: item.isDraft,
|
|
485
|
+
author: item.author,
|
|
486
|
+
authorKind: item.authorKind,
|
|
487
|
+
isSecurity: item.isSecurity,
|
|
488
|
+
comments: item.comments,
|
|
489
|
+
labels: [...item.labels],
|
|
490
|
+
changes: [...item.changes],
|
|
491
|
+
mergeable: item.mergeable,
|
|
492
|
+
mergeStateStatus: item.mergeStateStatus,
|
|
493
|
+
checksStatus: item.checksStatus,
|
|
494
|
+
reviewDecision: item.reviewDecision,
|
|
495
|
+
checks: [],
|
|
496
|
+
workspaceIds: [],
|
|
497
|
+
workspaceNames: [],
|
|
498
|
+
localProjectRoot: snapshot.repositoryRoots[item.repository.toLowerCase()] ?? null,
|
|
499
|
+
agents: [],
|
|
500
|
+
ownership: item.role === "author" ? "mine" : "external",
|
|
501
|
+
reviewRequestedFromMe: item.role === "reviewer",
|
|
502
|
+
bucket: "waiting",
|
|
503
|
+
reason: "Waiting on repository status",
|
|
504
|
+
activityAt: item.updatedAt,
|
|
505
|
+
refreshedAt: item.updatedAt,
|
|
506
|
+
};
|
|
507
|
+
const classification = classifyRow(row);
|
|
508
|
+
row.bucket = classification.bucket;
|
|
509
|
+
row.reason = classification.reason;
|
|
510
|
+
rows.set(id, row);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return [...rows.values()].sort((left, right) => {
|
|
514
|
+
const byBucket = BUCKET_ORDER[left.bucket] - BUCKET_ORDER[right.bucket];
|
|
515
|
+
if (byBucket !== 0) return byBucket;
|
|
516
|
+
const byActivity = Date.parse(right.activityAt ?? "") - Date.parse(left.activityAt ?? "");
|
|
517
|
+
if (byActivity !== 0) return byActivity;
|
|
518
|
+
return left.title.localeCompare(right.title);
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function applyViewerScope(
|
|
523
|
+
rows: readonly RadarRow[],
|
|
524
|
+
scope: ViewerScopeData | null,
|
|
525
|
+
): RadarRow[] {
|
|
526
|
+
const authored = new Set(scope?.error ? [] : scope?.authoredUrls.map((url) => url.toLowerCase()));
|
|
527
|
+
const reviewRequested = new Set(
|
|
528
|
+
scope?.error ? [] : scope?.reviewRequestedUrls.map((url) => url.toLowerCase()),
|
|
529
|
+
);
|
|
530
|
+
const ownershipAvailable = scope !== null && scope.error === null;
|
|
531
|
+
const result = rows.map((row) => {
|
|
532
|
+
const url = row.url.toLowerCase();
|
|
533
|
+
const updated: RadarRow = {
|
|
534
|
+
...row,
|
|
535
|
+
ownership: ownershipAvailable ? (authored.has(url) ? "mine" : "external") : "unknown",
|
|
536
|
+
reviewRequestedFromMe: ownershipAvailable && reviewRequested.has(url),
|
|
537
|
+
};
|
|
538
|
+
const classification = classifyRow(updated);
|
|
539
|
+
updated.bucket = classification.bucket;
|
|
540
|
+
updated.reason = classification.reason;
|
|
541
|
+
return updated;
|
|
542
|
+
});
|
|
543
|
+
result.sort((left, right) => {
|
|
544
|
+
const byBucket = BUCKET_ORDER[left.bucket] - BUCKET_ORDER[right.bucket];
|
|
545
|
+
if (byBucket !== 0) return byBucket;
|
|
546
|
+
const byActivity = Date.parse(left.activityAt ?? "") - Date.parse(right.activityAt ?? "");
|
|
547
|
+
if (byActivity !== 0) return byActivity;
|
|
548
|
+
return left.title.localeCompare(right.title);
|
|
549
|
+
});
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export function matchesRow(row: RadarRow, needle: string): boolean {
|
|
554
|
+
const query = needle.trim().toLowerCase();
|
|
555
|
+
if (!query) return true;
|
|
556
|
+
return [
|
|
557
|
+
row.repository,
|
|
558
|
+
row.number?.toString() ?? "",
|
|
559
|
+
row.title,
|
|
560
|
+
row.headRefName,
|
|
561
|
+
row.baseRefName,
|
|
562
|
+
row.reason,
|
|
563
|
+
...row.workspaceNames,
|
|
564
|
+
...row.agents.map((agent) => agent.title),
|
|
565
|
+
]
|
|
566
|
+
.join(" ")
|
|
567
|
+
.toLowerCase()
|
|
568
|
+
.includes(query);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function formatAge(value: string | null, now: number): string {
|
|
572
|
+
if (!value) return "";
|
|
573
|
+
const timestamp = Date.parse(value);
|
|
574
|
+
if (Number.isNaN(timestamp)) return "";
|
|
575
|
+
const minutes = Math.max(0, Math.floor((now - timestamp) / 60_000));
|
|
576
|
+
if (minutes < 1) return "now";
|
|
577
|
+
if (minutes < 60) return `${minutes}m`;
|
|
578
|
+
const hours = Math.floor(minutes / 60);
|
|
579
|
+
if (hours < 24) return `${hours}h`;
|
|
580
|
+
return `${Math.floor(hours / 24)}d`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function checkSummary(row: RadarRow): string {
|
|
584
|
+
if (row.checks.length === 0) {
|
|
585
|
+
if (row.checksStatus === "none") return "No checks";
|
|
586
|
+
return row.checksStatus === "success"
|
|
587
|
+
? "Checks passed"
|
|
588
|
+
: row.checksStatus === "failure"
|
|
589
|
+
? "Checks failing"
|
|
590
|
+
: "Checks running";
|
|
591
|
+
}
|
|
592
|
+
const passed = row.checks.filter(
|
|
593
|
+
(check) => check.status === "success" || check.status === "skipped",
|
|
594
|
+
).length;
|
|
595
|
+
const failed = row.checks.filter(
|
|
596
|
+
(check) => check.status === "failure" || check.status === "cancelled",
|
|
597
|
+
).length;
|
|
598
|
+
if (failed > 0) return `${failed} of ${row.checks.length} checks failing`;
|
|
599
|
+
if (passed < row.checks.length) return `${passed} of ${row.checks.length} checks passed`;
|
|
600
|
+
return passed === 1 ? "1 check passed" : `${passed} checks passed`;
|
|
601
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/index.client.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { PrRadar } from "./client/pr-radar";
|
|
3
|
+
|
|
4
|
+
export default function contribute(client: PluginClientContext) {
|
|
5
|
+
client.addSurface("radar", PrRadar);
|
|
6
|
+
client.addSidebarItem({
|
|
7
|
+
id: "radar",
|
|
8
|
+
title: "PR Radar",
|
|
9
|
+
icon: "GitPullRequest",
|
|
10
|
+
surface: "radar",
|
|
11
|
+
});
|
|
12
|
+
client.addCommandCenterItem({
|
|
13
|
+
id: "open-radar",
|
|
14
|
+
title: "Open PR Radar",
|
|
15
|
+
icon: "GitPullRequest",
|
|
16
|
+
keywords: ["pull requests", "delivery", "merge", "agents"],
|
|
17
|
+
context: "global",
|
|
18
|
+
onSelect({ openSurface }) {
|
|
19
|
+
openSurface("radar");
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
return () => {};
|
|
23
|
+
}
|
package/index.server.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { acknowledgeViewerUpdates, resolveViewerScope } from "./server/viewer-scope";
|
|
3
|
+
import { acknowledgeViewerScope, viewerScope } from "./shared/viewer-scope";
|
|
4
|
+
|
|
5
|
+
export default function contribute(server: PluginServerContext) {
|
|
6
|
+
server.handle(viewerScope, resolveViewerScope);
|
|
7
|
+
server.handle(acknowledgeViewerScope, acknowledgeViewerUpdates);
|
|
8
|
+
return () => {};
|
|
9
|
+
}
|