merge-steward 0.17.1 → 0.19.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/db/schema.js CHANGED
@@ -75,6 +75,7 @@ export function ensureSchema(connection) {
75
75
  ensureColumn(connection, "queue_entries", "post_merge_summary", "TEXT");
76
76
  ensureColumn(connection, "queue_entries", "post_merge_checked_at", "TEXT");
77
77
  ensureColumn(connection, "queue_entries", "wait_detail", "TEXT");
78
+ ensureColumn(connection, "queue_entries", "pr_title", "TEXT");
78
79
  connection.exec(`
79
80
  UPDATE queue_entries
80
81
  SET post_merge_status = 'pending'
@@ -29,6 +29,7 @@ function mapEntry(row) {
29
29
  postMergeSha: row.post_merge_sha === null || row.post_merge_sha === undefined ? null : String(row.post_merge_sha),
30
30
  postMergeSummary: row.post_merge_summary === null || row.post_merge_summary === undefined ? null : String(row.post_merge_summary),
31
31
  postMergeCheckedAt: row.post_merge_checked_at === null || row.post_merge_checked_at === undefined ? null : String(row.post_merge_checked_at),
32
+ prTitle: row.pr_title === null || row.pr_title === undefined ? null : String(row.pr_title),
32
33
  enqueuedAt: String(row.enqueued_at),
33
34
  updatedAt: String(row.updated_at),
34
35
  };
@@ -125,8 +126,9 @@ export class SqliteStore {
125
126
  priority, generation, ci_run_id, ci_retries, retry_attempts,
126
127
  max_retries, last_failed_base_sha, issue_key, wait_detail,
127
128
  post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
129
+ pr_title,
128
130
  enqueued_at, updated_at)
129
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.enqueuedAt, entry.updatedAt);
131
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.prTitle ?? null, entry.enqueuedAt, entry.updatedAt);
130
132
  this.writeEvent(entry.id, null, entry.status);
131
133
  })();
132
134
  }
@@ -24,13 +24,14 @@ export class GitHubPRClient {
24
24
  const result = await exec("gh", [
25
25
  "pr", "view", String(prNumber),
26
26
  "--repo", this.repoFullName,
27
- "--json", "number,headRefName,headRefOid,reviewDecision,state,mergeStateStatus",
27
+ "--json", "number,title,headRefName,headRefOid,reviewDecision,state,mergeStateStatus",
28
28
  ], { githubRepoFullName: this.repoFullName });
29
29
  const data = JSON.parse(result.stdout);
30
30
  return {
31
31
  number: data.number,
32
32
  branch: data.headRefName,
33
33
  headSha: data.headRefOid,
34
+ ...(data.title ? { title: data.title } : {}),
34
35
  mergeable: data.state === "OPEN",
35
36
  mergeStateStatus: data.mergeStateStatus,
36
37
  reviewDecision: data.reviewDecision,
@@ -18,6 +18,7 @@ export declare class MergeStewardQueueCommands {
18
18
  headSha: string;
19
19
  issueKey?: string;
20
20
  priority?: number;
21
+ prTitle?: string;
21
22
  }): QueueEntry | undefined;
22
23
  scanStartupAdmissions(): Promise<void>;
23
24
  scanEligibleOpenPrs(): Promise<{
@@ -63,6 +63,7 @@ export class MergeStewardQueueCommands {
63
63
  postMergeSha: null,
64
64
  postMergeSummary: null,
65
65
  postMergeCheckedAt: null,
66
+ prTitle: params.prTitle ?? null,
66
67
  enqueuedAt: new Date().toISOString(),
67
68
  updatedAt: new Date().toISOString(),
68
69
  };
@@ -161,7 +162,7 @@ export class MergeStewardQueueCommands {
161
162
  return false;
162
163
  }
163
164
  }
164
- this.enqueue({ prNumber, branch, headSha });
165
+ this.enqueue({ prNumber, branch, headSha, ...(status.title ? { prTitle: status.title } : {}) });
165
166
  return true;
166
167
  }
167
168
  catch (error) {
package/dist/types.d.ts CHANGED
@@ -42,6 +42,8 @@ export interface QueueEntry {
42
42
  waitDetail?: string | null;
43
43
  /** Post-merge verification status for the landed commit on main. */
44
44
  postMergeStatus: PostMergeStatus | null;
45
+ /** PR title captured at admission. Falls back to branch name in the UI. */
46
+ prTitle?: string | null;
45
47
  /** Commit SHA used when checking post-merge status. */
46
48
  postMergeSha: string | null;
47
49
  /** Optional one-line summary of post-merge checks. */
@@ -180,6 +182,7 @@ export interface PRStatus {
180
182
  number: number;
181
183
  branch: string;
182
184
  headSha: string;
185
+ title?: string | undefined;
183
186
  mergeable: boolean;
184
187
  mergeStateStatus?: string | undefined;
185
188
  reviewDecision?: string | undefined;
package/dist/watch/App.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from "react";
3
- import { Box, Text, useApp, useInput } from "ink";
3
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
4
4
  import { fetchGatewayHealth, fetchSnapshot, triggerReconcile } from "./api.js";
5
5
  import { buildDashboard, matchRepoRef, repoSelector, stepRepo } from "./dashboard-model.js";
6
6
  import { HelpBar } from "./HelpBar.js";
@@ -10,6 +10,7 @@ import { StatusBar } from "./StatusBar.js";
10
10
  const REFRESH_INTERVAL_MS = 1_500;
11
11
  export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber }) {
12
12
  const { exit } = useApp();
13
+ const { stdout } = useStdout();
13
14
  const [repoStates, setRepoStates] = useState(() => repos.map((repo) => ({
14
15
  ...repo,
15
16
  serviceState: "offline",
@@ -27,6 +28,7 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
27
28
  const [view, setView] = useState(initialRepoRef || initialPrNumber !== undefined ? "detail" : "list");
28
29
  const [flashMessage, setFlashMessage] = useState(null);
29
30
  const [pendingInitialPrNumber, setPendingInitialPrNumber] = useState(initialPrNumber ?? null);
31
+ const [detailScrollOffset, setDetailScrollOffset] = useState(0);
30
32
  const model = useMemo(() => buildDashboard(repoStates), [repoStates]);
31
33
  useEffect(() => {
32
34
  setSelectedRepoId((current) => repoSelector(model.repos, current));
@@ -150,6 +152,9 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
150
152
  setFlashMessage(error instanceof Error ? error.message : String(error));
151
153
  }
152
154
  }
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);
153
158
  useInput((input, key) => {
154
159
  if (input === "q") {
155
160
  exit();
@@ -159,22 +164,58 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
159
164
  void runReconcile(selectedRepoId);
160
165
  return;
161
166
  }
162
- if (input === "j" || key.downArrow) {
167
+ if (view === "list") {
168
+ if (input === "j" || key.downArrow || input === "]") {
169
+ setSelectedRepoId((current) => stepRepo(model.repos, current, 1));
170
+ return;
171
+ }
172
+ if (input === "k" || key.upArrow || input === "[") {
173
+ setSelectedRepoId((current) => stepRepo(model.repos, current, -1));
174
+ return;
175
+ }
176
+ if (key.return)
177
+ setView("detail");
178
+ return;
179
+ }
180
+ // detail view
181
+ if (input === "]") {
163
182
  setSelectedRepoId((current) => stepRepo(model.repos, current, 1));
183
+ setDetailScrollOffset(0);
164
184
  return;
165
185
  }
166
- if (input === "k" || key.upArrow) {
186
+ if (input === "[") {
167
187
  setSelectedRepoId((current) => stepRepo(model.repos, current, -1));
188
+ setDetailScrollOffset(0);
168
189
  return;
169
190
  }
170
- if (view === "list") {
171
- if (key.return)
172
- setView("detail");
191
+ if (input === "j" || key.downArrow) {
192
+ setDetailScrollOffset((offset) => offset + 1);
193
+ return;
194
+ }
195
+ if (input === "k" || key.upArrow) {
196
+ setDetailScrollOffset((offset) => Math.max(0, offset - 1));
197
+ return;
198
+ }
199
+ if (key.pageDown || input === " ") {
200
+ setDetailScrollOffset((offset) => offset + Math.max(1, bodyRows - 2));
201
+ return;
202
+ }
203
+ if (key.pageUp) {
204
+ setDetailScrollOffset((offset) => Math.max(0, offset - Math.max(1, bodyRows - 2)));
205
+ return;
206
+ }
207
+ if (input === "g") {
208
+ setDetailScrollOffset(0);
209
+ return;
210
+ }
211
+ if (input === "G") {
212
+ setDetailScrollOffset(Number.MAX_SAFE_INTEGER);
173
213
  return;
174
214
  }
175
215
  if (key.escape || key.backspace || key.delete) {
176
216
  setView("list");
217
+ setDetailScrollOffset(0);
177
218
  }
178
219
  });
179
- 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 })) : (_jsx(OverviewView, { model: model, selectedRepoId: selectedRepoId, showCursor: true })), flashMessage ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })) : null, _jsx(HelpBar, { view: view })] }));
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 })] }));
180
221
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  export function HelpBar({ view }) {
4
4
  const text = view === "list"
5
- ? "↑↓ switch repo enter detail r reconcile q quit"
6
- : "↑↓ switch repo esc back r reconcile q quit";
5
+ ? "↑↓ repo enter detail r reconcile q quit"
6
+ : "[ ] repo ↑↓ scroll g/G top/end esc back r reconcile q quit";
7
7
  return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: text }) }));
8
8
  }
@@ -3,6 +3,7 @@ interface ListViewProps {
3
3
  model: DashboardModel;
4
4
  selectedRepoId: string | null;
5
5
  showCursor: boolean;
6
+ bodyRows: number;
6
7
  }
7
8
  export declare function RepoRow({ repo, selected, showCursor, width, }: {
8
9
  repo: DashboardRepo;
@@ -14,5 +15,5 @@ export declare function pickVisibleWindow(total: number, selectedIndex: number,
14
15
  start: number;
15
16
  end: number;
16
17
  };
17
- export declare function OverviewView({ model, selectedRepoId, showCursor }: ListViewProps): React.JSX.Element;
18
+ export declare function OverviewView({ model, selectedRepoId, showCursor, bodyRows }: ListViewProps): React.JSX.Element;
18
19
  export {};
@@ -45,21 +45,36 @@ export function pickVisibleWindow(total, selectedIndex, availableRows) {
45
45
  }
46
46
  return { start, end };
47
47
  }
48
- export function OverviewView({ model, selectedRepoId, showCursor }) {
48
+ export function OverviewView({ model, selectedRepoId, showCursor, bodyRows }) {
49
49
  const { stdout } = useStdout();
50
- const rows = Math.max(3, stdout?.rows ?? 24);
51
50
  const width = Math.max(40, stdout?.columns ?? 80);
52
- const availableRows = Math.max(1, rows - 3);
51
+ const total = model.repos.length;
53
52
  const selectedIndex = Math.max(0, model.repos.findIndex((repo) => repo.repoId === selectedRepoId));
54
- const quietReserve = model.repos.length > availableRows && model.quietCount > 0 ? 1 : 0;
55
- const windowRows = Math.max(1, availableRows - quietReserve);
56
- const { start, end } = pickVisibleWindow(model.repos.length, selectedIndex, windowRows);
53
+ let { start, end } = pickVisibleWindow(total, selectedIndex, Math.max(1, bodyRows));
54
+ let needTop = start > 0;
55
+ let needBottom = end < total;
56
+ if (needTop || needBottom) {
57
+ const reserve = (needTop ? 1 : 0) + (needBottom ? 1 : 0);
58
+ const windowRows = Math.max(1, bodyRows - reserve);
59
+ ({ start, end } = pickVisibleWindow(total, selectedIndex, windowRows));
60
+ needTop = start > 0;
61
+ needBottom = end < total;
62
+ }
57
63
  const visible = model.repos.slice(start, end);
58
- const hiddenQuiet = model.repos
59
- .filter((repo, index) => !repo.hasActivity && repo.offlineMessage === null && (index < start || index >= end))
60
- .length;
61
- if (visible.length === 0) {
64
+ const above = start;
65
+ const below = total - end;
66
+ if (total === 0 || bodyRows <= 0) {
62
67
  return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
63
68
  }
64
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [visible.map((repo) => (_jsx(RepoRow, { repo: repo, selected: repo.repoId === selectedRepoId, showCursor: showCursor, width: width - 2 }, repo.repoId))), hiddenQuiet > 0 ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `+${hiddenQuiet} quiet` }) })) : null] }));
69
+ const children = [];
70
+ if (needTop) {
71
+ children.push(_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `\u2191${above} more above` }) }, "above"));
72
+ }
73
+ for (const repo of visible) {
74
+ children.push(_jsx(RepoRow, { repo: repo, selected: repo.repoId === selectedRepoId, showCursor: showCursor, width: width - 2 }, repo.repoId));
75
+ }
76
+ if (needBottom) {
77
+ children.push(_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `\u2193${below} more below` }) }, "below"));
78
+ }
79
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: children.slice(0, Math.max(1, bodyRows)) }));
65
80
  }
@@ -1,7 +1,20 @@
1
- import { type DashboardModel } from "./dashboard-model.ts";
1
+ import { type DashboardModel, type DashboardPrEntry, type DashboardRepo } from "./dashboard-model.ts";
2
2
  interface ProjectDetailViewProps {
3
3
  model: DashboardModel;
4
4
  selectedRepoId: string | null;
5
+ bodyRows: number;
6
+ scrollOffset: number;
5
7
  }
6
- export declare function ProjectDetailView({ model, selectedRepoId }: ProjectDetailViewProps): React.JSX.Element;
8
+ type ContentLine = {
9
+ kind: "blank";
10
+ } | {
11
+ kind: "entry-header";
12
+ entry: DashboardPrEntry;
13
+ } | {
14
+ kind: "summary-line";
15
+ text: string;
16
+ };
17
+ export declare function buildContentLines(repo: DashboardRepo, width: number): ContentLine[];
18
+ export declare function clampScrollOffset(requested: number, totalLines: number, scrollArea: number): number;
19
+ export declare function ProjectDetailView({ model, selectedRepoId, bodyRows, scrollOffset }: ProjectDetailViewProps): React.JSX.Element;
7
20
  export {};
@@ -3,17 +3,60 @@ import { Box, Text, useStdout } from "ink";
3
3
  import { RepoRow } from "./OverviewView.js";
4
4
  import { clipSummary } from "./dashboard-model.js";
5
5
  const PR_ID_WIDTH = 7;
6
+ const PR_PHRASE_WIDTH = 20;
6
7
  const SUMMARY_INDENT = 9;
7
- function PrEntryRow({ entry, width, includeSummary, }) {
8
+ function truncate(value, maxWidth) {
9
+ if (value.length <= maxWidth)
10
+ return value;
11
+ if (maxWidth <= 1)
12
+ return value.slice(0, maxWidth);
13
+ return `${value.slice(0, Math.max(0, maxWidth - 1))}\u2026`;
14
+ }
15
+ export function buildContentLines(repo, width) {
16
+ const lines = [];
17
+ repo.entries.forEach((entry, index) => {
18
+ if (index > 0)
19
+ lines.push({ kind: "blank" });
20
+ lines.push({ kind: "entry-header", entry });
21
+ if (entry.summary) {
22
+ const summaryText = clipSummary(entry.summary, {
23
+ maxLines: 3,
24
+ width: Math.max(20, width - SUMMARY_INDENT),
25
+ });
26
+ if (summaryText) {
27
+ summaryText.split("\n").forEach((line) => lines.push({ kind: "summary-line", text: line }));
28
+ }
29
+ }
30
+ });
31
+ return lines;
32
+ }
33
+ function EntryHeaderRow({ entry, width }) {
8
34
  const idText = `#${entry.prNumber}`.padEnd(PR_ID_WIDTH, " ");
9
- const summaryText = includeSummary
10
- ? clipSummary(entry.summary, { maxLines: 3, width: Math.max(20, width - SUMMARY_INDENT) })
35
+ const paddedPhrase = entry.phrase.padEnd(PR_PHRASE_WIDTH, " ");
36
+ const titleSpace = Math.max(0, width - (PR_ID_WIDTH + 3 + PR_PHRASE_WIDTH + 1));
37
+ const title = entry.title && entry.title !== entry.phrase && titleSpace >= 8
38
+ ? truncate(entry.title, titleSpace)
11
39
  : "";
12
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: entry.color, children: idText }), _jsx(Text, { color: entry.color, children: entry.glyph }), _jsx(Text, { children: ` ${entry.phrase}` })] }), summaryText ? (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { dimColor: true, children: summaryText })] })) : null] }));
40
+ return (_jsxs(Box, { children: [_jsx(Text, { color: entry.color, children: idText }), _jsx(Text, { color: entry.color, children: entry.glyph }), _jsx(Text, { children: ` ${paddedPhrase}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }));
41
+ }
42
+ function renderLine(line, key, width) {
43
+ if (line.kind === "blank")
44
+ return _jsx(Box, { children: _jsx(Text, { children: " " }) }, key);
45
+ if (line.kind === "summary-line") {
46
+ return (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { dimColor: true, children: line.text })] }, key));
47
+ }
48
+ return _jsx(EntryHeaderRow, { entry: line.entry, width: width }, key);
13
49
  }
14
- export function ProjectDetailView({ model, selectedRepoId }) {
50
+ export function clampScrollOffset(requested, totalLines, scrollArea) {
51
+ if (scrollArea <= 0 || totalLines <= scrollArea)
52
+ return 0;
53
+ // When scrolled at all, a one-row top indicator eats into the viewport, so the
54
+ // largest useful offset lets (scrollArea - 1) rows of content fill the remainder.
55
+ const maxOffset = Math.max(0, totalLines - Math.max(1, scrollArea - 1));
56
+ return Math.max(0, Math.min(requested, maxOffset));
57
+ }
58
+ export function ProjectDetailView({ model, selectedRepoId, bodyRows, scrollOffset }) {
15
59
  const { stdout } = useStdout();
16
- const rows = Math.max(3, stdout?.rows ?? 24);
17
60
  const width = Math.max(40, stdout?.columns ?? 80);
18
61
  const repo = selectedRepoId
19
62
  ? model.repos.find((candidate) => candidate.repoId === selectedRepoId) ?? model.repos[0] ?? null
@@ -21,42 +64,32 @@ export function ProjectDetailView({ model, selectedRepoId }) {
21
64
  if (!repo) {
22
65
  return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
23
66
  }
24
- const availableRows = Math.max(1, rows - 3);
25
- const afterRepoRow = Math.max(0, availableRows - 2);
26
- const entries = repo.entries;
27
- const planned = [];
28
- let used = 0;
29
- const maxSummaryLines = 3;
30
- for (const entry of entries) {
31
- if (used >= afterRepoRow)
32
- break;
33
- if (used + 1 > afterRepoRow)
34
- break;
35
- planned.push({ entry, includeSummary: false });
36
- used += 1;
67
+ const repoHeaderRows = 1;
68
+ const separatorRows = bodyRows >= 3 ? 1 : 0;
69
+ const scrollArea = Math.max(0, bodyRows - repoHeaderRows - separatorRows);
70
+ const contentWidth = Math.max(20, width - 2);
71
+ const contentLines = buildContentLines(repo, contentWidth);
72
+ const offset = clampScrollOffset(scrollOffset, contentLines.length, scrollArea);
73
+ const children = [];
74
+ const couldHaveAbove = offset > 0;
75
+ const reserveTop = couldHaveAbove && scrollArea > 0 ? 1 : 0;
76
+ let remaining = scrollArea - reserveTop;
77
+ let tentativeEnd = Math.min(contentLines.length, offset + remaining);
78
+ const belowCount = contentLines.length - tentativeEnd;
79
+ const reserveBottom = belowCount > 0 && remaining > 0 ? 1 : 0;
80
+ if (reserveBottom) {
81
+ remaining = Math.max(0, remaining - 1);
82
+ tentativeEnd = Math.min(contentLines.length, offset + remaining);
37
83
  }
38
- if (afterRepoRow - used >= 2) {
39
- for (let i = 0; i < planned.length; i += 1) {
40
- const item = planned[i];
41
- if (!item.entry.summary)
42
- continue;
43
- const summaryText = clipSummary(item.entry.summary, {
44
- maxLines: maxSummaryLines,
45
- width: Math.max(20, width - SUMMARY_INDENT),
46
- });
47
- if (!summaryText)
48
- continue;
49
- const summaryLines = summaryText.split("\n").length;
50
- const isLast = i === planned.length - 1;
51
- const extra = summaryLines + (isLast ? 0 : 1);
52
- if (used + extra > afterRepoRow)
53
- break;
54
- item.includeSummary = true;
55
- used += extra;
56
- }
84
+ if (reserveTop) {
85
+ children.push(_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` \u2191${offset} more above` }) }, "above"));
86
+ }
87
+ for (let i = offset; i < tentativeEnd; i += 1) {
88
+ children.push(renderLine(contentLines[i], i, contentWidth));
89
+ }
90
+ if (reserveBottom) {
91
+ const hidden = contentLines.length - tentativeEnd;
92
+ children.push(_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` \u2193${hidden} more below` }) }, "below"));
57
93
  }
58
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(RepoRow, { repo: repo, selected: false, showCursor: false, width: width - 2 }), planned.length > 0 ? _jsx(Box, { children: _jsx(Text, { children: " " }) }) : null, planned.map(({ entry, includeSummary }, index) => {
59
- const isLast = index === planned.length - 1;
60
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PrEntryRow, { entry: entry, width: width, includeSummary: includeSummary }), includeSummary && !isLast ? _jsx(Box, { children: _jsx(Text, { children: " " }) }) : null] }, entry.prNumber));
61
- })] }));
94
+ 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] }));
62
95
  }
@@ -23,6 +23,7 @@ export interface DashboardToken {
23
23
  export interface DashboardPrEntry extends DashboardToken {
24
24
  phrase: string;
25
25
  summary?: string;
26
+ title?: string;
26
27
  eventAt: number;
27
28
  }
28
29
  export interface DashboardRepo {
@@ -151,6 +151,9 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
151
151
  const summary = entrySummary(entry);
152
152
  if (summary)
153
153
  item.summary = summary;
154
+ const title = entry.prTitle?.trim() || entry.branch;
155
+ if (title)
156
+ item.title = title;
154
157
  byPr.set(entry.prNumber, item);
155
158
  }
156
159
  return [...byPr.values()].sort((left, right) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.17.1",
3
+ "version": "0.19.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": {