merge-steward 0.22.2 → 0.22.4

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/README.md CHANGED
@@ -34,7 +34,7 @@ The skill wraps `merge-steward pr status --wait` and `review-quill pr status --w
34
34
  Prerequisites: Node.js 24+, `gh` CLI in `PATH`, `git`.
35
35
 
36
36
  ```bash
37
- npm install -g merge-steward
37
+ pnpm add -g merge-steward
38
38
  merge-steward init https://queue.example.com
39
39
  merge-steward attach owner/repo --base-branch main
40
40
  merge-steward doctor --repo repo
package/dist/watch/App.js CHANGED
@@ -7,6 +7,7 @@ import { HelpBar } from "./HelpBar.js";
7
7
  import { OverviewView } from "./OverviewView.js";
8
8
  import { ProjectDetailView } from "./ProjectDetailView.js";
9
9
  import { StatusBar } from "./StatusBar.js";
10
+ import { computeDashboardLayout } from "./compact-layout.js";
10
11
  const REFRESH_INTERVAL_MS = 1_500;
11
12
  export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber }) {
12
13
  const { exit } = useApp();
@@ -152,9 +153,7 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
152
153
  setFlashMessage(error instanceof Error ? error.message : String(error));
153
154
  }
154
155
  }
155
- const rows = Math.max(8, stdout?.rows ?? 24);
156
- const chromeRows = 1 /* status */ + 1 /* body marginTop */ + (flashMessage ? 2 : 0) + 2 /* help bar + its marginTop */;
157
- const bodyRows = Math.max(2, rows - chromeRows);
156
+ const layout = computeDashboardLayout(stdout?.rows ?? 24, Boolean(flashMessage));
158
157
  useInput((input, key) => {
159
158
  if (input === "q") {
160
159
  exit();
@@ -197,11 +196,11 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
197
196
  return;
198
197
  }
199
198
  if (key.pageDown || input === " ") {
200
- setDetailScrollOffset((offset) => offset + Math.max(1, bodyRows - 2));
199
+ setDetailScrollOffset((offset) => offset + Math.max(1, layout.bodyRows - 2));
201
200
  return;
202
201
  }
203
202
  if (key.pageUp) {
204
- setDetailScrollOffset((offset) => Math.max(0, offset - Math.max(1, bodyRows - 2)));
203
+ setDetailScrollOffset((offset) => Math.max(0, offset - Math.max(1, layout.bodyRows - 2)));
205
204
  return;
206
205
  }
207
206
  if (input === "g") {
@@ -217,5 +216,5 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
217
216
  setDetailScrollOffset(0);
218
217
  }
219
218
  });
220
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { connected: model.repos.some((repo) => !repo.offlineMessage), lastSnapshotReceivedAt: lastSnapshotReceivedAt, gatewayError: gatewayError }), view === "detail" ? (_jsx(ProjectDetailView, { model: model, selectedRepoId: selectedRepoId, bodyRows: bodyRows, scrollOffset: detailScrollOffset })) : (_jsx(OverviewView, { model: model, selectedRepoId: selectedRepoId, showCursor: true, bodyRows: bodyRows })), flashMessage ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })) : null, _jsx(HelpBar, { view: view })] }));
219
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { connected: model.repos.some((repo) => !repo.offlineMessage), lastSnapshotReceivedAt: lastSnapshotReceivedAt, gatewayError: gatewayError }), view === "detail" ? (_jsx(ProjectDetailView, { model: model, selectedRepoId: selectedRepoId, bodyRows: layout.bodyRows, topMarginRows: layout.bodyTopMarginRows, scrollOffset: detailScrollOffset })) : (_jsx(OverviewView, { model: model, selectedRepoId: selectedRepoId, showCursor: true, bodyRows: layout.bodyRows, topMarginRows: layout.bodyTopMarginRows })), flashMessage && layout.showFlashMessage ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })) : null, layout.showHelp ? _jsx(HelpBar, { view: view }) : null] }));
221
220
  }
@@ -4,6 +4,7 @@ interface ListViewProps {
4
4
  selectedRepoId: string | null;
5
5
  showCursor: boolean;
6
6
  bodyRows: number;
7
+ topMarginRows?: number | undefined;
7
8
  }
8
9
  export declare function RepoRow({ repo, selected, showCursor, width }: {
9
10
  repo: DashboardRepo;
@@ -11,9 +12,5 @@ export declare function RepoRow({ repo, selected, showCursor, width }: {
11
12
  showCursor: boolean;
12
13
  width: number;
13
14
  }): React.JSX.Element;
14
- export declare function pickVisibleWindow(total: number, selectedIndex: number, availableRows: number): {
15
- start: number;
16
- end: number;
17
- };
18
- export declare function OverviewView({ model, selectedRepoId, showCursor, bodyRows }: ListViewProps): React.JSX.Element;
15
+ export declare function OverviewView({ model, selectedRepoId, showCursor, bodyRows, topMarginRows }: ListViewProps): React.JSX.Element;
19
16
  export {};
@@ -1,81 +1,49 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useStdout } from "ink";
3
3
  import { formatRepoTokenText } from "./format.js";
4
+ import { formatRepoTokensText, pickVisibleParts, } from "./compact-layout.js";
4
5
  function RepoTokens({ tokens, width }) {
5
- if (tokens.length === 0 || width < 6)
6
+ const text = formatRepoTokensText(tokens, width);
7
+ if (!text)
6
8
  return null;
7
- const parts = [];
8
- let used = 0;
9
- for (const token of tokens) {
10
- const text = formatRepoTokenText(token);
11
- const separatorWidth = parts.length === 0 ? 0 : 2;
12
- if (used + separatorWidth + text.length > width)
13
- break;
14
- used += separatorWidth + text.length;
15
- parts.push({ token, text });
16
- }
17
- return (_jsx(Text, { children: parts.map((part, index) => (_jsx(Text, { color: part.token.color, children: index === 0 ? part.text : ` ${part.text}` }, `${part.token.prNumber}-${index}`))) }));
9
+ let offset = 0;
10
+ const parts = text.split(" ").map((part) => {
11
+ const token = tokens.find((candidate) => formatRepoTokenText(candidate) === part);
12
+ const item = { text: part, token, offset };
13
+ offset += part.length + 2;
14
+ return item;
15
+ });
16
+ return (_jsx(Text, { children: parts.map((part, index) => (_jsx(Text, { ...(part.token ? { color: part.token.color } : {}), children: index === 0 ? part.text : ` ${part.text}` }, `${part.text}-${part.offset}`))) }));
18
17
  }
19
18
  export function RepoRow({ repo, selected, showCursor, width, }) {
20
- const cursorChar = showCursor && selected ? ">" : " ";
21
19
  const repoLabelWidth = Math.min(28, Math.max(12, Math.floor(width * 0.35)));
22
20
  const tokenWidth = Math.max(6, width - repoLabelWidth - 3);
23
21
  const repoLabel = repo.repoFullName.length > repoLabelWidth
24
22
  ? repo.repoFullName.slice(0, repoLabelWidth)
25
23
  : repo.repoFullName.padEnd(repoLabelWidth, " ");
26
- return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: cursorChar }), _jsx(Text, { bold: selected, children: ` ${repoLabel} ` }), repo.offlineMessage ? (_jsx(Text, { color: "red", children: repo.offlineMessage })) : (_jsx(RepoTokens, { tokens: repo.tokens, width: tokenWidth }))] }));
27
- }
28
- export function pickVisibleWindow(total, selectedIndex, availableRows) {
29
- if (total === 0)
30
- return { start: 0, end: 0 };
31
- if (total <= availableRows)
32
- return { start: 0, end: total };
33
- const clamped = Math.max(0, Math.min(selectedIndex, total - 1));
34
- let start = clamped;
35
- let end = clamped + 1;
36
- while (end - start < availableRows) {
37
- if (start > 0 && (end === total || clamped - start <= end - 1 - clamped)) {
38
- start -= 1;
39
- }
40
- else if (end < total) {
41
- end += 1;
42
- }
43
- else {
44
- break;
45
- }
46
- }
47
- return { start, end };
24
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: showCursor && selected ? ">" : " " }), _jsx(Text, { bold: selected, children: ` ${repoLabel} ` }), repo.offlineMessage ? (_jsx(Text, { color: "red", children: repo.offlineMessage })) : (_jsx(RepoTokens, { tokens: repo.tokens, width: tokenWidth }))] }));
48
25
  }
49
- export function OverviewView({ model, selectedRepoId, showCursor, bodyRows }) {
26
+ export function OverviewView({ model, selectedRepoId, showCursor, bodyRows, topMarginRows = 1, }) {
50
27
  const { stdout } = useStdout();
51
28
  const width = Math.max(40, stdout?.columns ?? 80);
52
29
  const total = model.repos.length;
53
30
  const selectedIndex = Math.max(0, model.repos.findIndex((repo) => repo.repoId === selectedRepoId));
54
- let { start, end } = pickVisibleWindow(total, selectedIndex, Math.max(1, bodyRows));
55
- let needTop = start > 0;
56
- let needBottom = end < total;
57
- if (needTop || needBottom) {
58
- const reserve = (needTop ? 1 : 0) + (needBottom ? 1 : 0);
59
- const windowRows = Math.max(1, bodyRows - reserve);
60
- ({ start, end } = pickVisibleWindow(total, selectedIndex, windowRows));
61
- needTop = start > 0;
62
- needBottom = end < total;
63
- }
31
+ const { start, end, showAbove, showBelow } = pickVisibleParts(total, selectedIndex, bodyRows);
64
32
  const visible = model.repos.slice(start, end);
65
33
  const above = start;
66
34
  const below = total - end;
67
35
  if (total === 0 || bodyRows <= 0) {
68
- return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
36
+ return _jsx(Box, { marginTop: topMarginRows, children: _jsx(Text, { dimColor: true, children: " " }) });
69
37
  }
70
38
  const children = [];
71
- if (needTop) {
39
+ if (showAbove) {
72
40
  children.push(_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `\u2191${above} more above` }) }, "above"));
73
41
  }
74
42
  for (const repo of visible) {
75
43
  children.push(_jsx(RepoRow, { repo: repo, selected: repo.repoId === selectedRepoId, showCursor: showCursor, width: width - 2 }, repo.repoId));
76
44
  }
77
- if (needBottom) {
45
+ if (showBelow) {
78
46
  children.push(_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `\u2193${below} more below` }) }, "below"));
79
47
  }
80
- return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: children.slice(0, Math.max(1, bodyRows)) }));
48
+ return (_jsx(Box, { flexDirection: "column", marginTop: topMarginRows, children: children }));
81
49
  }
@@ -3,6 +3,7 @@ interface ProjectDetailViewProps {
3
3
  model: DashboardModel;
4
4
  selectedRepoId: string | null;
5
5
  bodyRows: number;
6
+ topMarginRows?: number | undefined;
6
7
  scrollOffset: number;
7
8
  }
8
9
  type ContentLine = {
@@ -16,5 +17,5 @@ type ContentLine = {
16
17
  };
17
18
  export declare function buildContentLines(repo: DashboardRepo, width: number): ContentLine[];
18
19
  export declare function clampScrollOffset(requested: number, totalLines: number, scrollArea: number): number;
19
- export declare function ProjectDetailView({ model, selectedRepoId, bodyRows, scrollOffset }: ProjectDetailViewProps): React.JSX.Element;
20
+ export declare function ProjectDetailView({ model, selectedRepoId, bodyRows, topMarginRows, scrollOffset }: ProjectDetailViewProps): React.JSX.Element;
20
21
  export {};
@@ -58,14 +58,14 @@ export function clampScrollOffset(requested, totalLines, scrollArea) {
58
58
  const maxOffset = Math.max(0, totalLines - Math.max(1, scrollArea - 1));
59
59
  return Math.max(0, Math.min(requested, maxOffset));
60
60
  }
61
- export function ProjectDetailView({ model, selectedRepoId, bodyRows, scrollOffset }) {
61
+ export function ProjectDetailView({ model, selectedRepoId, bodyRows, topMarginRows = 1, scrollOffset, }) {
62
62
  const { stdout } = useStdout();
63
63
  const width = Math.max(40, stdout?.columns ?? 80);
64
64
  const repo = selectedRepoId
65
65
  ? model.repos.find((candidate) => candidate.repoId === selectedRepoId) ?? model.repos[0] ?? null
66
66
  : model.repos[0] ?? null;
67
67
  if (!repo) {
68
- return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
68
+ return _jsx(Box, { marginTop: topMarginRows, children: _jsx(Text, { dimColor: true, children: " " }) });
69
69
  }
70
70
  const repoHeaderRows = 1;
71
71
  const separatorRows = bodyRows >= 3 ? 1 : 0;
@@ -94,5 +94,5 @@ export function ProjectDetailView({ model, selectedRepoId, bodyRows, scrollOffse
94
94
  const hidden = contentLines.length - tentativeEnd;
95
95
  children.push(_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` \u2193${hidden} more below` }) }, "below"));
96
96
  }
97
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(RepoRow, { repo: repo, selected: false, showCursor: false, width: width - 2 }), separatorRows > 0 ? _jsx(Box, { children: _jsx(Text, { children: " " }) }) : null, children] }));
97
+ return (_jsxs(Box, { flexDirection: "column", marginTop: topMarginRows, children: [_jsx(RepoRow, { repo: repo, selected: false, showCursor: false, width: width - 2 }), separatorRows > 0 ? _jsx(Box, { children: _jsx(Text, { children: " " }) }) : null, children] }));
98
98
  }
@@ -0,0 +1,32 @@
1
+ import type { DashboardModel, DashboardRepo, DashboardToken } from "./dashboard-model.ts";
2
+ export declare function computeDashboardLayout(totalRows: number, hasFlashMessage: boolean): {
3
+ bodyRows: number;
4
+ bodyTopMarginRows: number;
5
+ showFlashMessage: boolean;
6
+ showHelp: boolean;
7
+ };
8
+ export declare function pickVisibleWindow(total: number, selectedIndex: number, availableRows: number): {
9
+ start: number;
10
+ end: number;
11
+ };
12
+ export declare function pickVisibleParts(total: number, selectedIndex: number, rowBudget: number): {
13
+ start: number;
14
+ end: number;
15
+ showAbove: boolean;
16
+ showBelow: boolean;
17
+ };
18
+ export declare function formatRepoTokensText(tokens: DashboardToken[], width: number): string | null;
19
+ export declare function formatRepoRowText({ repo, selected, showCursor, width }: {
20
+ repo: DashboardRepo;
21
+ selected: boolean;
22
+ showCursor: boolean;
23
+ width: number;
24
+ }): string;
25
+ export declare function renderOverviewLines({ model, selectedRepoId, showCursor, bodyRows, topMarginRows, width }: {
26
+ model: DashboardModel;
27
+ selectedRepoId: string | null;
28
+ showCursor: boolean;
29
+ bodyRows: number;
30
+ topMarginRows?: number | undefined;
31
+ width: number;
32
+ }): string[];
@@ -0,0 +1,111 @@
1
+ import { formatRepoTokenText } from "./format.js";
2
+ export function computeDashboardLayout(totalRows, hasFlashMessage) {
3
+ const rows = Math.max(1, totalRows);
4
+ const bodyTopMarginRows = rows >= 5 ? 1 : 0;
5
+ const showFlashMessage = hasFlashMessage && rows >= 6;
6
+ const showHelp = rows >= 8;
7
+ const chromeRows = 1
8
+ + bodyTopMarginRows
9
+ + (showFlashMessage ? 2 : 0)
10
+ + (showHelp ? 2 : 0);
11
+ return {
12
+ bodyRows: Math.max(1, rows - chromeRows),
13
+ bodyTopMarginRows,
14
+ showFlashMessage,
15
+ showHelp,
16
+ };
17
+ }
18
+ export function pickVisibleWindow(total, selectedIndex, availableRows) {
19
+ if (total === 0)
20
+ return { start: 0, end: 0 };
21
+ if (total <= availableRows)
22
+ return { start: 0, end: total };
23
+ const clamped = Math.max(0, Math.min(selectedIndex, total - 1));
24
+ let start = clamped;
25
+ let end = clamped + 1;
26
+ while (end - start < availableRows) {
27
+ if (start > 0 && (end === total || clamped - start <= end - 1 - clamped)) {
28
+ start -= 1;
29
+ }
30
+ else if (end < total) {
31
+ end += 1;
32
+ }
33
+ else {
34
+ break;
35
+ }
36
+ }
37
+ return { start, end };
38
+ }
39
+ export function pickVisibleParts(total, selectedIndex, rowBudget) {
40
+ if (total === 0 || rowBudget <= 0) {
41
+ return { start: 0, end: 0, showAbove: false, showBelow: false };
42
+ }
43
+ let { start, end } = pickVisibleWindow(total, selectedIndex, Math.max(1, rowBudget));
44
+ let hiddenAbove = start > 0;
45
+ let hiddenBelow = end < total;
46
+ if (rowBudget >= 3 && (hiddenAbove || hiddenBelow)) {
47
+ const indicatorRows = (hiddenAbove ? 1 : 0) + (hiddenBelow ? 1 : 0);
48
+ ({ start, end } = pickVisibleWindow(total, selectedIndex, Math.max(1, rowBudget - indicatorRows)));
49
+ hiddenAbove = start > 0;
50
+ hiddenBelow = end < total;
51
+ }
52
+ const usedRows = end - start;
53
+ let remaining = Math.max(0, rowBudget - usedRows);
54
+ const showAbove = hiddenAbove && remaining > 0;
55
+ if (showAbove)
56
+ remaining -= 1;
57
+ const showBelow = hiddenBelow && remaining > 0;
58
+ return { start, end, showAbove, showBelow };
59
+ }
60
+ export function formatRepoTokensText(tokens, width) {
61
+ if (tokens.length === 0 || width < 6)
62
+ return null;
63
+ const parts = [];
64
+ let used = 0;
65
+ for (const token of tokens) {
66
+ const text = formatRepoTokenText(token);
67
+ const separatorWidth = parts.length === 0 ? 0 : 2;
68
+ if (used + separatorWidth + text.length > width)
69
+ break;
70
+ used += separatorWidth + text.length;
71
+ parts.push(text);
72
+ }
73
+ return parts.join(" ");
74
+ }
75
+ export function formatRepoRowText({ repo, selected, showCursor, width, }) {
76
+ const cursorChar = showCursor && selected ? ">" : " ";
77
+ const repoLabelWidth = Math.min(28, Math.max(12, Math.floor(width * 0.35)));
78
+ const tokenWidth = Math.max(6, width - repoLabelWidth - 3);
79
+ const repoLabel = repo.repoFullName.length > repoLabelWidth
80
+ ? repo.repoFullName.slice(0, repoLabelWidth)
81
+ : repo.repoFullName.padEnd(repoLabelWidth, " ");
82
+ return `${cursorChar} ${repoLabel} ${repo.offlineMessage ?? formatRepoTokensText(repo.tokens, tokenWidth) ?? ""}`;
83
+ }
84
+ export function renderOverviewLines({ model, selectedRepoId, showCursor, bodyRows, topMarginRows = 1, width, }) {
85
+ const total = model.repos.length;
86
+ const selectedIndex = Math.max(0, model.repos.findIndex((repo) => repo.repoId === selectedRepoId));
87
+ const lines = Array.from({ length: topMarginRows }, () => "");
88
+ if (total === 0 || bodyRows <= 0) {
89
+ lines.push(" ");
90
+ return lines;
91
+ }
92
+ const { start, end, showAbove, showBelow } = pickVisibleParts(total, selectedIndex, bodyRows);
93
+ const visible = model.repos.slice(start, end);
94
+ const above = start;
95
+ const below = total - end;
96
+ if (showAbove) {
97
+ lines.push(` \u2191${above} more above`);
98
+ }
99
+ for (const repo of visible) {
100
+ lines.push(formatRepoRowText({
101
+ repo,
102
+ selected: repo.repoId === selectedRepoId,
103
+ showCursor,
104
+ width: width - 2,
105
+ }));
106
+ }
107
+ if (showBelow) {
108
+ lines.push(` \u2193${below} more below`);
109
+ }
110
+ return lines;
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.22.2",
3
+ "version": "0.22.4",
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": {
@@ -34,16 +34,6 @@
34
34
  "bin": {
35
35
  "merge-steward": "dist/index.js"
36
36
  },
37
- "scripts": {
38
- "dev": "node --watch --experimental-transform-types src/index.ts",
39
- "prepack": "pnpm build",
40
- "build": "rm -rf dist && tsgo -p tsconfig.json && chmod +x dist/index.js",
41
- "start": "node dist/index.js serve",
42
- "typecheck": "tsgo -p tsconfig.json --noEmit",
43
- "test": "node --experimental-transform-types --test 'test/**/*.test.ts'",
44
- "test:scenarios": "node --experimental-transform-types --test 'test/scenarios/*.test.ts'",
45
- "test:property": "node --experimental-transform-types --test 'test/property/*.test.ts'"
46
- },
47
37
  "dependencies": {
48
38
  "fastify": "^5.8.5",
49
39
  "fastify-raw-body": "^5.0.0",
@@ -59,5 +49,14 @@
59
49
  "fast-check": "^4.7.0",
60
50
  "isomorphic-git": "^1.37.6",
61
51
  "memfs": "^4.57.2"
52
+ },
53
+ "scripts": {
54
+ "dev": "node --watch --experimental-transform-types src/index.ts",
55
+ "build": "rm -rf dist && tsgo -p tsconfig.json && chmod +x dist/index.js",
56
+ "start": "node dist/index.js serve",
57
+ "typecheck": "tsgo -p tsconfig.json --noEmit",
58
+ "test": "node --experimental-transform-types --test 'test/**/*.test.ts'",
59
+ "test:scenarios": "node --experimental-transform-types --test 'test/scenarios/*.test.ts'",
60
+ "test:property": "node --experimental-transform-types --test 'test/property/*.test.ts'"
62
61
  }
63
- }
62
+ }