@gtrabanco/pi-web-github-pr-status 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,1427 @@
1
+ // src/checks.ts
2
+ var EMPTY_CI = Object.freeze({ state: "none", total: 0, passed: 0, running: 0, failed: 0, checks: [] });
3
+ var FAILING_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED", "STARTUP_FAILURE"]);
4
+ var NEUTRAL_CONCLUSIONS = new Set(["NEUTRAL", "SKIPPED"]);
5
+ function optionalString(value) {
6
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
7
+ }
8
+ function parseCheckRun(entry) {
9
+ const name = optionalString(entry.name) ?? "check";
10
+ const status = optionalString(entry.status);
11
+ const conclusion = optionalString(entry.conclusion);
12
+ let state;
13
+ if (status !== "COMPLETED" || conclusion === undefined) {
14
+ state = "running";
15
+ } else if (conclusion === "SUCCESS") {
16
+ state = "passed";
17
+ } else if (NEUTRAL_CONCLUSIONS.has(conclusion)) {
18
+ state = "skipped";
19
+ } else if (FAILING_CONCLUSIONS.has(conclusion)) {
20
+ state = "failed";
21
+ } else {
22
+ state = "running";
23
+ }
24
+ return { name, state, url: optionalString(entry.detailsUrl), workflow: optionalString(entry.workflowName) };
25
+ }
26
+ function parseStatusContext(entry) {
27
+ const name = optionalString(entry.context) ?? "status";
28
+ const state = optionalString(entry.state);
29
+ let checkState;
30
+ if (state === "SUCCESS") {
31
+ checkState = "passed";
32
+ } else if (state === "PENDING" || state === "EXPECTED") {
33
+ checkState = "running";
34
+ } else if (state === "FAILURE" || state === "ERROR") {
35
+ checkState = "failed";
36
+ } else {
37
+ return;
38
+ }
39
+ return { name, state: checkState, url: optionalString(entry.targetUrl) };
40
+ }
41
+ function summarizeChecks(rollup) {
42
+ if (!Array.isArray(rollup))
43
+ return EMPTY_CI;
44
+ const checks = [];
45
+ for (const raw of rollup) {
46
+ if (typeof raw !== "object" || raw === null)
47
+ continue;
48
+ const entry = raw;
49
+ const typename = optionalString(entry.__typename);
50
+ if (typename === "CheckRun") {
51
+ const check = parseCheckRun(entry);
52
+ if (check !== undefined)
53
+ checks.push(check);
54
+ } else if (typename === "StatusContext") {
55
+ const check = parseStatusContext(entry);
56
+ if (check !== undefined)
57
+ checks.push(check);
58
+ }
59
+ }
60
+ if (checks.length === 0)
61
+ return EMPTY_CI;
62
+ let passed = 0;
63
+ let running = 0;
64
+ let failed = 0;
65
+ for (const check of checks) {
66
+ if (check.state === "failed")
67
+ failed += 1;
68
+ else if (check.state === "running")
69
+ running += 1;
70
+ else
71
+ passed += 1;
72
+ }
73
+ const state = failed > 0 ? "failed" : running > 0 ? "running" : "passed";
74
+ return { state, total: checks.length, passed, running, failed, checks };
75
+ }
76
+
77
+ // src/types.ts
78
+ var PLUGIN_ID = "github-pr-status";
79
+ var SETTINGS_PATH = ".pi-web/github-pr.json";
80
+ var SCRATCH_DIR = ".pi-web/github-pr";
81
+ var PROBE_SCRIPT_PATH = `${SCRATCH_DIR}/probe.sh`;
82
+ var PROBE_MARKER_PATH = `${SCRATCH_DIR}/probe.json`;
83
+
84
+ // src/probe.ts
85
+ var PROBE_SCRIPT_PATH2 = `${SCRATCH_DIR}/probe.sh`;
86
+ var PROBE_COMMAND = `sh ${PROBE_SCRIPT_PATH2}`;
87
+ var PROBE_SCRIPT = `#!/bin/sh
88
+ # Generated by @gtrabanco/pi-web-github-pr-status. Do not edit.
89
+ D=${SCRATCH_DIR}
90
+ mkdir -p "$D"
91
+ T=$(date +%s 2>/dev/null)
92
+ if [ -z "$T" ]; then T=0; fi
93
+ G=1
94
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
95
+ git rev-parse --abbrev-ref HEAD >"$D/branch.txt" 2>/dev/null
96
+ git rev-parse HEAD >"$D/head.txt" 2>/dev/null
97
+ git rev-parse --abbrev-ref --symbolic-full-name "@{u}" >"$D/upstream.txt" 2>/dev/null
98
+ git diff --cached --numstat 2>/dev/null | wc -l | tr -d " " >"$D/staged.txt"
99
+ git diff --numstat 2>/dev/null | wc -l | tr -d " " >"$D/unstaged.txt"
100
+ git ls-files --others --exclude-standard --exclude=.pi-web/github-pr/ 2>/dev/null | wc -l | tr -d " " >"$D/untracked.txt"
101
+ git rev-list --left-right --count "@{u}"..."HEAD" >"$D/ab.txt" 2>/dev/null
102
+ if command -v gh >/dev/null 2>&1; then
103
+ GH_PAGER=cat gh pr view --json number,url,title,state,isDraft,author,headRefName,baseRefName,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup >"$D/pr.json" 2>"$D/gh.err"
104
+ else
105
+ printf "gh: command not found" >"$D/gh.err"
106
+ fi
107
+ else
108
+ G=0
109
+ fi
110
+ printf '{"v":1,"ts":%s,"git":%s}
111
+ ' "$T" "$G" >"$D/probe.json"
112
+ exit 0
113
+ `;
114
+ function parseCount(raw) {
115
+ if (raw === undefined)
116
+ return 0;
117
+ const match = /^\s*(\d+)\s*$/.exec(raw);
118
+ return match === null ? 0 : Number(match[1]);
119
+ }
120
+ function parseAheadBehind(raw) {
121
+ if (raw === undefined)
122
+ return { ahead: 0, behind: 0 };
123
+ const parts = raw.trim().split("\t");
124
+ const behind = parseCount(parts[0]);
125
+ const ahead = parseCount(parts[1]);
126
+ return { ahead, behind };
127
+ }
128
+ function parseMarker(raw) {
129
+ if (raw === undefined || raw.trim() === "")
130
+ return { git: false, usable: false };
131
+ try {
132
+ const parsed = JSON.parse(raw);
133
+ if (typeof parsed !== "object" || parsed === null)
134
+ return { git: false, usable: false };
135
+ const record = parsed;
136
+ return {
137
+ git: record.git === 1 || record.git === true,
138
+ probeAt: typeof record.ts === "number" && Number.isFinite(record.ts) ? record.ts : undefined,
139
+ usable: true
140
+ };
141
+ } catch {
142
+ return { git: false, usable: false };
143
+ }
144
+ }
145
+ var GH_MISSING_PATTERN = /command not found|not recognized/iu;
146
+ var GH_AUTH_PATTERN = /gh auth login|GH_TOKEN|authentication/iu;
147
+ var GH_NO_PR_PATTERN = /no pull requests found/iu;
148
+ function classifyGh(ghErr, hasPr) {
149
+ if (hasPr)
150
+ return { gh: "ok" };
151
+ const message = ghErr?.trim();
152
+ if (message === undefined || message === "")
153
+ return { gh: "error", ghMessage: "gh output unavailable" };
154
+ if (GH_MISSING_PATTERN.test(message))
155
+ return { gh: "missing", ghMessage: message.split(`
156
+ `)[0] };
157
+ if (GH_AUTH_PATTERN.test(message))
158
+ return { gh: "unauthenticated", ghMessage: message.split(`
159
+ `)[0] };
160
+ if (GH_NO_PR_PATTERN.test(message))
161
+ return { gh: "ok" };
162
+ return { gh: "error", ghMessage: message.split(`
163
+ `)[0] };
164
+ }
165
+ function optionalString2(value) {
166
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
167
+ }
168
+ function parseBoolean(value) {
169
+ return value === true || value === "true";
170
+ }
171
+ function parsePrJson(raw) {
172
+ if (raw === undefined || raw.trim() === "")
173
+ return;
174
+ let parsed;
175
+ try {
176
+ parsed = JSON.parse(raw);
177
+ } catch {
178
+ return;
179
+ }
180
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
181
+ return;
182
+ const record = parsed;
183
+ const number = record.number;
184
+ if (typeof number !== "number" || !Number.isInteger(number) || number < 1 || number > 2147483647)
185
+ return;
186
+ const url = record.url;
187
+ if (typeof url !== "string" || !/^https:\/\/[\w.-]+/u.test(url))
188
+ return;
189
+ const state = record.state;
190
+ if (state !== "OPEN" && state !== "CLOSED" && state !== "MERGED")
191
+ return;
192
+ const author = record.author;
193
+ return {
194
+ number,
195
+ url,
196
+ title: typeof record.title === "string" ? record.title : "",
197
+ state,
198
+ isDraft: parseBoolean(record.isDraft),
199
+ author: typeof author === "object" && author !== null ? optionalString2(author.login) : undefined,
200
+ headRefName: optionalString2(record.headRefName),
201
+ baseRefName: optionalString2(record.baseRefName),
202
+ mergeable: optionalString2(record.mergeable),
203
+ mergeStateStatus: optionalString2(record.mergeStateStatus),
204
+ reviewDecision: optionalString2(record.reviewDecision)
205
+ };
206
+ }
207
+ var EMPTY_STATUS = Object.freeze({
208
+ git: false,
209
+ staged: 0,
210
+ unstaged: 0,
211
+ untracked: 0,
212
+ ahead: 0,
213
+ behind: 0,
214
+ hasUpstream: false,
215
+ ci: EMPTY_CI,
216
+ cold: true
217
+ });
218
+ function parseProbeResult(files) {
219
+ const marker = parseMarker(files.probe);
220
+ if (!marker.usable)
221
+ return { ...EMPTY_STATUS };
222
+ if (!marker.git) {
223
+ return {
224
+ ...EMPTY_STATUS,
225
+ git: false,
226
+ cold: false,
227
+ probeAt: marker.probeAt
228
+ };
229
+ }
230
+ const upstream = optionalString2(files.upstream);
231
+ const counts = parseAheadBehind(files.aheadBehind);
232
+ const pr = parsePrJson(files.pr);
233
+ const gh = classifyGh(files.ghErr, pr !== undefined);
234
+ const staged = parseCount(files.staged);
235
+ const unstaged = parseCount(files.unstaged);
236
+ const untracked = parseCount(files.untracked);
237
+ const ahead = upstream === undefined ? 0 : counts.ahead;
238
+ const behind = upstream === undefined ? 0 : counts.behind;
239
+ const ci = pr !== undefined && pr.state === "OPEN" ? summarizeChecks(extractRollup(files.pr)) : EMPTY_CI;
240
+ return {
241
+ git: true,
242
+ cold: false,
243
+ probeAt: marker.probeAt,
244
+ branch: optionalString2(files.branch),
245
+ head: optionalString2(files.head),
246
+ upstream,
247
+ staged,
248
+ unstaged,
249
+ untracked,
250
+ ahead,
251
+ behind,
252
+ hasUpstream: upstream !== undefined,
253
+ pr,
254
+ ci,
255
+ gh: gh.gh,
256
+ ghMessage: gh.ghMessage,
257
+ isDirty: staged + unstaged + untracked > 0,
258
+ unpushed: upstream === undefined || ahead > 0
259
+ };
260
+ }
261
+ function extractRollup(prRaw) {
262
+ if (prRaw === undefined)
263
+ return;
264
+ try {
265
+ const parsed = JSON.parse(prRaw);
266
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
267
+ return parsed.statusCheckRollup;
268
+ }
269
+ } catch {}
270
+ return;
271
+ }
272
+ async function readProbeFile(readFile, path) {
273
+ try {
274
+ const file = await readFile(path);
275
+ return file.binary ? undefined : file.content;
276
+ } catch {
277
+ return;
278
+ }
279
+ }
280
+ var PROBE_FILE_PATHS = {
281
+ probe: `${SCRATCH_DIR}/probe.json`,
282
+ branch: `${SCRATCH_DIR}/branch.txt`,
283
+ head: `${SCRATCH_DIR}/head.txt`,
284
+ upstream: `${SCRATCH_DIR}/upstream.txt`,
285
+ staged: `${SCRATCH_DIR}/staged.txt`,
286
+ unstaged: `${SCRATCH_DIR}/unstaged.txt`,
287
+ untracked: `${SCRATCH_DIR}/untracked.txt`,
288
+ aheadBehind: `${SCRATCH_DIR}/ab.txt`,
289
+ pr: `${SCRATCH_DIR}/pr.json`,
290
+ ghErr: `${SCRATCH_DIR}/gh.err`
291
+ };
292
+ async function readAllProbeFiles(readFile) {
293
+ const paths = Object.values(PROBE_FILE_PATHS);
294
+ const [probe, branch, head, upstream, staged, unstaged, untracked, aheadBehind, pr, ghErr] = await Promise.all(paths.map(async (path) => readProbeFile(readFile, path)));
295
+ return { probe, branch, head, upstream, staged, unstaged, untracked, aheadBehind, pr, ghErr };
296
+ }
297
+
298
+ // src/settings.ts
299
+ var DEFAULT_SETTINGS = Object.freeze({
300
+ showCI: true,
301
+ refreshSeconds: 90,
302
+ merge: Object.freeze({
303
+ enabled: true,
304
+ method: "merge",
305
+ requireCleanWorktree: true,
306
+ requireCI: true,
307
+ deleteBranch: false
308
+ })
309
+ });
310
+ var MERGE_METHODS = ["merge", "squash", "rebase"];
311
+ var MIN_REFRESH_SECONDS = 0;
312
+ var MAX_REFRESH_SECONDS = 3600;
313
+ function asObject(value) {
314
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
315
+ }
316
+ function coerceBoolean(value) {
317
+ if (typeof value === "boolean")
318
+ return value;
319
+ if (value === "true")
320
+ return true;
321
+ if (value === "false")
322
+ return false;
323
+ if (value === 1)
324
+ return true;
325
+ if (value === 0)
326
+ return false;
327
+ return;
328
+ }
329
+ function mergeBooleans(raw, defaults, warnings, prefix) {
330
+ const pick = (key) => {
331
+ const coerced = coerceBoolean(raw[key]);
332
+ if (coerced === undefined) {
333
+ if (raw[key] !== undefined)
334
+ warnings.push(`${prefix}${key}: ignored invalid value`);
335
+ return defaults[key];
336
+ }
337
+ return coerced;
338
+ };
339
+ return {
340
+ enabled: pick("enabled"),
341
+ requireCleanWorktree: pick("requireCleanWorktree"),
342
+ requireCI: pick("requireCI"),
343
+ deleteBranch: pick("deleteBranch"),
344
+ method: normalizeMethod(raw.method, defaults.method, warnings)
345
+ };
346
+ }
347
+ function normalizeMethod(value, fallback, warnings) {
348
+ if (value === undefined)
349
+ return fallback;
350
+ if (typeof value === "string" && MERGE_METHODS.includes(value)) {
351
+ return value;
352
+ }
353
+ warnings.push(`merge.method: ignored invalid value ${JSON.stringify(String(value))}`);
354
+ return fallback;
355
+ }
356
+ function normalizeRefreshSeconds(value, fallback, warnings) {
357
+ if (value === undefined)
358
+ return fallback;
359
+ if (typeof value !== "number" || !Number.isFinite(value)) {
360
+ warnings.push("refreshSeconds: ignored invalid value");
361
+ return fallback;
362
+ }
363
+ const seconds = Math.max(MIN_REFRESH_SECONDS, Math.min(MAX_REFRESH_SECONDS, Math.floor(value)));
364
+ return seconds;
365
+ }
366
+ function normalizeSettings(raw) {
367
+ const warnings = [];
368
+ const source = asObject(raw);
369
+ const showCI = coerceBoolean(source.showCI);
370
+ if (showCI === undefined && source.showCI !== undefined)
371
+ warnings.push("showCI: ignored invalid value");
372
+ const mergeSource = asObject(source.merge);
373
+ const settings = {
374
+ showCI: showCI ?? DEFAULT_SETTINGS.showCI,
375
+ refreshSeconds: normalizeRefreshSeconds(source.refreshSeconds, DEFAULT_SETTINGS.refreshSeconds, warnings),
376
+ merge: mergeBooleans(mergeSource, DEFAULT_SETTINGS.merge, warnings, "merge.")
377
+ };
378
+ return { settings, warnings };
379
+ }
380
+ function serializeSettings(settings) {
381
+ return `${JSON.stringify(settings, null, 2)}
382
+ `;
383
+ }
384
+
385
+ // src/cache.ts
386
+ var FILE_TTL_MS = 1e4;
387
+ var PROBE_TIMEOUT_MS = 25000;
388
+ var MAX_ENTRIES = 32;
389
+ function contextKey(context) {
390
+ return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
391
+ }
392
+ function nowMs() {
393
+ return Date.now();
394
+ }
395
+ async function runWorkspaceCommand(context, input) {
396
+ if (context.terminal === undefined)
397
+ throw new Error("Workspace terminal helper is unavailable in this context");
398
+ const handle = await context.terminal.runCommand({
399
+ title: input.title,
400
+ command: input.command,
401
+ open: false,
402
+ metadata: { "pi.plugin": PLUGIN_ID, op: input.op }
403
+ });
404
+ return await withTimeout(handle.completed, input.timeoutMs ?? PROBE_TIMEOUT_MS);
405
+ }
406
+ async function withTimeout(promise, timeoutMs) {
407
+ return await new Promise((resolve, reject) => {
408
+ const timer = setTimeout(() => reject(new Error(`Command timed out after ${String(Math.round(timeoutMs / 1000))}s`)), timeoutMs);
409
+ promise.then((value) => {
410
+ clearTimeout(timer);
411
+ resolve(value);
412
+ }, (error) => {
413
+ clearTimeout(timer);
414
+ reject(error);
415
+ });
416
+ });
417
+ }
418
+
419
+ class StatusCache {
420
+ entries = new Map;
421
+ get(context) {
422
+ return this.entries.get(contextKey(context));
423
+ }
424
+ entryStatus(context) {
425
+ return this.entries.get(contextKey(context))?.status;
426
+ }
427
+ statusByKey(machineId, projectId, workspaceId) {
428
+ return this.entries.get(`${machineId}:${projectId}:${workspaceId}`)?.status;
429
+ }
430
+ entrySettings(context) {
431
+ const entry = this.entries.get(contextKey(context));
432
+ return {
433
+ settings: entry?.settings ?? DEFAULT_SETTINGS,
434
+ warnings: entry?.settingsWarnings ?? [],
435
+ error: entry?.settingsError
436
+ };
437
+ }
438
+ ensureLoaded(context) {
439
+ const key = contextKey(context);
440
+ let entry = this.entries.get(key);
441
+ if (entry === undefined) {
442
+ if (context.state?.selectedWorkspace?.id !== context.workspace.id) {
443
+ return { settingsWarnings: [], loadedAt: 0, probedAt: 0 };
444
+ }
445
+ entry = this.evictAndCreate(key);
446
+ }
447
+ entry.host = context.host;
448
+ if (nowMs() - entry.loadedAt > FILE_TTL_MS && entry.reading === undefined) {
449
+ this.startRead(context, entry);
450
+ }
451
+ return entry;
452
+ }
453
+ async refreshFiles(context) {
454
+ const key = contextKey(context);
455
+ const entry = this.entries.get(key) ?? this.evictAndCreate(key);
456
+ entry.host = context.host;
457
+ await this.startRead(context, entry);
458
+ }
459
+ async probe(context, options = {}) {
460
+ const key = contextKey(context);
461
+ const entry = this.entries.get(key) ?? this.evictAndCreate(key);
462
+ entry.host = context.host;
463
+ if (entry.probing !== undefined)
464
+ return await entry.probing;
465
+ entry.probing = (async () => {
466
+ try {
467
+ await context.files.writeFile(PROBE_SCRIPT_PATH2, PROBE_SCRIPT, { overwrite: true });
468
+ await runWorkspaceCommand(context, { title: "GitHub PR status", command: PROBE_COMMAND, op: "probe" });
469
+ } finally {
470
+ entry.probedAt = nowMs();
471
+ entry.probing = undefined;
472
+ }
473
+ await this.startRead(context, entry);
474
+ })();
475
+ return await entry.probing;
476
+ }
477
+ startRead(context, entry) {
478
+ if (entry.reading !== undefined)
479
+ return entry.reading;
480
+ entry.reading = (async () => {
481
+ try {
482
+ const files = await readAllProbeFiles((path) => context.files.readFile(path));
483
+ const status = parseProbeResult(files);
484
+ const { settings, warnings, error } = await readSettings(context);
485
+ this.apply(entry, status, settings, warnings, error);
486
+ } catch (error) {
487
+ entry.settingsError = error instanceof Error ? error.message : String(error);
488
+ } finally {
489
+ entry.loadedAt = nowMs();
490
+ entry.reading = undefined;
491
+ }
492
+ })();
493
+ return entry.reading;
494
+ }
495
+ apply(entry, status, settings, warnings, settingsError) {
496
+ entry.status = status;
497
+ entry.settings = settings;
498
+ entry.settingsWarnings = warnings;
499
+ entry.settingsError = settingsError;
500
+ const serialized = JSON.stringify([status, settings, warnings, settingsError]);
501
+ const changed = entry.serialized !== serialized;
502
+ entry.serialized = serialized;
503
+ if (changed)
504
+ entry.host?.requestRender();
505
+ }
506
+ evictAndCreate(key) {
507
+ if (this.entries.size >= MAX_ENTRIES) {
508
+ const oldest = [...this.entries.entries()].sort((left, right) => left[1].loadedAt - right[1].loadedAt)[0];
509
+ if (oldest !== undefined)
510
+ this.entries.delete(oldest[0]);
511
+ }
512
+ const entry = { settingsWarnings: [], loadedAt: 0, probedAt: 0 };
513
+ this.entries.set(key, entry);
514
+ return entry;
515
+ }
516
+ }
517
+ async function readSettings(context) {
518
+ try {
519
+ const file = await context.files.readFile(SETTINGS_PATH);
520
+ if (file.binary)
521
+ return { settings: DEFAULT_SETTINGS, warnings: [], error: `${SETTINGS_PATH} is binary` };
522
+ const parsed = JSON.parse(file.content);
523
+ const { settings, warnings } = normalizeSettings(parsed);
524
+ return { settings, warnings };
525
+ } catch {
526
+ return { settings: DEFAULT_SETTINGS, warnings: [] };
527
+ }
528
+ }
529
+ var statusCache = new StatusCache;
530
+
531
+ // src/guards.ts
532
+ var STATUS_UNAVAILABLE = "PR status is not available yet — refresh";
533
+ function dirtyCount(status) {
534
+ return status.staged + status.unstaged + status.untracked;
535
+ }
536
+ function evaluateMerge(status, settings) {
537
+ if (!settings.merge.enabled) {
538
+ return { canMerge: false, blockers: ["One-click merge is disabled in this workspace's plugin settings"], confirmations: [] };
539
+ }
540
+ if (status === undefined) {
541
+ return { canMerge: false, blockers: [STATUS_UNAVAILABLE], confirmations: [] };
542
+ }
543
+ if (!status.git) {
544
+ return { canMerge: false, blockers: ["Not a git repository workspace"], confirmations: [] };
545
+ }
546
+ const blockers = [];
547
+ const pr = status.pr;
548
+ if (pr === undefined) {
549
+ blockers.push(status.branch === "HEAD" ? "HEAD is detached" : `No open pull request for branch ${status.branch ?? ""}`.trimEnd());
550
+ return { canMerge: false, blockers, confirmations: [] };
551
+ }
552
+ if (pr.state === "MERGED") {
553
+ blockers.push(`Pull request #${String(pr.number)} is already merged`);
554
+ } else if (pr.state === "CLOSED") {
555
+ blockers.push(`Pull request #${String(pr.number)} is closed`);
556
+ }
557
+ if (pr.isDraft)
558
+ blockers.push("Pull request is a draft");
559
+ if (pr.mergeable === "CONFLICTING")
560
+ blockers.push("Pull request has merge conflicts");
561
+ const dirty = dirtyCount(status);
562
+ if (settings.merge.requireCleanWorktree && dirty > 0) {
563
+ blockers.push(`Worktree has uncommitted changes (${String(status.staged)} staged, ${String(status.unstaged)} unstaged, ${String(status.untracked)} untracked)`);
564
+ }
565
+ if (!status.hasUpstream) {
566
+ blockers.push("Branch has no upstream — publish it first (git push -u)");
567
+ } else if (status.ahead > 0) {
568
+ blockers.push(`${String(status.ahead)} local commit(s) not pushed`);
569
+ }
570
+ const confirmations = [];
571
+ if (settings.merge.requireCI) {
572
+ const ciNote = ciConfirmation(status.ci);
573
+ if (ciNote !== undefined)
574
+ confirmations.push(ciNote);
575
+ }
576
+ if (status.behind > 0) {
577
+ confirmations.push(`Local branch is ${String(status.behind)} commit(s) behind its upstream`);
578
+ }
579
+ if (pr.mergeStateStatus === "BLOCKED" || pr.mergeStateStatus === "UNSTABLE") {
580
+ confirmations.push(`GitHub reports merge state: ${pr.mergeStateStatus}`);
581
+ }
582
+ return { canMerge: blockers.length === 0, blockers, confirmations };
583
+ }
584
+ function ciConfirmation(ci) {
585
+ if (ci.state === "running")
586
+ return `CI is still running (${String(ci.passed)} passed, ${String(ci.running)} running)`;
587
+ if (ci.state === "failed")
588
+ return `CI has failures (${String(ci.failed)} failed)`;
589
+ return;
590
+ }
591
+ function evaluateClose(status, _settings) {
592
+ if (status === undefined)
593
+ return { canClose: false, blockers: [STATUS_UNAVAILABLE] };
594
+ if (!status.git)
595
+ return { canClose: false, blockers: ["Not a git repository workspace"] };
596
+ const pr = status.pr;
597
+ if (pr === undefined) {
598
+ const reason = status.branch === "HEAD" ? "HEAD is detached" : `No open pull request for branch ${status.branch ?? ""}`.trimEnd();
599
+ return { canClose: false, blockers: [reason] };
600
+ }
601
+ if (pr.state !== "OPEN")
602
+ return { canClose: false, blockers: [`Pull request #${String(pr.number)} is ${pr.state.toLowerCase()}`] };
603
+ return { canClose: true, blockers: [] };
604
+ }
605
+ function assertPrNumber(prNumber) {
606
+ if (!Number.isInteger(prNumber) || prNumber < 1 || prNumber > 2147483647) {
607
+ throw new Error(`Invalid pull request number: ${String(prNumber)}`);
608
+ }
609
+ }
610
+ function buildMergeCommand(prNumber, settings) {
611
+ assertPrNumber(prNumber);
612
+ const flag = `--${settings.merge.method}`;
613
+ const deleteFlag = settings.merge.deleteBranch ? " --delete-branch" : "";
614
+ return `gh pr merge ${String(prNumber)} ${flag}${deleteFlag} < /dev/null`;
615
+ }
616
+ function buildCloseCommand(prNumber) {
617
+ assertPrNumber(prNumber);
618
+ return `gh pr close ${String(prNumber)} < /dev/null`;
619
+ }
620
+ function explainCiState(ci) {
621
+ switch (ci.state) {
622
+ case "none":
623
+ return "No CI checks";
624
+ case "passed":
625
+ return `CI passing (${String(ci.passed)}/${String(ci.total)})`;
626
+ case "running":
627
+ return `CI running (${String(ci.passed)}/${String(ci.total)})`;
628
+ case "failed":
629
+ return `CI failing (${String(ci.failed)} failed)`;
630
+ }
631
+ }
632
+
633
+ // src/panel.ts
634
+ var PANEL_LOCAL_ID = "workspace.pr";
635
+ var ACTIVITY_ELEMENT_TAG = "pi-web-github-pr-activity";
636
+ var INVALIDATE_MIN_INTERVAL_MS = 3000;
637
+ var MERGE_CLOSE_TIMEOUT_MS = 60000;
638
+
639
+ class PrUiController {
640
+ states = new Map;
641
+ stateFor(context) {
642
+ const key = `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
643
+ const existing = this.states.get(key);
644
+ if (existing !== undefined) {
645
+ existing.context = context;
646
+ return existing;
647
+ }
648
+ const created = {
649
+ context,
650
+ retained: true,
651
+ confirm: null,
652
+ busy: null,
653
+ outcome: null,
654
+ settingsOpen: false,
655
+ draft: null,
656
+ lastInvalidate: 0
657
+ };
658
+ this.states.set(key, created);
659
+ return created;
660
+ }
661
+ connect(context) {
662
+ const state = this.stateFor(context);
663
+ statusCache.ensureLoaded(context);
664
+ const settings = statusCache.entrySettings(context).settings;
665
+ const entry = statusCache.get(context);
666
+ const staleMs = settings.refreshSeconds * 1000;
667
+ const shouldProbe = entry !== undefined && settings.refreshSeconds > 0 && Date.now() - entry.probedAt > staleMs && Date.now() - entry.loadedAt > staleMs;
668
+ if (shouldProbe)
669
+ this.probe(context);
670
+ else
671
+ this.requestRender(state);
672
+ }
673
+ disconnect(context) {
674
+ const key = `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
675
+ this.states.delete(key);
676
+ }
677
+ tick(context) {
678
+ if (typeof document !== "undefined" && document.visibilityState === "hidden")
679
+ return;
680
+ const settings = statusCache.entrySettings(context).settings;
681
+ if (settings.refreshSeconds <= 0)
682
+ return;
683
+ const entry = statusCache.get(context);
684
+ const staleMs = settings.refreshSeconds * 1000;
685
+ if (entry === undefined) {
686
+ this.probe(context);
687
+ return;
688
+ }
689
+ if (Date.now() - Math.max(entry.probedAt, entry.loadedAt) >= staleMs)
690
+ this.probe(context);
691
+ }
692
+ invalidate(context) {
693
+ const state = this.stateFor(context);
694
+ const now = Date.now();
695
+ if (now - state.lastInvalidate < INVALIDATE_MIN_INTERVAL_MS) {
696
+ statusCache.refreshFiles(context).then(() => this.requestRender(state));
697
+ return;
698
+ }
699
+ state.lastInvalidate = now;
700
+ this.probe(context);
701
+ }
702
+ async refresh(context) {
703
+ await this.probe(context);
704
+ }
705
+ async probe(context) {
706
+ const state = this.stateFor(context);
707
+ if (state.busy !== null)
708
+ return;
709
+ state.busy = "probe";
710
+ state.outcome = null;
711
+ this.requestRender(state);
712
+ try {
713
+ await statusCache.probe(context, { force: true });
714
+ } catch (error) {
715
+ state.outcome = { ok: false, message: errorMessage(error) };
716
+ } finally {
717
+ state.busy = null;
718
+ this.requestRender(state);
719
+ }
720
+ }
721
+ onMergeClick(context) {
722
+ const state = this.stateFor(context);
723
+ const status = statusCache.entryStatus(context);
724
+ const settings = statusCache.entrySettings(context).settings;
725
+ const evaluation = evaluateMerge(status, settings);
726
+ if (!evaluation.canMerge) {
727
+ state.confirm = null;
728
+ state.outcome = { ok: false, message: evaluation.blockers.join(" · ") };
729
+ this.requestRender(state);
730
+ return;
731
+ }
732
+ if (evaluation.confirmations.length > 0 && state.confirm?.kind !== "merge") {
733
+ state.confirm = { kind: "merge", reasons: evaluation.confirmations };
734
+ state.outcome = null;
735
+ this.requestRender(state);
736
+ return;
737
+ }
738
+ this.runMerge(context, status, settings);
739
+ }
740
+ onCloseClick(context) {
741
+ const state = this.stateFor(context);
742
+ const status = statusCache.entryStatus(context);
743
+ const evaluation = evaluateClose(status);
744
+ if (!evaluation.canClose) {
745
+ state.confirm = null;
746
+ state.outcome = { ok: false, message: evaluation.blockers.join(" · ") };
747
+ this.requestRender(state);
748
+ return;
749
+ }
750
+ const prNumber = status?.pr?.number;
751
+ if (state.confirm?.kind !== "close") {
752
+ state.confirm = { kind: "close", reasons: [`Close PR #${String(prNumber ?? 0)} without merging?`] };
753
+ state.outcome = null;
754
+ this.requestRender(state);
755
+ return;
756
+ }
757
+ this.runClose(context, status);
758
+ }
759
+ cancelConfirm(context) {
760
+ const state = this.stateFor(context);
761
+ state.confirm = null;
762
+ this.requestRender(state);
763
+ }
764
+ async runMerge(context, status, settings) {
765
+ const state = this.stateFor(context);
766
+ const pr = status?.pr;
767
+ if (pr === undefined)
768
+ return;
769
+ state.confirm = null;
770
+ state.busy = "merge";
771
+ state.outcome = null;
772
+ this.requestRender(state);
773
+ try {
774
+ const run = await runGuardedCommand(context, {
775
+ title: `Merge PR #${String(pr.number)}`,
776
+ command: buildMergeCommand(pr.number, settings),
777
+ op: "merge"
778
+ });
779
+ if (run.exitCode === 0) {
780
+ state.outcome = { ok: true, message: `PR #${String(pr.number)} merged (${settings.merge.method}).` };
781
+ } else {
782
+ state.outcome = { ok: false, message: `Merge failed (exit ${String(run.exitCode ?? "?")}). Check the terminal output.`, terminalId: run.terminalId };
783
+ }
784
+ } catch (error) {
785
+ state.outcome = { ok: false, message: errorMessage(error) };
786
+ } finally {
787
+ state.busy = null;
788
+ statusCache.probe(context, { force: true }).catch(() => {
789
+ return;
790
+ });
791
+ this.requestRender(state);
792
+ }
793
+ }
794
+ async runClose(context, status) {
795
+ const state = this.stateFor(context);
796
+ const pr = status?.pr;
797
+ if (pr === undefined)
798
+ return;
799
+ state.confirm = null;
800
+ state.busy = "close";
801
+ state.outcome = null;
802
+ this.requestRender(state);
803
+ try {
804
+ const run = await runGuardedCommand(context, {
805
+ title: `Close PR #${String(pr.number)}`,
806
+ command: buildCloseCommand(pr.number),
807
+ op: "close"
808
+ });
809
+ if (run.exitCode === 0) {
810
+ state.outcome = { ok: true, message: `PR #${String(pr.number)} closed.` };
811
+ } else {
812
+ state.outcome = { ok: false, message: `Close failed (exit ${String(run.exitCode ?? "?")}). Check the terminal output.`, terminalId: run.terminalId };
813
+ }
814
+ } catch (error) {
815
+ state.outcome = { ok: false, message: errorMessage(error) };
816
+ } finally {
817
+ state.busy = null;
818
+ statusCache.probe(context, { force: true }).catch(() => {
819
+ return;
820
+ });
821
+ this.requestRender(state);
822
+ }
823
+ }
824
+ updateDraft(context, mutate) {
825
+ const state = this.stateFor(context);
826
+ const current = state.draft ?? statusCache.entrySettings(context).settings;
827
+ state.draft = mutate({ ...current, merge: { ...current.merge } });
828
+ this.requestRender(state);
829
+ }
830
+ toggleSettings(context) {
831
+ const state = this.stateFor(context);
832
+ state.settingsOpen = !state.settingsOpen;
833
+ state.draft = null;
834
+ this.requestRender(state);
835
+ }
836
+ async saveSettings(context) {
837
+ const state = this.stateFor(context);
838
+ const draft = state.draft ?? statusCache.entrySettings(context).settings;
839
+ state.busy = "settings";
840
+ state.outcome = null;
841
+ this.requestRender(state);
842
+ try {
843
+ await context.files.writeFile(SETTINGS_PATH, serializeSettings(draft), { overwrite: true });
844
+ state.outcome = { ok: true, message: `Settings saved to ${SETTINGS_PATH}.` };
845
+ state.draft = null;
846
+ state.settingsOpen = false;
847
+ } catch (error) {
848
+ state.outcome = { ok: false, message: `Could not save settings: ${errorMessage(error)}` };
849
+ } finally {
850
+ state.busy = null;
851
+ statusCache.refreshFiles(context).catch(() => {
852
+ return;
853
+ });
854
+ this.requestRender(state);
855
+ }
856
+ }
857
+ resetSettings(context) {
858
+ const state = this.stateFor(context);
859
+ state.draft = { ...DEFAULT_SETTINGS, merge: { ...DEFAULT_SETTINGS.merge } };
860
+ this.requestRender(state);
861
+ }
862
+ requestRender(state) {
863
+ if (state.retained)
864
+ state.context.host.requestRender();
865
+ }
866
+ }
867
+ async function runGuardedCommand(context, input) {
868
+ return await runWorkspaceCommand(context, { ...input, timeoutMs: MERGE_CLOSE_TIMEOUT_MS });
869
+ }
870
+ function errorMessage(error) {
871
+ return error instanceof Error ? error.message : String(error);
872
+ }
873
+ function createPanelContribution(html, svg, controller) {
874
+ defineActivityElement(controller);
875
+ return {
876
+ id: PANEL_LOCAL_ID,
877
+ title: "Pull Request",
878
+ icon: svg`
879
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
880
+ <circle cx="6" cy="6" r="2.5"></circle>
881
+ <circle cx="6" cy="18" r="2.5"></circle>
882
+ <circle cx="18" cy="18" r="2.5"></circle>
883
+ <path d="M6 8.5v7"></path>
884
+ <path d="M13 6h3a2 2 0 0 1 2 2v7.5"></path>
885
+ <path d="m13 6 2-2"></path>
886
+ <path d="m13 6 2 2"></path>
887
+ </svg>
888
+ `,
889
+ order: 30,
890
+ routeAliases: ["pull-request", "pr"],
891
+ visible: (context) => {
892
+ const metadata = context.workspace.provider?.metadata;
893
+ if (metadata?.isGitRepo === false)
894
+ return false;
895
+ const status = statusCache.get(context)?.status;
896
+ if (status !== undefined && status.git === false && status.cold !== true)
897
+ return false;
898
+ return true;
899
+ },
900
+ badge: (context) => {
901
+ const settings = statusCache.entrySettings(context).settings;
902
+ if (!settings.showCI)
903
+ return;
904
+ const ci = statusCache.get(context)?.status?.ci;
905
+ if (ci === undefined || ci.state === "none" || ci.state === "passed")
906
+ return;
907
+ const color = ci.state === "failed" ? CI_DOT_COLORS.failed : CI_DOT_COLORS.running;
908
+ return html`<span title=${explainCiState(ci)} style="display:inline-block;width:9px;height:9px;border-radius:999px;background:${color}"></span>`;
909
+ },
910
+ onInvalidate: (context) => {
911
+ controller.invalidate(context);
912
+ },
913
+ render: (context) => renderPanel(html, controller, context)
914
+ };
915
+ }
916
+ function defineActivityElement(controller) {
917
+ if (typeof customElements === "undefined" || typeof HTMLElement === "undefined")
918
+ return;
919
+ if (customElements.get(ACTIVITY_ELEMENT_TAG) !== undefined)
920
+ return;
921
+
922
+ class PrPanelActivityElement extends HTMLElement {
923
+ contextValue;
924
+ tickTimer;
925
+ set context(value) {
926
+ this.contextValue = value;
927
+ }
928
+ get context() {
929
+ return this.contextValue;
930
+ }
931
+ connectedCallback() {
932
+ if (this.contextValue !== undefined)
933
+ controller.connect(this.contextValue);
934
+ this.tickTimer = window.setInterval(() => {
935
+ if (this.contextValue !== undefined)
936
+ controller.tick(this.contextValue);
937
+ }, 1000);
938
+ }
939
+ disconnectedCallback() {
940
+ if (this.tickTimer !== undefined)
941
+ window.clearInterval(this.tickTimer);
942
+ this.tickTimer = undefined;
943
+ if (this.contextValue !== undefined)
944
+ controller.disconnect(this.contextValue);
945
+ }
946
+ }
947
+ customElements.define(ACTIVITY_ELEMENT_TAG, PrPanelActivityElement);
948
+ }
949
+ var CI_DOT_COLORS = { passed: "#3fb950", running: "#d29922", failed: "#f85149" };
950
+ function renderPanel(html, controller, context) {
951
+ const state = controller.stateFor(context);
952
+ const entry = statusCache.ensureLoaded(context);
953
+ const settings = state.draft ?? statusCache.entrySettings(context).settings;
954
+ const status = entry.status;
955
+ const busy = state.busy !== null;
956
+ return html`
957
+ <section class="ghpr-panel">
958
+ <style .textContent=${panelStyles}></style>
959
+ <pi-web-github-pr-activity .context=${context}></pi-web-github-pr-activity>
960
+ <section class="ghpr-toolbar">
961
+ <strong>Pull Request</strong>
962
+ <div class="ghpr-toolbar-actions">
963
+ <button type="button" ?disabled=${busy} @click=${() => {
964
+ controller.refresh(context);
965
+ }}>Refresh</button>
966
+ </div>
967
+ </section>
968
+ ${renderBody(html, controller, context, state, settings, status, busy)}
969
+ </section>
970
+ `;
971
+ }
972
+ function renderBody(html, controller, context, state, settings, status, busy) {
973
+ if (status === undefined || status.cold === true) {
974
+ return html`<p class="ghpr-muted">${busy ? "Probing workspace…" : "PR status has not been collected yet. Press Refresh."}</p>`;
975
+ }
976
+ if (!status.git) {
977
+ return html`<p class="ghpr-muted">Not a git repository workspace.</p>`;
978
+ }
979
+ const mergeEvaluation = evaluateMerge(status, settings);
980
+ const closeEvaluation = evaluateClose(status);
981
+ const pr = status.pr;
982
+ return html`
983
+ ${status.gh !== undefined && status.gh !== "ok" ? renderGhHint(html, status) : null}
984
+ ${pr === undefined ? renderNoPr(html, status) : renderPrCard(html, pr)}
985
+ ${settings.showCI ? renderCiSection(html, status) : html`<p class="ghpr-muted">CI display is disabled in settings.</p>`}
986
+ ${renderWorktreeSection(html, status)}
987
+ ${renderOutcome(html, state)}
988
+ ${pr !== undefined && pr.state === "OPEN" ? renderActions(html, controller, context, state, mergeEvaluation, closeEvaluation, busy) : null}
989
+ ${renderSettings(html, controller, context, state, settings)}
990
+ `;
991
+ }
992
+ function renderGhHint(html, status) {
993
+ const hint = status.gh === "missing" ? "GitHub CLI (gh) was not found on this machine. Install it to see pull request and CI status: https://cli.github.com" : status.gh === "unauthenticated" ? "gh is not authenticated. Run `gh auth login` in a terminal to enable pull request status." : `gh error: ${status.ghMessage ?? "unknown error"}`;
994
+ return html`<div class="ghpr-warning" role="status">${hint}</div>`;
995
+ }
996
+ function renderNoPr(html, status) {
997
+ const branch = status.branch === "HEAD" ? "detached HEAD" : status.branch ?? "unknown branch";
998
+ return html`<p class="ghpr-muted">No open pull request for branch ${branch}.</p>`;
999
+ }
1000
+ function renderPrCard(html, pr) {
1001
+ const stateChip = pr.state === "OPEN" ? pr.isDraft ? "DRAFT" : "OPEN" : pr.state === "MERGED" ? "MERGED" : "CLOSED";
1002
+ return html`
1003
+ <section class="ghpr-pr">
1004
+ <div class="ghpr-pr-title">
1005
+ <a href=${pr.url} target="_blank" rel="noopener noreferrer">PR #${String(pr.number)} — ${pr.title}</a>
1006
+ <span class=${`ghpr-chip ghpr-chip-${stateChip.toLowerCase()}`}>${stateChip}</span>
1007
+ </div>
1008
+ <p class="ghpr-meta">
1009
+ ${pr.author === undefined ? null : html`<span>@${pr.author}</span>`}
1010
+ ${pr.headRefName === undefined || pr.baseRefName === undefined ? null : html`<span>${pr.headRefName} → ${pr.baseRefName}</span>`}
1011
+ ${pr.mergeable === "CONFLICTING" ? html`<span class="ghpr-conflict">conflicts</span>` : null}
1012
+ ${pr.reviewDecision === undefined ? null : html`<span>review: ${pr.reviewDecision.toLowerCase()}</span>`}
1013
+ </p>
1014
+ </section>
1015
+ `;
1016
+ }
1017
+ function renderCiSection(html, status) {
1018
+ const ci = status.ci;
1019
+ if (ci.state === "none") {
1020
+ return html`<p class="ghpr-muted">${status.pr === undefined ? "No CI checks (status unknown without a PR)." : "No CI checks configured for this pull request."}</p>`;
1021
+ }
1022
+ return html`
1023
+ <section class="ghpr-ci">
1024
+ <p class="ghpr-ci-head">
1025
+ <span
1026
+ class="ghpr-ball"
1027
+ title=${explainCiState(ci)}
1028
+ style="background:${CI_DOT_COLORS[ci.state]}"
1029
+ ></span>
1030
+ ${explainCiState(ci)}
1031
+ </p>
1032
+ ${ci.checks.length === 0 ? null : html`
1033
+ <ul class="ghpr-checks">
1034
+ ${ci.checks.map((check) => html`
1035
+ <li>
1036
+ <span class=${`ghpr-check-state ghpr-check-${check.state}`}>${checkStateGlyph(check.state)}</span>
1037
+ ${check.url === undefined ? html`<span>${check.name}</span>` : html`<a href=${check.url} target="_blank" rel="noopener noreferrer">${check.name}</a>`}
1038
+ </li>
1039
+ `)}
1040
+ </ul>
1041
+ `}
1042
+ </section>
1043
+ `;
1044
+ }
1045
+ function checkStateGlyph(state) {
1046
+ if (state === "passed")
1047
+ return "✓";
1048
+ if (state === "failed")
1049
+ return "✗";
1050
+ if (state === "running")
1051
+ return "◐";
1052
+ return "○";
1053
+ }
1054
+ function renderWorktreeSection(html, status) {
1055
+ const dirty = status.staged + status.unstaged + status.untracked;
1056
+ return html`
1057
+ <section class="ghpr-git">
1058
+ <p class="ghpr-meta">
1059
+ <span>${status.branch ?? "unknown branch"}</span>
1060
+ ${status.hasUpstream && (status.ahead > 0 || status.behind > 0) ? html`<span>↑${String(status.ahead)} ↓${String(status.behind)}</span>` : null}
1061
+ ${!status.hasUpstream ? html`<span class="ghpr-conflict">no upstream</span>` : null}
1062
+ </p>
1063
+ <p class="ghpr-meta ${dirty > 0 ? "ghpr-dirty" : ""}">
1064
+ ${dirty > 0 ? html`<span title="Uncommitted changes">✎ ${String(dirty)} changed (${String(status.staged)} staged, ${String(status.unstaged)} unstaged, ${String(status.untracked)} untracked)</span>` : html`<span>worktree clean</span>`}
1065
+ </p>
1066
+ </section>
1067
+ `;
1068
+ }
1069
+ function renderOutcome(html, state) {
1070
+ if (state.outcome === null)
1071
+ return null;
1072
+ return html`
1073
+ <div class=${state.outcome.ok ? "ghpr-success" : "ghpr-error"} role="status">
1074
+ ${state.outcome.message}
1075
+ ${state.outcome.terminalId === undefined ? null : html`<button type="button" @click=${() => {
1076
+ state.context.terminal.open({ terminalId: state.outcome?.terminalId });
1077
+ }}>Open terminal</button>`}
1078
+ </div>
1079
+ `;
1080
+ }
1081
+ function renderActions(html, controller, context, state, mergeEvaluation, closeEvaluation, busy) {
1082
+ const mergeTitle = mergeEvaluation.canMerge ? mergeEvaluation.confirmations.length > 0 ? "Merge with confirmation" : "Merge this pull request" : mergeEvaluation.blockers.join(" · ");
1083
+ const confirm = state.confirm;
1084
+ return html`
1085
+ <section class="ghpr-actions">
1086
+ ${confirm === null ? null : html`
1087
+ <div class="ghpr-warning" role="alert">
1088
+ <p>${confirm.reasons.join(" ")}</p>
1089
+ <div class="ghpr-confirm-buttons">
1090
+ <button
1091
+ type="button"
1092
+ class="ghpr-primary"
1093
+ ?disabled=${busy}
1094
+ @click=${() => {
1095
+ confirm.kind === "merge" ? controller.onMergeClick(context) : controller.onCloseClick(context);
1096
+ }}
1097
+ >
1098
+ ${confirm.kind === "merge" ? "Merge anyway" : "Close PR"}
1099
+ </button>
1100
+ <button type="button" ?disabled=${busy} @click=${() => {
1101
+ controller.cancelConfirm(context);
1102
+ }}>Cancel</button>
1103
+ </div>
1104
+ </div>
1105
+ `}
1106
+ <div class="ghpr-buttons">
1107
+ <button
1108
+ type="button"
1109
+ class="ghpr-primary"
1110
+ title=${mergeTitle}
1111
+ ?disabled=${busy}
1112
+ @click=${() => {
1113
+ controller.onMergeClick(context);
1114
+ }}
1115
+ >
1116
+ ${state.busy === "merge" ? "Merging…" : "Merge"}
1117
+ </button>
1118
+ <button
1119
+ type="button"
1120
+ title=${closeEvaluation.canClose ? "Close this pull request" : closeEvaluation.blockers.join(" · ")}
1121
+ ?disabled=${busy}
1122
+ @click=${() => {
1123
+ controller.onCloseClick(context);
1124
+ }}
1125
+ >
1126
+ ${state.busy === "close" ? "Closing…" : "Close PR"}
1127
+ </button>
1128
+ </div>
1129
+ </section>
1130
+ `;
1131
+ }
1132
+ function renderSettings(html, controller, context, state, settings) {
1133
+ if (!state.settingsOpen) {
1134
+ return html`<button type="button" class="ghpr-settings-toggle" @click=${() => {
1135
+ controller.toggleSettings(context);
1136
+ }}>⚙ Settings</button>`;
1137
+ }
1138
+ const draft = state.draft ?? settings;
1139
+ return html`
1140
+ <section class="ghpr-settings">
1141
+ <p class="ghpr-settings-hint">
1142
+ Stored per workspace in <code>${SETTINGS_PATH}</code>. Plugin: ${PLUGIN_ID}.
1143
+ </p>
1144
+ <label><input type="checkbox" ?checked=${draft.showCI} @change=${(event) => {
1145
+ updateCheckbox(context, controller, event, (d, value) => {
1146
+ d.showCI = value;
1147
+ });
1148
+ }} /> Show CI status</label>
1149
+ <label><input type="checkbox" ?checked=${draft.merge.enabled} @change=${(event) => {
1150
+ updateCheckbox(context, controller, event, (d, value) => {
1151
+ d.merge.enabled = value;
1152
+ });
1153
+ }} /> Allow one-click merge</label>
1154
+ <label><input type="checkbox" ?checked=${draft.merge.requireCleanWorktree} @change=${(event) => {
1155
+ updateCheckbox(context, controller, event, (d, value) => {
1156
+ d.merge.requireCleanWorktree = value;
1157
+ });
1158
+ }} /> Require clean worktree to merge</label>
1159
+ <label
1160
+ ><input type="checkbox" ?checked=${draft.merge.requireCI} @change=${(event) => {
1161
+ updateCheckbox(context, controller, event, (d, value) => {
1162
+ d.merge.requireCI = value;
1163
+ });
1164
+ }} /> Require CI
1165
+ confirmation (only when CI exists)</label
1166
+ >
1167
+ <label
1168
+ ><input type="checkbox" ?checked=${draft.merge.deleteBranch} @change=${(event) => {
1169
+ updateCheckbox(context, controller, event, (d, value) => {
1170
+ d.merge.deleteBranch = value;
1171
+ });
1172
+ }} /> Delete
1173
+ branch after merge</label
1174
+ >
1175
+ <label>
1176
+ Merge method
1177
+ <select
1178
+ @change=${(event) => {
1179
+ const value = event.target.value;
1180
+ controller.updateDraft(context, (d) => {
1181
+ d.merge.method = value === "squash" ? "squash" : value === "rebase" ? "rebase" : "merge";
1182
+ return d;
1183
+ });
1184
+ }}
1185
+ >
1186
+ ${MERGE_METHODS.map((method) => html`<option value=${method} ?selected=${draft.merge.method === method}>${method}</option>`)}
1187
+ </select>
1188
+ </label>
1189
+ <label>
1190
+ Refresh every
1191
+ <input
1192
+ type="number"
1193
+ min="0"
1194
+ max="3600"
1195
+ step="5"
1196
+ .value=${String(draft.refreshSeconds)}
1197
+ @change=${(event) => {
1198
+ const raw = event.target.value;
1199
+ const seconds = Math.max(0, Math.min(3600, Math.floor(Number(raw))));
1200
+ controller.updateDraft(context, (d) => {
1201
+ d.refreshSeconds = Number.isFinite(seconds) ? seconds : 0;
1202
+ return d;
1203
+ });
1204
+ }}
1205
+ />
1206
+ seconds (0 = only manual refresh)
1207
+ </label>
1208
+ <div class="ghpr-settings-actions">
1209
+ <button type="button" class="ghpr-primary" ?disabled=${state.busy === "settings"} @click=${() => {
1210
+ controller.saveSettings(context);
1211
+ }}>Save</button>
1212
+ <button type="button" @click=${() => {
1213
+ controller.resetSettings(context);
1214
+ }}>Reset to defaults</button>
1215
+ <button type="button" @click=${() => {
1216
+ controller.toggleSettings(context);
1217
+ }}>Close</button>
1218
+ </div>
1219
+ </section>
1220
+ `;
1221
+ }
1222
+ function updateCheckbox(context, controller, event, apply) {
1223
+ const value = event.target.checked;
1224
+ controller.updateDraft(context, (draft) => {
1225
+ apply(draft, value);
1226
+ return draft;
1227
+ });
1228
+ }
1229
+ var panelStyles = `
1230
+ .ghpr-panel { flex: 1 1 auto; min-height: 0; overflow: auto; color: var(--pi-text); background: var(--pi-bg); font: 13px system-ui, sans-serif; display: flex; flex-direction: column; }
1231
+ .ghpr-panel ${ACTIVITY_ELEMENT_TAG} { display: none; }
1232
+ .ghpr-panel button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 8px; cursor: pointer; font-size: 12px; }
1233
+ .ghpr-panel button:disabled { cursor: wait; opacity: .6; }
1234
+ .ghpr-panel button.ghpr-primary { border-color: var(--pi-accent); color: var(--pi-accent); }
1235
+ .ghpr-panel a { color: var(--pi-accent); text-decoration: none; }
1236
+ .ghpr-panel a:hover { text-decoration: underline; }
1237
+ .ghpr-toolbar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); }
1238
+ .ghpr-toolbar-actions { display: flex; gap: 8px; margin-left: auto; }
1239
+ .ghpr-muted { color: var(--pi-muted); margin: 10px 8px; }
1240
+ .ghpr-panel > :not(.ghpr-toolbar) { margin: 8px; }
1241
+ .ghpr-warning, .ghpr-error { border: 1px solid var(--pi-warning, #d29922); border-radius: 7px; padding: 8px; color: var(--pi-warning, #d29922); display: flex; flex-direction: column; gap: 6px; }
1242
+ .ghpr-error { border-color: var(--pi-danger, #f85149); color: var(--pi-danger, #f85149); }
1243
+ .ghpr-success { border: 1px solid var(--pi-success, #3fb950); border-radius: 7px; padding: 8px; color: var(--pi-success, #3fb950); display: flex; align-items: center; gap: 8px; }
1244
+ .ghpr-warning p { margin: 0; }
1245
+ .ghpr-confirm-buttons, .ghpr-buttons, .ghpr-settings-actions { display: flex; gap: 8px; flex-wrap: wrap; }
1246
+ .ghpr-pr-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-weight: 600; }
1247
+ .ghpr-meta { display: flex; gap: 10px; flex-wrap: wrap; color: var(--pi-muted); margin: 4px 0 0; font-size: 12px; }
1248
+ .ghpr-meta span { white-space: nowrap; }
1249
+ .ghpr-chip { border: 1px solid var(--pi-border); border-radius: 999px; padding: 0 7px; font-size: 11px; font-weight: 500; }
1250
+ .ghpr-chip-open, .ghpr-chip-merged { border-color: var(--pi-success, #3fb950); color: var(--pi-success, #3fb950); }
1251
+ .ghpr-chip-closed, .ghpr-chip-draft { border-color: var(--pi-muted); color: var(--pi-muted); }
1252
+ .ghpr-conflict { color: var(--pi-danger, #f85149); font-weight: 600; }
1253
+ .ghpr-ci-head { display: flex; align-items: center; gap: 8px; margin: 0; }
1254
+ .ghpr-ball { width: 10px; height: 10px; border-radius: 999px; display: inline-block; }
1255
+ .ghpr-checks { list-style: none; margin: 6px 0 0; padding: 0 0 0 18px; font-size: 12px; display: flex; flex-direction: column; gap: 3px; }
1256
+ .ghpr-check-state { display: inline-block; width: 14px; font-weight: 600; }
1257
+ .ghpr-check-passed { color: var(--pi-success, #3fb950); }
1258
+ .ghpr-check-failed { color: var(--pi-danger, #f85149); }
1259
+ .ghpr-check-running { color: #d29922; }
1260
+ .ghpr-check-skipped { color: var(--pi-muted); }
1261
+ .ghpr-git { border-top: 1px dashed var(--pi-border-muted); padding-top: 8px; }
1262
+ .ghpr-dirty span { color: #e3b341; }
1263
+ .ghpr-actions { display: flex; flex-direction: column; gap: 8px; border-top: 1px dashed var(--pi-border-muted); padding-top: 8px; }
1264
+ .ghpr-settings { display: flex; flex-direction: column; gap: 8px; border-top: 1px dashed var(--pi-border-muted); padding-top: 8px; font-size: 12px; }
1265
+ .ghpr-settings label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
1266
+ .ghpr-settings input[type="number"] { width: 80px; }
1267
+ .ghpr-settings select { background: var(--pi-surface); color: var(--pi-text); border: 1px solid var(--pi-border); border-radius: 6px; padding: 3px 6px; }
1268
+ .ghpr-settings-hint { color: var(--pi-muted); margin: 0; }
1269
+ .ghpr-settings-hint code { font-size: 11px; }
1270
+ .ghpr-settings-toggle { align-self: flex-start; margin-top: auto; }
1271
+ `;
1272
+
1273
+ // src/actions.ts
1274
+ function createActions(runtimePluginId) {
1275
+ const panelId = `${runtimePluginId}:${PANEL_LOCAL_ID}`;
1276
+ return [
1277
+ {
1278
+ id: "workspace.open-pr",
1279
+ title: "Open Pull Request panel",
1280
+ description: "Open the GitHub pull request status panel for this workspace",
1281
+ group: "Workspace",
1282
+ enabled: (context) => context.state.selectedWorkspace !== undefined,
1283
+ run: (context) => {
1284
+ context.selectWorkspaceTool(panelId);
1285
+ }
1286
+ },
1287
+ {
1288
+ id: "workspace.refresh-pr",
1289
+ title: "Refresh GitHub PR status",
1290
+ description: "Re-run the pull request status probe for the selected workspace",
1291
+ group: "Workspace",
1292
+ enabled: (context) => context.state.selectedWorkspace !== undefined,
1293
+ run: (context) => {
1294
+ context.refreshWorkspacePanels(panelId);
1295
+ }
1296
+ },
1297
+ {
1298
+ id: "workspace.open-pr-on-github",
1299
+ title: "Open pull request on GitHub",
1300
+ description: "Open the current pull request in a new tab",
1301
+ group: "Workspace",
1302
+ enabled: (context) => currentPrUrl(context) !== undefined,
1303
+ run: (context) => {
1304
+ const url = currentPrUrl(context);
1305
+ if (url !== undefined)
1306
+ window.open(url, "_blank", "noopener,noreferrer");
1307
+ }
1308
+ }
1309
+ ];
1310
+ }
1311
+ function currentPrUrl(context) {
1312
+ const workspace = context.state.selectedWorkspace;
1313
+ if (workspace === undefined)
1314
+ return;
1315
+ const machineId = context.state.selectedMachine?.id ?? "local";
1316
+ return statusCache.statusByKey(machineId, workspace.projectId, workspace.id)?.pr?.url;
1317
+ }
1318
+
1319
+ // src/labels.ts
1320
+ function labelDescriptors(status, showCI) {
1321
+ if (status === undefined || !status.git)
1322
+ return [];
1323
+ const pr = status.pr;
1324
+ if (pr === undefined || pr.state !== "OPEN")
1325
+ return [];
1326
+ const items = [
1327
+ {
1328
+ kind: "prLink",
1329
+ number: pr.number,
1330
+ href: pr.url,
1331
+ title: pr.isDraft ? `PR #${String(pr.number)} (draft): ${pr.title}` : `PR #${String(pr.number)}: ${pr.title}`,
1332
+ draft: pr.isDraft
1333
+ }
1334
+ ];
1335
+ if (showCI && status.ci.state !== "none") {
1336
+ items.push({ kind: "ciDot", ciState: status.ci.state, href: pr.url, title: explainCiState(status.ci) });
1337
+ }
1338
+ const dirty = status.staged + status.unstaged + status.untracked;
1339
+ if (dirty > 0) {
1340
+ items.push({
1341
+ kind: "dirtyDot",
1342
+ count: dirty,
1343
+ title: `Uncommitted changes: ${String(status.staged)} staged, ${String(status.unstaged)} unstaged, ${String(status.untracked)} untracked`
1344
+ });
1345
+ }
1346
+ if (status.ahead > 0) {
1347
+ items.push({ kind: "aheadArrow", count: status.ahead, title: `${String(status.ahead)} commit(s) not pushed` });
1348
+ }
1349
+ if (status.behind > 0) {
1350
+ items.push({ kind: "behindArrow", count: status.behind, title: `${String(status.behind)} commit(s) behind upstream` });
1351
+ }
1352
+ return items;
1353
+ }
1354
+ var CI_DOT_COLORS2 = {
1355
+ none: "transparent",
1356
+ passed: "#3fb950",
1357
+ running: "#d29922",
1358
+ failed: "#f85149"
1359
+ };
1360
+ function labelItem(html, descriptor) {
1361
+ switch (descriptor.kind) {
1362
+ case "prLink":
1363
+ return { type: "link", text: `#${String(descriptor.number)}`, href: descriptor.href, title: descriptor.title, target: "_blank" };
1364
+ case "ciDot": {
1365
+ const color = CI_DOT_COLORS2[descriptor.ciState];
1366
+ return {
1367
+ type: "render",
1368
+ render: () => html`<a
1369
+ href=${descriptor.href}
1370
+ target="_blank"
1371
+ rel="noopener noreferrer"
1372
+ title=${descriptor.title}
1373
+ style="text-decoration:none;color:${color};font-size:11px;line-height:1"
1374
+ >●</a
1375
+ >`
1376
+ };
1377
+ }
1378
+ case "dirtyDot":
1379
+ return {
1380
+ type: "render",
1381
+ render: () => html`<span title=${descriptor.title} style="color:#e3b341;font-weight:600;font-size:11px;line-height:1"
1382
+ >✎${String(descriptor.count)}</span
1383
+ >`
1384
+ };
1385
+ case "aheadArrow":
1386
+ return {
1387
+ type: "render",
1388
+ render: () => html`<span title=${descriptor.title} style="font-weight:600;font-size:11px;line-height:1">↑${String(descriptor.count)}</span>`
1389
+ };
1390
+ case "behindArrow":
1391
+ return {
1392
+ type: "render",
1393
+ render: () => html`<span title=${descriptor.title} style="color:var(--pi-muted, #8b949e);font-size:11px;line-height:1">↓${String(descriptor.count)}</span>`
1394
+ };
1395
+ }
1396
+ }
1397
+ function createWorkspaceLabelContribution(html) {
1398
+ return {
1399
+ id: "pr-status",
1400
+ order: 15,
1401
+ items: (context) => {
1402
+ const entry = statusCache.ensureLoaded(context);
1403
+ const showCI = entry.settings?.showCI ?? true;
1404
+ return labelDescriptors(entry.status, showCI).map((descriptor) => labelItem(html, descriptor));
1405
+ }
1406
+ };
1407
+ }
1408
+
1409
+ // src/index.ts
1410
+ var plugin = {
1411
+ apiVersion: 2,
1412
+ name: "GitHub PR Status",
1413
+ activate: ({ runtimePluginId, html, svg }) => {
1414
+ const controller = new PrUiController;
1415
+ return {
1416
+ contributions: {
1417
+ actions: createActions(runtimePluginId),
1418
+ workspacePanels: [createPanelContribution(html, svg, controller)],
1419
+ workspaceLabels: [createWorkspaceLabelContribution(html)]
1420
+ }
1421
+ };
1422
+ }
1423
+ };
1424
+ var src_default = plugin;
1425
+ export {
1426
+ src_default as default
1427
+ };