merge-steward 0.10.4 → 0.11.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.
@@ -20,6 +20,10 @@ export declare class MergeStewardQueueCommands {
20
20
  priority?: number;
21
21
  }): QueueEntry | undefined;
22
22
  scanStartupAdmissions(): Promise<void>;
23
+ scanEligibleOpenPrs(): Promise<{
24
+ scanned: number;
25
+ admitted: number;
26
+ }>;
23
27
  tryAdmit(prNumber: number, branch: string, headSha: string): Promise<boolean>;
24
28
  dequeueByPR(prNumber: number): void;
25
29
  updateHeadByPR(prNumber: number, headSha: string): void;
@@ -68,20 +68,24 @@ export class MergeStewardQueueCommands {
68
68
  async scanStartupAdmissions() {
69
69
  this.logger.info({ repoId: this.config.repoId }, "Scanning startup admissions");
70
70
  try {
71
- const open = await this.github.listOpenPRs();
72
- let admitted = 0;
73
- for (const pr of open) {
74
- if (await this.tryAdmit(pr.number, pr.branch, pr.headSha))
75
- admitted += 1;
76
- }
77
- if (open.length > 0) {
78
- this.logger.info({ scanned: open.length, admitted }, "Startup scan for eligible open PRs complete");
71
+ const { scanned, admitted } = await this.scanEligibleOpenPrs();
72
+ if (scanned > 0) {
73
+ this.logger.info({ scanned, admitted }, "Startup scan for eligible open PRs complete");
79
74
  }
80
75
  }
81
76
  catch (error) {
82
77
  this.logger.warn({ err: error }, "Startup scan for eligible open PRs failed");
83
78
  }
84
79
  }
80
+ async scanEligibleOpenPrs() {
81
+ const open = await this.github.listOpenPRs();
82
+ let admitted = 0;
83
+ for (const pr of open) {
84
+ if (await this.tryAdmit(pr.number, pr.branch, pr.headSha))
85
+ admitted += 1;
86
+ }
87
+ return { scanned: open.length, admitted };
88
+ }
85
89
  async tryAdmit(prNumber, branch, headSha) {
86
90
  if (this.config.excludeBranches.some((pattern) => matchGlob(pattern, branch))) {
87
91
  this.logger.debug({ prNumber, branch }, "Branch excluded from admission");
@@ -14,6 +14,7 @@ export declare class MergeStewardRuntime {
14
14
  private readonly eviction;
15
15
  private readonly specBuilder;
16
16
  private readonly logger;
17
+ private readonly beforeTick?;
17
18
  private tickTimer;
18
19
  private tickInProgress;
19
20
  private lastTickStartedAt;
@@ -21,7 +22,7 @@ export declare class MergeStewardRuntime {
21
22
  private lastTickOutcome;
22
23
  private lastTickError;
23
24
  private currentQueueBlock;
24
- constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger);
25
+ constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger, beforeTick?: (() => Promise<void>) | undefined);
25
26
  start(): Promise<void>;
26
27
  stop(): Promise<void>;
27
28
  triggerReconcile(): Promise<{
@@ -9,6 +9,7 @@ export class MergeStewardRuntime {
9
9
  eviction;
10
10
  specBuilder;
11
11
  logger;
12
+ beforeTick;
12
13
  tickTimer;
13
14
  tickInProgress = false;
14
15
  lastTickStartedAt = null;
@@ -16,7 +17,7 @@ export class MergeStewardRuntime {
16
17
  lastTickOutcome = "idle";
17
18
  lastTickError = null;
18
19
  currentQueueBlock = null;
19
- constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger) {
20
+ constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger, beforeTick) {
20
21
  this.config = config;
21
22
  this.policy = policy;
22
23
  this.store = store;
@@ -26,6 +27,7 @@ export class MergeStewardRuntime {
26
27
  this.eviction = eviction;
27
28
  this.specBuilder = specBuilder;
28
29
  this.logger = logger;
30
+ this.beforeTick = beforeTick;
29
31
  }
30
32
  async start() {
31
33
  this.logger.info({ pollIntervalMs: this.config.pollIntervalMs }, "Steward service starting");
@@ -79,6 +81,7 @@ export class MergeStewardRuntime {
79
81
  this.lastTickOutcome = "running";
80
82
  this.lastTickError = null;
81
83
  try {
84
+ await this.beforeTick?.();
82
85
  let tickQueueBlockEvent = null;
83
86
  await reconcile({
84
87
  store: this.store,
package/dist/service.js CHANGED
@@ -28,8 +28,10 @@ export class MergeStewardService {
28
28
  this.eviction = eviction;
29
29
  this.specBuilder = specBuilder;
30
30
  this.logger = logger;
31
- this.runtime = new MergeStewardRuntime(config, policy, store, git, ci, github, eviction, specBuilder, logger);
32
31
  this.queueCommands = new MergeStewardQueueCommands(config, policy, store, github, specBuilder, logger);
32
+ this.runtime = new MergeStewardRuntime(config, policy, store, git, ci, github, eviction, specBuilder, logger, async () => {
33
+ await this.queueCommands.scanEligibleOpenPrs();
34
+ });
33
35
  this.watchQueries = new MergeStewardWatchQueries(config, store, this.runtime);
34
36
  }
35
37
  /** Expose the GitHub client for webhook handler branch→PR lookups. */
@@ -1,8 +1,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
2
+ import { Box, Text, useStdout } from "ink";
3
3
  export function HelpBar({ view }) {
4
- const text = view === "overview"
5
- ? "j/k: select project Enter: open project r: reconcile selected project q: quit"
6
- : "j/k: select PR Esc: overview a: filter r: reconcile project d: dequeue PR q: quit";
4
+ const { stdout } = useStdout();
5
+ const compact = (stdout?.columns ?? 80) < 80;
6
+ const text = compact
7
+ ? (view === "overview"
8
+ ? "j/k select Enter open r refresh q quit"
9
+ : "j/k select Esc overview a filter r refresh d dequeue q quit")
10
+ : view === "overview"
11
+ ? "j/k: select project Enter: open project r: reconcile selected project q: quit"
12
+ : "j/k: select PR Esc: overview a: filter r: reconcile project d: dequeue PR q: quit";
7
13
  return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: text }) }));
8
14
  }
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useStdout } from "ink";
3
3
  import { ciStatusIcon, summarizeQueueBlock, truncate } from "./format.js";
4
- import { getChainEntries, getClusterSummary, getRepoHealth, projectStatsSummary } from "./dashboard-model.js";
4
+ import { getClusterSummary, getRepoHealth, projectStatsSummary } from "./dashboard-model.js";
5
+ import { getChainEntries } from "./dashboard-model.js";
5
6
  function clampWindowStart(selectedIndex, itemCount, maxItems) {
6
7
  if (itemCount <= maxItems) {
7
8
  return 0;
@@ -12,12 +13,19 @@ function clampWindowStart(selectedIndex, itemCount, maxItems) {
12
13
  export function OverviewView({ repos, selectedRepoId, gatewayError }) {
13
14
  const { stdout } = useStdout();
14
15
  const rows = stdout?.rows ?? 24;
16
+ const width = stdout?.columns ?? 80;
17
+ const compact = width < 90;
15
18
  const cluster = getClusterSummary(repos);
16
19
  const selectedIndex = Math.max(0, repos.findIndex((repo) => repo.repoId === selectedRepoId));
17
20
  const maxItems = Math.max(1, Math.floor((rows - 8) / 2));
18
21
  const startIndex = clampWindowStart(selectedIndex, repos.length, maxItems);
19
22
  const visibleRepos = repos.slice(startIndex, startIndex + maxItems);
20
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Queue Overview" }), _jsxs(Text, { dimColor: true, children: [cluster.total, " projects \u00B7 ", cluster.connected, " connected \u00B7 ", cluster.active, " active \u00B7 ", cluster.blocked, " blocked \u00B7 ", cluster.stuck, " stuck \u00B7 ", cluster.attention, " need attention"] }), gatewayError ? (_jsx(Text, { color: "red", children: `Gateway offline: ${truncate(gatewayError, 88)}. Run \`merge-steward service restart\`.` })) : null, repos.length === 0 ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["No repositories are attached yet. Run ", "`merge-steward repo attach <owner/repo>`", " first."] }) })) : (_jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleRepos.map((repo) => {
23
+ const summaryText = compact
24
+ ? `${cluster.total}p ${cluster.connected}c ${cluster.active}a ${cluster.blocked}b ${cluster.stuck}s`
25
+ : `${cluster.total} projects · ${cluster.connected} connected · ${cluster.active} active · ${cluster.blocked} blocked · ${cluster.stuck} stuck · ${cluster.attention} need attention`;
26
+ const idWidth = compact ? Math.max(14, Math.min(24, Math.floor(width * 0.44))) : 18;
27
+ const chainWidth = compact ? Math.max(12, width - 10) : 100;
28
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Queue Overview" }), _jsx(Text, { dimColor: true, children: summaryText }), gatewayError ? (_jsx(Text, { color: "red", children: `Gateway offline: ${truncate(gatewayError, 88)}. Run \`merge-steward service restart\`.` })) : null, repos.length === 0 ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["No repositories are attached yet. Run ", "`merge-steward repo attach <owner/repo>`", " first."] }) })) : (_jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleRepos.map((repo) => {
21
29
  const health = getRepoHealth(repo);
22
30
  const queueBlockSummary = summarizeQueueBlock(repo.snapshot?.queueBlock);
23
31
  const chainEntries = getChainEntries(repo.snapshot);
@@ -25,10 +33,12 @@ export function OverviewView({ repos, selectedRepoId, gatewayError }) {
25
33
  ? chainEntries.length > 0
26
34
  ? chainEntries.map((entry) => {
27
35
  const ci = ciStatusIcon(entry);
28
- return `#${entry.prNumber} ${ci.icon}`;
29
- }).join(" ")
30
- : "queue is empty"
36
+ return compact
37
+ ? `#${entry.prNumber}${ci.icon}`
38
+ : `#${entry.prNumber} ${ci.icon}`;
39
+ }).join(" ")
40
+ : compact ? "empty" : "queue is empty"
31
41
  : "queue data unavailable";
32
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: repo.repoId === selectedRepoId ? "cyan" : "gray", children: repo.repoId === selectedRepoId ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: repo.repoId }), _jsx(Text, { dimColor: true, children: ` ${truncate(repo.repoFullName, 28)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: health.label }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(repo.snapshot)}` })] }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `Queue: ${truncate(chainText, 100)}` }) }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: health.color, children: truncate(queueBlockSummary ?? health.detail, 110) }) })] }, repo.repoId));
42
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: repo.repoId === selectedRepoId ? "cyan" : "gray", children: repo.repoId === selectedRepoId ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: truncate(repo.repoId, idWidth) }), compact ? null : _jsx(Text, { dimColor: true, children: ` ${truncate(repo.repoFullName, 28)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: compact ? health.label.slice(0, 5) : health.label }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(repo.snapshot, compact)}` })] }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `Queue: ${truncate(chainText, chainWidth)}` }) }), compact ? null : (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: health.color, children: truncate(queueBlockSummary ?? health.detail, 110) }) }))] }, repo.repoId));
33
43
  }) }))] }));
34
44
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text, useStdout } from "ink";
3
3
  import { buildDisplayEntries } from "./display-filter.js";
4
4
  import { describeEntry, getChainEntries, getRepoHealth, projectStatsSummary, runtimeSummary } from "./dashboard-model.js";
@@ -18,9 +18,31 @@ function selectedEntryIndex(snapshot, filter, entryId) {
18
18
  const index = entries.findIndex((entry) => entry.id === entryId);
19
19
  return index >= 0 ? index : 0;
20
20
  }
21
+ function compactEntryStatus(entryStatus) {
22
+ switch (entryStatus) {
23
+ case "queued":
24
+ return "waiting";
25
+ case "preparing_head":
26
+ return "prep";
27
+ case "validating":
28
+ return "testing";
29
+ case "merging":
30
+ return "merging";
31
+ case "merged":
32
+ return "merged";
33
+ case "evicted":
34
+ return "repair";
35
+ case "dequeued":
36
+ return "removed";
37
+ default:
38
+ return entryStatus;
39
+ }
40
+ }
21
41
  export function ProjectDetailView({ repo, selectedEntryId, detail, filter, }) {
22
42
  const { stdout } = useStdout();
23
43
  const rows = stdout?.rows ?? 24;
44
+ const width = stdout?.columns ?? 80;
45
+ const compact = width < 95;
24
46
  if (!repo) {
25
47
  return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No project selected." }) }));
26
48
  }
@@ -42,20 +64,24 @@ export function ProjectDetailView({ repo, selectedEntryId, detail, filter, }) {
42
64
  const selectedEntry = entries.find((entry) => entry.id === selectedEntryId)
43
65
  ?? snapshot.entries.find((entry) => entry.id === selectedEntryId)
44
66
  ?? null;
67
+ const eventRows = compact ? 4 : 6;
45
68
  const visibleEvents = detail
46
- ? detail.events.slice(-6)
47
- : snapshot.recentEvents.slice(-6);
69
+ ? detail.events.slice(-eventRows)
70
+ : snapshot.recentEvents.slice(-eventRows);
48
71
  const chainEntries = getChainEntries(snapshot);
49
72
  const chainText = chainEntries.length > 0
50
73
  ? chainEntries.map((entry) => `#${entry.prNumber}`).join(" -> ")
51
74
  : "empty";
52
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: repo.repoId }), _jsx(Text, { dimColor: true, children: ` ${repo.repoFullName}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: health.label })] }), _jsx(Text, { dimColor: true, children: projectStatsSummary(snapshot) }), _jsx(Text, { dimColor: true, children: runtimeSummary(snapshot) }), _jsx(Text, { dimColor: true, children: `Queue: main -> ${truncate(chainText, 96)}` }), queueBlockSummary ? _jsx(Text, { color: "yellow", children: queueBlockSummary }) : null, _jsxs(Text, { dimColor: true, children: ["GitHub required checks: ", snapshot.githubPolicy.requiredChecks.length > 0 ? snapshot.githubPolicy.requiredChecks.join(", ") : "(none)"] }), snapshot.githubPolicy.fetchedAt ? (_jsxs(Text, { dimColor: true, children: ["Policy fetched ", relativeTime(snapshot.githubPolicy.fetchedAt), " ago", snapshot.githubPolicy.lastRefreshReason
53
- ? ` via ${snapshot.githubPolicy.lastRefreshReason}${snapshot.githubPolicy.lastRefreshChanged === null ? "" : snapshot.githubPolicy.lastRefreshChanged ? " (changed)" : " (unchanged)"}`
54
- : ""] })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pull Requests" }), visibleEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (visibleEntries.map((entry) => {
75
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: repo.repoId }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: compact ? health.label.slice(0, 5) : health.label })] }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(snapshot, compact)}` }), compact ? null : _jsx(Text, { dimColor: true, children: runtimeSummary(snapshot) }), _jsx(Text, { dimColor: true, children: `Queue: main -> ${truncate(chainText, compact ? width - 18 : 96)}` }), compact ? null : queueBlockSummary ? _jsx(Text, { color: "yellow", children: queueBlockSummary }) : null, compact ? null : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["GitHub required checks: ", snapshot.githubPolicy.requiredChecks.length > 0 ? snapshot.githubPolicy.requiredChecks.join(", ") : "(none)"] }), snapshot.githubPolicy.fetchedAt ? (_jsxs(Text, { dimColor: true, children: ["Policy fetched ", relativeTime(snapshot.githubPolicy.fetchedAt), " ago", snapshot.githubPolicy.lastRefreshReason
76
+ ? ` via ${snapshot.githubPolicy.lastRefreshReason}${snapshot.githubPolicy.lastRefreshChanged === null ? "" : snapshot.githubPolicy.lastRefreshChanged ? " (changed)" : " (unchanged)"}`
77
+ : ""] })) : null] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pull Requests" }), visibleEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (visibleEntries.map((entry) => {
55
78
  const ci = ciStatusIcon(entry);
56
79
  const isHead = snapshot.summary.headEntryId === entry.id;
57
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: entry.id === selectedEntryId ? "cyan" : "gray", children: entry.id === selectedEntryId ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: `#${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { children: ` ` }), _jsx(Text, { color: ci.color, children: ci.icon }), _jsx(Text, { children: ` ` }), _jsx(Text, { ...(entry.status === "evicted" ? { color: "red" } : {}), children: humanStatus(entry.status, entry) }), _jsx(Text, { dimColor: true, children: ` updated ${relativeTime(entry.updatedAt)} ago` })] }), (entry.id === selectedEntryId || entry.status === "evicted" || (isHead && queueBlockSummary)) && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: truncate(describeEntry(entry, { isHead, queueBlockSummary }), 110) }) }))] }, entry.id));
58
- }))] }), selectedEntry && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: `Selected PR #${selectedEntry.prNumber}` }), _jsxs(Text, { dimColor: true, children: [selectedEntry.branch, " \u00B7 head ", shortSha(selectedEntry.headSha), " \u00B7 base ", shortSha(selectedEntry.baseSha)] }), _jsx(Text, { dimColor: true, children: `Test branch: ${specChainLabel(selectedEntry, snapshot.entries)}` }), detail?.incidents.length
80
+ const isSelected = entry.id === selectedEntryId;
81
+ const statusColor = entry.status === "evicted" ? "red" : ci.color;
82
+ const statusText = compact ? compactEntryStatus(entry.status) : humanStatus(entry.status, entry);
83
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? "cyan" : "gray", children: isSelected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: `#${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { children: ` ` }), _jsx(Text, { color: ci.color, children: ci.icon }), _jsx(Text, { color: statusColor, children: ` ${statusText}` }), compact ? null : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ` updated ${relativeTime(entry.updatedAt)} ago` }), isHead ? _jsx(Text, { dimColor: true, children: ` · ${humanStatus(entry.status, entry)} head` }) : null] }))] }), (isSelected || entry.status === "evicted" || (isHead && queueBlockSummary)) && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: truncate(describeEntry(entry, { isHead, queueBlockSummary }), compact ? width - 4 : 110) }) }))] }, entry.id));
84
+ }))] }), selectedEntry ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: `Selected PR #${selectedEntry.prNumber}` }), compact ? (_jsx(Text, { dimColor: true, children: `head ${shortSha(selectedEntry.headSha)} · base ${shortSha(selectedEntry.baseSha)}` })) : (_jsxs(Text, { dimColor: true, children: [selectedEntry.branch, " \u00B7 head ", shortSha(selectedEntry.headSha), " \u00B7 base ", shortSha(selectedEntry.baseSha)] })), compact ? null : _jsx(Text, { dimColor: true, children: `Test branch: ${specChainLabel(selectedEntry, snapshot.entries)}` }), detail?.incidents.length
59
85
  ? detail.incidents.slice(-2).map((incident) => (_jsxs(Text, { color: "red", children: [relativeTime(incident.at), " ago \u00B7 ", incident.failureClass, " \u00B7 ", incident.outcome] }, incident.id)))
60
- : null] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: detail ? "Selected PR Activity" : "Recent Project Activity" }), visibleEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (visibleEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: truncate(formatEventNarrative(event), 106) })] }, "id" in event && event.id ? String(event.id) : `${event.entryId}-${event.at}`))))] })] }));
86
+ : null] })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: detail ? "Selected PR Activity" : "Recent Project Activity" }), visibleEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (visibleEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: truncate(formatEventNarrative(event), compact ? Math.max(12, width - 8) : 106) })] }, "id" in event && event.id ? String(event.id) : `${event.entryId}-${event.at}`))))] })] }));
61
87
  }
@@ -6,27 +6,32 @@ import { FreshnessBadge } from "./FreshnessBadge.js";
6
6
  export function StatusBar({ repos, currentRepo, view, filter, lastSnapshotReceivedAt, expectedFreshMs, gatewayError, }) {
7
7
  const { stdout } = useStdout();
8
8
  const width = stdout?.columns ?? 80;
9
+ const compact = width < 88;
9
10
  const cluster = getClusterSummary(repos);
10
11
  const connected = cluster.connected > 0;
11
12
  const repoHealth = currentRepo ? getRepoHealth(currentRepo) : null;
12
13
  const leftParts = view === "project" && currentRepo
13
- ? [
14
- "merge-steward dashboard",
15
- currentRepo.repoId,
16
- repoHealth?.label ?? null,
17
- currentRepo.snapshot ? `${currentRepo.snapshot.summary.active} active` : null,
18
- currentRepo.snapshot?.summary.headPrNumber ? `head #${currentRepo.snapshot.summary.headPrNumber}` : "no head PR",
19
- `filter:${filter}`,
20
- ].filter(Boolean).join(" | ")
21
- : [
22
- "merge-steward dashboard",
23
- `${cluster.total} projects`,
24
- `${cluster.connected} connected`,
25
- `${cluster.active} active`,
26
- `${cluster.blocked} blocked`,
27
- `${cluster.stuck} stuck`,
28
- `${cluster.attention} need attention`,
29
- ].join(" | ");
30
- const availableLeft = Math.max(1, width - 28);
14
+ ? compact && currentRepo.repoId
15
+ ? `ms ${currentRepo.repoId} ${repoHealth?.label ?? ""} a:${currentRepo.snapshot?.summary.active ?? 0} h:${currentRepo.snapshot?.summary.headPrNumber ?? "?"}`
16
+ : [
17
+ "merge-steward dashboard",
18
+ currentRepo.repoId,
19
+ repoHealth?.label ?? null,
20
+ currentRepo.snapshot ? `${currentRepo.snapshot.summary.active} active` : null,
21
+ currentRepo.snapshot?.summary.headPrNumber ? `head #${currentRepo.snapshot.summary.headPrNumber}` : "no head PR",
22
+ `filter:${filter}`,
23
+ ].filter(Boolean).join(" | ")
24
+ : compact
25
+ ? `${cluster.total}p ${cluster.connected}c ${cluster.active}a ${cluster.blocked}b`
26
+ : [
27
+ "merge-steward dashboard",
28
+ `${cluster.total} projects`,
29
+ `${cluster.connected} connected`,
30
+ `${cluster.active} active`,
31
+ `${cluster.blocked} blocked`,
32
+ `${cluster.stuck} stuck`,
33
+ `${cluster.attention} need attention`,
34
+ ].join(" | ");
35
+ const availableLeft = compact ? Math.max(1, width - 14) : Math.max(1, width - 28);
31
36
  return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: truncate(leftParts, availableLeft) }), _jsx(FreshnessBadge, { connected: connected, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: expectedFreshMs, gatewayError: gatewayError })] }));
32
37
  }
@@ -34,7 +34,7 @@ export declare function getActiveEntries(snapshot: QueueWatchSnapshot | null): Q
34
34
  export declare function getDefaultEntryId(snapshot: QueueWatchSnapshot | null, filter: "active" | "all", preferredPrNumber?: number | null): string | null;
35
35
  export declare function getRepoHealth(repo: DashboardRepoState, now?: number): RepoHealthSummary;
36
36
  export declare function getClusterSummary(repos: DashboardRepoState[], now?: number): ClusterSummary;
37
- export declare function projectStatsSummary(snapshot: QueueWatchSnapshot | null): string;
37
+ export declare function projectStatsSummary(snapshot: QueueWatchSnapshot | null, compact?: boolean): string;
38
38
  export declare function runtimeSummary(snapshot: QueueWatchSnapshot | null): string;
39
39
  export declare function describeEntry(entry: QueueEntry, options: {
40
40
  isHead: boolean;
@@ -116,7 +116,7 @@ export function getDefaultEntryId(snapshot, filter, preferredPrNumber) {
116
116
  return match.id;
117
117
  }
118
118
  }
119
- return snapshot.summary.headEntryId ?? visibleEntries[0]?.id ?? snapshot.entries[0]?.id ?? null;
119
+ return snapshot.summary.headEntryId ?? visibleEntries[0]?.id ?? null;
120
120
  }
121
121
  export function getRepoHealth(repo, now = Date.now()) {
122
122
  const snapshot = repo.snapshot;
@@ -232,7 +232,7 @@ export function getClusterSummary(repos, now = Date.now()) {
232
232
  return summary;
233
233
  }, initial);
234
234
  }
235
- export function projectStatsSummary(snapshot) {
235
+ export function projectStatsSummary(snapshot, compact = false) {
236
236
  if (!snapshot) {
237
237
  return "No queue data yet.";
238
238
  }
@@ -245,17 +245,24 @@ export function projectStatsSummary(snapshot) {
245
245
  const validating = latestEntries.filter((entry) => entry.status === "validating" || entry.status === "preparing_head").length;
246
246
  const merging = latestEntries.filter((entry) => entry.status === "merging").length;
247
247
  const evicted = latestEntries.filter((entry) => entry.status === "evicted").length;
248
- const parts = [
249
- `${activeEntries.length} active`,
250
- `${queued} waiting`,
251
- `${validating} testing`,
252
- `${merging} merging`,
253
- ];
248
+ const parts = compact
249
+ ? [
250
+ `${activeEntries.length}a`,
251
+ `${queued}w`,
252
+ `${validating}t`,
253
+ `${merging}m`,
254
+ ]
255
+ : [
256
+ `${activeEntries.length} active`,
257
+ `${queued} waiting`,
258
+ `${validating} testing`,
259
+ `${merging} merging`,
260
+ ];
254
261
  if (evicted > 0) {
255
- parts.push(`${evicted} need repair`);
262
+ parts.push(compact ? `${evicted}x` : `${evicted} need repair`);
256
263
  }
257
264
  if (avgWaitMs > 0) {
258
- parts.push(`avg wait ${formatDuration(avgWaitMs)}`);
265
+ parts.push(compact ? `w ${formatDuration(avgWaitMs)}` : `avg wait ${formatDuration(avgWaitMs)}`);
259
266
  }
260
267
  return parts.join(" · ");
261
268
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {