@promptctl/cc-candybar 1.5.0 → 1.6.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.mjs +74 -74
- package/package.json +5 -5
- package/src/config/default-dsl-config.ts +33 -0
- package/src/daemon/cache/git.ts +98 -2
- package/src/daemon/render-payload.ts +39 -0
- package/src/proc/launch.ts +4 -0
- package/src/segments/git.ts +228 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -91,10 +91,10 @@
|
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.6.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.6.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.6.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.6.0"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
|
@@ -280,6 +280,21 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
280
280
|
default: 0,
|
|
281
281
|
},
|
|
282
282
|
|
|
283
|
+
// Forge PR/MR — the daemon's git provider resolves the branch's open PR via
|
|
284
|
+
// gh/glab and projects it here. Declaring any of these turns on the network
|
|
285
|
+
// lookup. [LAW:no-silent-failure] prError is non-empty ONLY when the forge
|
|
286
|
+
// was asked but couldn't answer (auth/network) — distinct from "no PR"
|
|
287
|
+
// (every field empty). prNumber 0 (default) ⇒ no open PR.
|
|
288
|
+
"git.prNumber": {
|
|
289
|
+
kind: "input",
|
|
290
|
+
path: "git.prNumber",
|
|
291
|
+
type: "number",
|
|
292
|
+
default: 0,
|
|
293
|
+
},
|
|
294
|
+
"git.prState": { kind: "input", path: "git.prState", default: "" },
|
|
295
|
+
"git.prUrl": { kind: "input", path: "git.prUrl", default: "" },
|
|
296
|
+
"git.prError": { kind: "input", path: "git.prError", default: "" },
|
|
297
|
+
|
|
283
298
|
// Prompt-cache expiry — epoch seconds, projected by the cache provider.
|
|
284
299
|
// Same unit/shape as block/weekly resetsAt so the cacheTimer segment
|
|
285
300
|
// composes `minutesUntilReset` identically. 0 (default) ⇒ no cache
|
|
@@ -542,6 +557,24 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
542
557
|
fg: "foreground",
|
|
543
558
|
when: '{{ ne .git.branch "" }}',
|
|
544
559
|
},
|
|
560
|
+
// Git PR/MR — the branch's open pull/merge request as a clickable link.
|
|
561
|
+
// OPT-IN: declared but NOT in the default root (it adds a network gh/glab
|
|
562
|
+
// call). Add "gitPr" to a container's children to enable it. The `{{ link
|
|
563
|
+
// url text }}` emits ONE OSC-8 region carrying the https PR url, so the
|
|
564
|
+
// CLICK is handled by the terminal/OS (opens the browser) — no daemon verb.
|
|
565
|
+
// [LAW:no-silent-failure] Three render states from the data: an open PR
|
|
566
|
+
// (prUrl set) renders the link; a lookup FAILURE (prError set, prUrl empty)
|
|
567
|
+
// renders a distinct ⚠ marker so an outage is not mistaken for "no PR";
|
|
568
|
+
// no PR (both empty) leaves the `when` gate false and the segment absent.
|
|
569
|
+
gitPr: {
|
|
570
|
+
template:
|
|
571
|
+
'{{ if ne .git.prUrl "" }}' +
|
|
572
|
+
'{{ link .git.prUrl (printf " ⇆ #%v " .git.prNumber) }}' +
|
|
573
|
+
"{{ else }} ⚠ PR {{ end }}",
|
|
574
|
+
bg: "surface-active",
|
|
575
|
+
fg: "foreground",
|
|
576
|
+
when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
|
|
577
|
+
},
|
|
545
578
|
// Quick-action tray — copy the session id / cwd, open the project dir /
|
|
546
579
|
// transcript in the editor. [LAW:locality-or-seam] The glyph is the
|
|
547
580
|
// REPRESENTATION; the named action (below) is the BEHAVIOR; the action
|
package/src/daemon/cache/git.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { GitService, type GitInfo } from "../../segments/git";
|
|
4
|
-
import { ok, type Outcome } from "../../utils/outcome";
|
|
3
|
+
import { GitService, type GitInfo, type PullRequest } from "../../segments/git";
|
|
4
|
+
import { ABSENT, ok, type Outcome } from "../../utils/outcome";
|
|
5
5
|
import { debug } from "../../utils/logger";
|
|
6
6
|
import { WatcherRegistry, type WatcherHandle } from "./watchers";
|
|
7
7
|
|
|
@@ -44,6 +44,17 @@ const DEFAULT_TTL_MS = 30_000;
|
|
|
44
44
|
const DEFAULT_MAX_ENTRIES = 64;
|
|
45
45
|
const SANITY_INTERVAL_MS = 5 * 60_000;
|
|
46
46
|
|
|
47
|
+
// [LAW:decomposition] The forge PR lookup is a network resource with a
|
|
48
|
+
// different lifecycle than local git state: it changes ~twice in a PR's life
|
|
49
|
+
// and the fs watchers on .git/HEAD|index say nothing about remote PR state. So
|
|
50
|
+
// it gets its OWN cache + TTL — independent of the 30s GitInfo TTL — so the
|
|
51
|
+
// per-render git fetch stays all-local and the gh/glab spawn happens at most
|
|
52
|
+
// once per window (or on branch switch, via the branch in the key). A `failed`
|
|
53
|
+
// lookup caches for a SHORTER window so a transient forge outage is retried
|
|
54
|
+
// soon, not pinned to the bar for the full ok-TTL.
|
|
55
|
+
const PR_TTL_OK_MS = 5 * 60_000;
|
|
56
|
+
const PR_TTL_FAIL_MS = 45_000;
|
|
57
|
+
|
|
47
58
|
// Options applied to subscribe()'s internal getInfo() call. var-system's six
|
|
48
59
|
// GitField values (branch, sha, dirty, ahead, behind, stash) all project from
|
|
49
60
|
// these flags — keep them in lockstep with the projection in
|
|
@@ -60,6 +71,11 @@ interface MtimeSnapshot {
|
|
|
60
71
|
index: number;
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
interface PrCacheEntry {
|
|
75
|
+
pr: Outcome<PullRequest>;
|
|
76
|
+
computedAt: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
interface GitCacheEntry {
|
|
64
80
|
info: GitInfo;
|
|
65
81
|
computedAt: number;
|
|
@@ -147,6 +163,16 @@ export class GitDataProvider extends GitService {
|
|
|
147
163
|
// here; subsequent concurrent callers await the same promise and resolve in
|
|
148
164
|
// lockstep.
|
|
149
165
|
private readonly fetchInFlight = new Map<string, Promise<Outcome<GitInfo>>>();
|
|
166
|
+
// [LAW:one-source-of-truth] The forge PR cache, keyed by `repoRoot|branch`
|
|
167
|
+
// (a branch switch is a new key, so its PR is fetched fresh; the old branch's
|
|
168
|
+
// entry ages out). Separate from `entries` because it carries its own TTL.
|
|
169
|
+
private readonly prCache = new Map<string, PrCacheEntry>();
|
|
170
|
+
// [LAW:single-enforcer] Coalesce concurrent PR misses on the same key — same
|
|
171
|
+
// role as fetchInFlight for the GitInfo path, but for the network forge call.
|
|
172
|
+
private readonly prFetchInFlight = new Map<
|
|
173
|
+
string,
|
|
174
|
+
Promise<Outcome<PullRequest>>
|
|
175
|
+
>();
|
|
150
176
|
// [LAW:single-enforcer] Coalesce overlapping refreshes for the same repo.
|
|
151
177
|
// `refreshing` holds the repoRoots whose refresh loop is currently
|
|
152
178
|
// executing; `refreshAgain` is the trailing-edge flag: if a new
|
|
@@ -292,6 +318,15 @@ export class GitDataProvider extends GitService {
|
|
|
292
318
|
if (outcome.kind !== "ok") return outcome;
|
|
293
319
|
const info = outcome.value;
|
|
294
320
|
|
|
321
|
+
// [LAW:decomposition] The inner GitService never resolves the PR — the
|
|
322
|
+
// cache layer owns that lookup so it can give it an independent TTL. On the
|
|
323
|
+
// 30s GitInfo refetch the prCache is almost always warm, so attaching the
|
|
324
|
+
// PR here is a memory read; the gh/glab spawn only fires when the prCache
|
|
325
|
+
// entry has aged past its (outcome-dependent) TTL or the branch changed.
|
|
326
|
+
if (options.showPullRequest) {
|
|
327
|
+
info.pullRequest = await this.getPullRequestCached(repoRoot, info.branch);
|
|
328
|
+
}
|
|
329
|
+
|
|
295
330
|
// Drop any prior entry for this exact key before re-inserting (so we
|
|
296
331
|
// release its watcher refcount cleanly).
|
|
297
332
|
this.dropEntry(key);
|
|
@@ -315,6 +350,63 @@ export class GitDataProvider extends GitService {
|
|
|
315
350
|
return outcome;
|
|
316
351
|
}
|
|
317
352
|
|
|
353
|
+
// [LAW:single-enforcer] The one read path for a branch's PR. Reads the remote
|
|
354
|
+
// (cheap local git) so the cache key reflects every input the PR value
|
|
355
|
+
// depends on — `repoRoot|branch|remote` — and a re-pointed origin is a fresh
|
|
356
|
+
// key, not a stale hit ([LAW:one-source-of-truth]). TTL is outcome-dependent
|
|
357
|
+
// (a `failed` retries sooner). Concurrent misses coalesce through
|
|
358
|
+
// prFetchInFlight. The forge dispatch delegates to inner.resolvePullRequest —
|
|
359
|
+
// this layer is cache + lifecycle only, never forge knowledge.
|
|
360
|
+
private async getPullRequestCached(
|
|
361
|
+
repoRoot: string,
|
|
362
|
+
branch: string,
|
|
363
|
+
): Promise<Outcome<PullRequest>> {
|
|
364
|
+
// [LAW:no-silent-failure] A `failed` remote read (git couldn't run) must
|
|
365
|
+
// surface; only `absent` (no remote configured) means "no forge PR".
|
|
366
|
+
const remote = await this.inner.getRemoteOriginUrl(repoRoot);
|
|
367
|
+
if (remote.kind === "failed") return remote;
|
|
368
|
+
if (remote.kind === "absent") return ABSENT;
|
|
369
|
+
const remoteUrl = remote.value;
|
|
370
|
+
|
|
371
|
+
const key = `${repoRoot}|${branch}|${remoteUrl}`;
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
|
|
374
|
+
const existing = this.prCache.get(key);
|
|
375
|
+
if (existing) {
|
|
376
|
+
const ttl = existing.pr.kind === "failed" ? PR_TTL_FAIL_MS : PR_TTL_OK_MS;
|
|
377
|
+
if (now - existing.computedAt < ttl) {
|
|
378
|
+
// LRU touch so the active branch's PR survives eviction.
|
|
379
|
+
this.prCache.delete(key);
|
|
380
|
+
this.prCache.set(key, existing);
|
|
381
|
+
return existing.pr;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const pending = this.prFetchInFlight.get(key);
|
|
386
|
+
if (pending) return pending;
|
|
387
|
+
|
|
388
|
+
const promise = this.inner
|
|
389
|
+
.resolvePullRequest(repoRoot, remoteUrl)
|
|
390
|
+
.then((pr) => {
|
|
391
|
+
this.prCache.set(key, { pr, computedAt: Date.now() });
|
|
392
|
+
this.evictPrIfNeeded();
|
|
393
|
+
return pr;
|
|
394
|
+
})
|
|
395
|
+
.finally(() => {
|
|
396
|
+
this.prFetchInFlight.delete(key);
|
|
397
|
+
});
|
|
398
|
+
this.prFetchInFlight.set(key, promise);
|
|
399
|
+
return promise;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private evictPrIfNeeded(): void {
|
|
403
|
+
while (this.prCache.size > this.maxEntries) {
|
|
404
|
+
const oldest = this.prCache.keys().next().value;
|
|
405
|
+
if (oldest === undefined) break;
|
|
406
|
+
this.prCache.delete(oldest);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
318
410
|
// [LAW:effects-at-boundaries] The subscribe surface's edge: fold the typed
|
|
319
411
|
// outcome into the GitInfo|null the var-system callback contract expects.
|
|
320
412
|
// `failed` is logged HERE — the one log site for this consumption path
|
|
@@ -548,6 +640,10 @@ export class GitDataProvider extends GitService {
|
|
|
548
640
|
this.refreshing.clear();
|
|
549
641
|
this.refreshAgain.clear();
|
|
550
642
|
this.fetchInFlight.clear();
|
|
643
|
+
// In-flight PR fetches resolve naturally; clearing the maps means the next
|
|
644
|
+
// caller starts fresh (the prCache is rebuilt cold like every other cache).
|
|
645
|
+
this.prCache.clear();
|
|
646
|
+
this.prFetchInFlight.clear();
|
|
551
647
|
if (this.ownsWatchers) this.watchers.closeAll();
|
|
552
648
|
}
|
|
553
649
|
}
|
|
@@ -98,6 +98,16 @@ export interface GitPayload {
|
|
|
98
98
|
readonly status?: string;
|
|
99
99
|
readonly operation?: string;
|
|
100
100
|
readonly timeSinceCommit?: number;
|
|
101
|
+
// Forge PR/MR. [LAW:no-silent-failure] Unlike every other git field (where
|
|
102
|
+
// `failed` collapses to a missing key), the PR's failure is surfaced as
|
|
103
|
+
// `prError` so the segment can render a VISIBLY DISTINCT marker — a forge
|
|
104
|
+
// outage must not look like "no PR". The three render-distinguishable states:
|
|
105
|
+
// open PR (prNumber/prState/prUrl present), lookup failed (prError present),
|
|
106
|
+
// no PR (all absent → segment when-gated off).
|
|
107
|
+
readonly prNumber?: number;
|
|
108
|
+
readonly prState?: string;
|
|
109
|
+
readonly prUrl?: string;
|
|
110
|
+
readonly prError?: string;
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
export interface SessionPayload {
|
|
@@ -472,6 +482,12 @@ function gitOptionsFromClosure(needed: ReadonlySet<string>): GitInfoOptions {
|
|
|
472
482
|
...(has("git.repoName") && { showRepoName: true }),
|
|
473
483
|
...(has("git.operation") && { showOperation: true }),
|
|
474
484
|
...(has("git.timeSinceCommit") && { showTimeSinceCommit: true }),
|
|
485
|
+
// Any PR field laid out turns on the (network) forge lookup. Keep these in
|
|
486
|
+
// lockstep with the projected `git.pr*` fields below.
|
|
487
|
+
...((has("git.prNumber") ||
|
|
488
|
+
has("git.prState") ||
|
|
489
|
+
has("git.prUrl") ||
|
|
490
|
+
has("git.prError")) && { showPullRequest: true }),
|
|
475
491
|
};
|
|
476
492
|
}
|
|
477
493
|
|
|
@@ -809,6 +825,28 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
|
|
|
809
825
|
const upstream = field("upstream", info.upstream);
|
|
810
826
|
const repoName = field("repoName", info.repoName);
|
|
811
827
|
|
|
828
|
+
// [LAW:no-silent-failure] The PR deliberately breaks the `field` pattern: a
|
|
829
|
+
// `failed` lookup is NOT dropped to a missing key (which the template can't
|
|
830
|
+
// tell apart from "no PR"). It is BOTH logged AND surfaced as `prError` so
|
|
831
|
+
// the segment renders a distinct marker. `absent` is still a missing key (no
|
|
832
|
+
// PR / no forge → segment off). The reason is the gate value the template
|
|
833
|
+
// tests; the same reason is logged for the operator.
|
|
834
|
+
const pr = info.pullRequest;
|
|
835
|
+
const prFields: {
|
|
836
|
+
prNumber?: number;
|
|
837
|
+
prState?: string;
|
|
838
|
+
prUrl?: string;
|
|
839
|
+
prError?: string;
|
|
840
|
+
} = {};
|
|
841
|
+
if (pr?.kind === "ok") {
|
|
842
|
+
prFields.prNumber = pr.value.number;
|
|
843
|
+
prFields.prState = pr.value.state;
|
|
844
|
+
prFields.prUrl = pr.value.url;
|
|
845
|
+
} else if (pr?.kind === "failed") {
|
|
846
|
+
failures.push(`git.pr: ${pr.reason}`);
|
|
847
|
+
prFields.prError = pr.reason;
|
|
848
|
+
}
|
|
849
|
+
|
|
812
850
|
return {
|
|
813
851
|
git: {
|
|
814
852
|
branch: info.branch,
|
|
@@ -829,6 +867,7 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
|
|
|
829
867
|
...(stash !== undefined && { stash }),
|
|
830
868
|
...(upstream !== undefined && { upstream }),
|
|
831
869
|
...(repoName !== undefined && { repoName }),
|
|
870
|
+
...prFields,
|
|
832
871
|
},
|
|
833
872
|
failures,
|
|
834
873
|
};
|
package/src/proc/launch.ts
CHANGED
|
@@ -31,6 +31,10 @@ import type { LaunchStatsHandle } from "./stats-handle";
|
|
|
31
31
|
// pattern. [LAW:no-mode-explosion]: no per-site escape hatch.
|
|
32
32
|
export const LAUNCH_CATEGORIES = [
|
|
33
33
|
"git",
|
|
34
|
+
// Forge CLIs (gh / glab) for the PR/MR lookup. A network-bound spawn,
|
|
35
|
+
// separate from "git" so daemon-stats attributes it independently and a
|
|
36
|
+
// future rate limit can target it without throttling local git.
|
|
37
|
+
"forge",
|
|
34
38
|
"user-shell",
|
|
35
39
|
"tmux",
|
|
36
40
|
"click.pbcopy",
|
package/src/segments/git.ts
CHANGED
|
@@ -16,6 +16,17 @@ export interface AheadBehind {
|
|
|
16
16
|
behind: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// [LAW:types-are-the-program] The branch's open PR/MR as the forge reports it.
|
|
20
|
+
// `number` and `url` are the click target; `state` is the forge's status string
|
|
21
|
+
// (GitHub "OPEN", GitLab "opened") — carried so a consumer can color/label it,
|
|
22
|
+
// though resolvePullRequest only ever returns a PR whose state is open (a
|
|
23
|
+
// merged/closed PR for the branch is the domain's `absent`, not a value).
|
|
24
|
+
export interface PullRequest {
|
|
25
|
+
number: number;
|
|
26
|
+
state: string;
|
|
27
|
+
url: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
// [LAW:types-are-the-program] Every on-demand field is an Outcome, so "this
|
|
20
31
|
// value is unknown because the fetch failed" is representable distinct from
|
|
21
32
|
// a real 0/""/basename — the states the old catch-and-substitute blocks
|
|
@@ -37,6 +48,13 @@ export interface GitInfo {
|
|
|
37
48
|
upstream?: Outcome<string>;
|
|
38
49
|
repoName?: Outcome<string>;
|
|
39
50
|
isWorktree?: boolean;
|
|
51
|
+
// [LAW:no-silent-failure] The forge lookup's three outcomes are all kept
|
|
52
|
+
// distinct here: `ok` is an open PR, `absent` is "this branch has none / no
|
|
53
|
+
// forge / no forge CLI", `failed` is "the forge was asked but couldn't
|
|
54
|
+
// answer" (auth, network, API error). The render boundary surfaces `failed`
|
|
55
|
+
// as a VISIBLE marker — collapsing it to `absent` would make a transient
|
|
56
|
+
// outage look like the PR vanished. Undefined = `showPullRequest` was off.
|
|
57
|
+
pullRequest?: Outcome<PullRequest>;
|
|
40
58
|
}
|
|
41
59
|
|
|
42
60
|
// [LAW:one-source-of-truth] The one shape of getGitInfo's `show*` toggles. Each
|
|
@@ -53,6 +71,11 @@ export interface GitInfoOptions {
|
|
|
53
71
|
showStashCount?: boolean;
|
|
54
72
|
showUpstream?: boolean;
|
|
55
73
|
showRepoName?: boolean;
|
|
74
|
+
// Opts into the forge (gh/glab) PR/MR lookup — a network call, so it is the
|
|
75
|
+
// one option whose fetch the daemon caches on a longer, independent TTL than
|
|
76
|
+
// the rest of GitInfo (see src/daemon/cache/git.ts). Never resolved by the
|
|
77
|
+
// inner GitService's computeGitInfo; the cache layer owns the lookup+cache.
|
|
78
|
+
showPullRequest?: boolean;
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
// [LAW:dataflow-not-control-flow] One classifier for every git invocation.
|
|
@@ -89,6 +112,134 @@ function nonEmpty(o: Outcome<string>): Outcome<string> {
|
|
|
89
112
|
return v ? ok(v) : ABSENT;
|
|
90
113
|
}
|
|
91
114
|
|
|
115
|
+
// [LAW:one-type-per-behavior] `gh` and `glab` are two instances of one act:
|
|
116
|
+
// "ask a forge CLI for the branch's PR, fold the typed launch result into an
|
|
117
|
+
// Outcome<PullRequest>." The accept/reject shape table is identical across
|
|
118
|
+
// both — only the no-PR stderr signature and the JSON field names differ — so
|
|
119
|
+
// the classification lives here once and each forge supplies its own
|
|
120
|
+
// (noPrPattern, parse) as data.
|
|
121
|
+
//
|
|
122
|
+
// [LAW:no-silent-failure] The full shape table, enumerated so no input leaks:
|
|
123
|
+
// ok + parse ok (open PR) → ok (the value)
|
|
124
|
+
// ok + parse ok (not open) → absent (branch's PR is done)
|
|
125
|
+
// ok + parse fails → failed (forge answered garbage)
|
|
126
|
+
// non-zero + no-PR stderr → absent (genuine "none for branch")
|
|
127
|
+
// spawn-error ENOENT (no CLI) → absent (no forge integration)
|
|
128
|
+
// spawn-error other (EACCES, …) → failed (CLI present but unlaunchable)
|
|
129
|
+
// non-zero (auth/net/not-a-repo) → failed (forge couldn't answer)
|
|
130
|
+
// timeout / signal / rate-limited → failed (forge couldn't answer)
|
|
131
|
+
export type ForgeName = "github" | "gitlab";
|
|
132
|
+
|
|
133
|
+
// [LAW:types-are-the-program] Extract the host from a git remote, handling the
|
|
134
|
+
// two shapes git uses: scp-like `[user@]host:path` and URL `scheme://[user@]
|
|
135
|
+
// host[:port]/path`. The URL form is checked first — its `host` in a scp regex
|
|
136
|
+
// would mis-capture the scheme (`https` before `://`). Returns null for an
|
|
137
|
+
// unrecognized shape (local path, unknown syntax).
|
|
138
|
+
function remoteHost(remoteUrl: string): string | null {
|
|
139
|
+
const url = remoteUrl.trim();
|
|
140
|
+
const proto = url.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i);
|
|
141
|
+
if (proto) return proto[1]!.toLowerCase();
|
|
142
|
+
const scp = url.match(/^(?:[^@/]+@)?([^/:]+):/);
|
|
143
|
+
if (scp) return scp[1]!.toLowerCase();
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// [LAW:types-are-the-program] Branch on the HOST, not a substring of the whole
|
|
148
|
+
// URL — a non-GitLab remote whose path merely contains "gitlab" (a repo named
|
|
149
|
+
// `gitlab`) must not dispatch to glab. Self-hosted GitLab is detected by a
|
|
150
|
+
// `gitlab.`-prefixed host label (gitlab.example.com); a GitLab on an arbitrary
|
|
151
|
+
// hostname is undetectable here and falls through to null (absent), same as
|
|
152
|
+
// GitHub Enterprise on a custom domain.
|
|
153
|
+
export function detectForge(remoteUrl: string): ForgeName | null {
|
|
154
|
+
const host = remoteHost(remoteUrl);
|
|
155
|
+
if (!host) return null;
|
|
156
|
+
if (host === "github.com" || host.endsWith(".github.com")) return "github";
|
|
157
|
+
if (/(^|\.)gitlab\./.test(host)) return "gitlab";
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function classifyForgePr(
|
|
162
|
+
label: string,
|
|
163
|
+
result: LaunchResult,
|
|
164
|
+
noPrPattern: RegExp,
|
|
165
|
+
parse: (stdout: string) => Outcome<PullRequest>,
|
|
166
|
+
): Outcome<PullRequest> {
|
|
167
|
+
if (result.ok) return parse(result.stdout);
|
|
168
|
+
// ENOENT (no forge CLI on PATH) is a static configuration absence, not a
|
|
169
|
+
// transient lookup failure — it never showed a PR, so showing nothing costs
|
|
170
|
+
// nothing. [LAW:no-silent-failure] Every OTHER spawn failure (EACCES,
|
|
171
|
+
// resource limits) means the CLI is present but could not launch — a real
|
|
172
|
+
// failure that must stay visible, so it falls through to the `failed` path.
|
|
173
|
+
if (result.reason === "spawn-error" && /ENOENT/i.test(result.error ?? ""))
|
|
174
|
+
return ABSENT;
|
|
175
|
+
if (result.reason === "non-zero" && noPrPattern.test(result.stderr))
|
|
176
|
+
return ABSENT;
|
|
177
|
+
const detail = [
|
|
178
|
+
result.reason,
|
|
179
|
+
result.exitCode != null ? `exit ${result.exitCode}` : null,
|
|
180
|
+
result.error ?? firstLine(result.stderr),
|
|
181
|
+
]
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
.join(", ");
|
|
184
|
+
return failed(`${label}: ${detail}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
188
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// `gh pr view --json number,state,url` → one JSON object. Only an OPEN PR is a
|
|
192
|
+
// value; a MERGED/CLOSED PR for the branch is the domain's `absent`.
|
|
193
|
+
export function parseGithubPr(stdout: string): Outcome<PullRequest> {
|
|
194
|
+
let json: unknown;
|
|
195
|
+
try {
|
|
196
|
+
json = JSON.parse(stdout);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return failed(
|
|
199
|
+
`gh pr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (!isRecord(json)) return failed("gh pr view: JSON is not an object");
|
|
203
|
+
const { number, state, url } = json;
|
|
204
|
+
if (
|
|
205
|
+
typeof number !== "number" ||
|
|
206
|
+
typeof state !== "string" ||
|
|
207
|
+
typeof url !== "string"
|
|
208
|
+
) {
|
|
209
|
+
return failed("gh pr view: missing number/state/url");
|
|
210
|
+
}
|
|
211
|
+
if (state.toUpperCase() !== "OPEN") return ABSENT;
|
|
212
|
+
return ok({ number, state, url });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `glab mr view --output json` → one JSON object (iid / state / web_url). State
|
|
216
|
+
// "opened" is the open MR; anything else is `absent`. NOTE: verified against
|
|
217
|
+
// glab's documented JSON shape, not runtime-exercised here (glab not installed
|
|
218
|
+
// on the dev machine) — the github path is the runtime-verified one.
|
|
219
|
+
export function parseGitlabMr(stdout: string): Outcome<PullRequest> {
|
|
220
|
+
let json: unknown;
|
|
221
|
+
try {
|
|
222
|
+
json = JSON.parse(stdout);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
return failed(
|
|
225
|
+
`glab mr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (!isRecord(json)) return failed("glab mr view: JSON is not an object");
|
|
229
|
+
const iid = json.iid;
|
|
230
|
+
const state = json.state;
|
|
231
|
+
const url = json.web_url;
|
|
232
|
+
if (
|
|
233
|
+
typeof iid !== "number" ||
|
|
234
|
+
typeof state !== "string" ||
|
|
235
|
+
typeof url !== "string"
|
|
236
|
+
) {
|
|
237
|
+
return failed("glab mr view: missing iid/state/web_url");
|
|
238
|
+
}
|
|
239
|
+
if (state.toLowerCase() !== "opened") return ABSENT;
|
|
240
|
+
return ok({ number: iid, state, url });
|
|
241
|
+
}
|
|
242
|
+
|
|
92
243
|
export class GitService {
|
|
93
244
|
private isGitRepo(workingDir: string): boolean {
|
|
94
245
|
try {
|
|
@@ -407,6 +558,83 @@ export class GitService {
|
|
|
407
558
|
return ok(match?.[1] || path.basename(workingDir));
|
|
408
559
|
}
|
|
409
560
|
|
|
561
|
+
// [LAW:locality-or-seam] Public so the daemon's GitDataProvider can read the
|
|
562
|
+
// remote to fold into its PR cache key (the PR value depends on the remote;
|
|
563
|
+
// a re-pointed origin must be a new key). Raw origin URL (unparsed) — the
|
|
564
|
+
// forge detector reads the host from it. `config --get` exits 1 when unset →
|
|
565
|
+
// `absent` (no remote, hence no forge PR concept), distinct from a failure.
|
|
566
|
+
async getRemoteOriginUrl(workingDir: string): Promise<Outcome<string>> {
|
|
567
|
+
return nonEmpty(
|
|
568
|
+
classify(
|
|
569
|
+
"git config remote.origin.url",
|
|
570
|
+
await this.execGitAsync(["config", "--get", "remote.origin.url"], {
|
|
571
|
+
cwd: workingDir,
|
|
572
|
+
timeout: 2000,
|
|
573
|
+
}),
|
|
574
|
+
"absent",
|
|
575
|
+
),
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// [LAW:single-enforcer] One boundary for forge-CLI spawns. Mirrors
|
|
580
|
+
// execGitAsync but carries the "forge" launch category and a longer timeout
|
|
581
|
+
// (this is a network call, not a local git read). Returns the typed
|
|
582
|
+
// LaunchResult so classifyForgePr maps every termination cause to an Outcome.
|
|
583
|
+
private async execForgeAsync(
|
|
584
|
+
bin: string,
|
|
585
|
+
args: readonly string[],
|
|
586
|
+
options: { cwd: string; timeout: number },
|
|
587
|
+
): Promise<LaunchResult> {
|
|
588
|
+
return launch({
|
|
589
|
+
bin,
|
|
590
|
+
args: [...args],
|
|
591
|
+
cwd: options.cwd,
|
|
592
|
+
env: { ...process.env },
|
|
593
|
+
timeoutMs: options.timeout,
|
|
594
|
+
category: "forge",
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// [LAW:effects-at-boundaries] Resolve the branch's open PR/MR via the forge
|
|
599
|
+
// CLI. Pure dispatch over a remote the CALLER has already read: pick the
|
|
600
|
+
// forge by host, run its CLI, fold the launch result into an Outcome. The
|
|
601
|
+
// remote is a parameter (not read here) so the cache layer can fold it into
|
|
602
|
+
// its key in the same read — no caching here; the daemon's GitDataProvider
|
|
603
|
+
// owns the PR cache + TTL (a network resource wants a longer, independent
|
|
604
|
+
// lifecycle than local git state). `absent` when the host is no recognized
|
|
605
|
+
// forge; the CLI dispatch then classifies the rest.
|
|
606
|
+
async resolvePullRequest(
|
|
607
|
+
workingDir: string,
|
|
608
|
+
remoteUrl: string,
|
|
609
|
+
): Promise<Outcome<PullRequest>> {
|
|
610
|
+
const forge = detectForge(remoteUrl);
|
|
611
|
+
if (forge === "github") {
|
|
612
|
+
return classifyForgePr(
|
|
613
|
+
"gh pr view",
|
|
614
|
+
await this.execForgeAsync(
|
|
615
|
+
"gh",
|
|
616
|
+
["pr", "view", "--json", "number,state,url"],
|
|
617
|
+
{ cwd: workingDir, timeout: 5000 },
|
|
618
|
+
),
|
|
619
|
+
/no (open )?pull requests? found/i,
|
|
620
|
+
parseGithubPr,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
if (forge === "gitlab") {
|
|
624
|
+
return classifyForgePr(
|
|
625
|
+
"glab mr view",
|
|
626
|
+
await this.execForgeAsync("glab", ["mr", "view", "--output", "json"], {
|
|
627
|
+
cwd: workingDir,
|
|
628
|
+
timeout: 5000,
|
|
629
|
+
}),
|
|
630
|
+
/no (open )?merge requests? (found|available)/i,
|
|
631
|
+
parseGitlabMr,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
// Recognized neither host → no forge integration for this remote.
|
|
635
|
+
return ABSENT;
|
|
636
|
+
}
|
|
637
|
+
|
|
410
638
|
private isWorktree(workingDir: string): boolean {
|
|
411
639
|
try {
|
|
412
640
|
const gitDir = path.join(workingDir, ".git");
|