merge-steward 0.16.2 → 0.17.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.
@@ -37,6 +37,7 @@ async function readQueueSnapshot(config, eventLimit) {
37
37
  baseBranch: config.baseBranch,
38
38
  githubPolicy: {
39
39
  requiredChecks: [],
40
+ requireAllChecksOnEmptyRequiredSet: false,
40
41
  fetchedAt: null,
41
42
  lastRefreshReason: null,
42
43
  lastRefreshChanged: null,
@@ -9,7 +9,8 @@ import type { CIStatus } from "../types.ts";
9
9
  export declare class GitHubActionsRunner implements CIRunner {
10
10
  private readonly repoFullName;
11
11
  private readonly getRequiredChecks;
12
- constructor(repoFullName: string, getRequiredChecks?: () => string[]);
12
+ private readonly shouldRequireAllChecksOnEmptyRequiredSet;
13
+ constructor(repoFullName: string, getRequiredChecks?: () => string[], shouldRequireAllChecksOnEmptyRequiredSet?: () => boolean);
13
14
  triggerRun(_branch: string, sha: string): Promise<string>;
14
15
  getStatus(runId: string): Promise<CIStatus>;
15
16
  cancelRun(_runId: string): Promise<void>;
@@ -11,9 +11,11 @@ function normalizeCheckName(name) {
11
11
  export class GitHubActionsRunner {
12
12
  repoFullName;
13
13
  getRequiredChecks;
14
- constructor(repoFullName, getRequiredChecks = () => []) {
14
+ shouldRequireAllChecksOnEmptyRequiredSet;
15
+ constructor(repoFullName, getRequiredChecks = () => [], shouldRequireAllChecksOnEmptyRequiredSet = () => false) {
15
16
  this.repoFullName = repoFullName;
16
17
  this.getRequiredChecks = getRequiredChecks;
18
+ this.shouldRequireAllChecksOnEmptyRequiredSet = shouldRequireAllChecksOnEmptyRequiredSet;
17
19
  }
18
20
  async triggerRun(_branch, sha) {
19
21
  // CI is triggered by the push. Return the SHA as the poll key.
@@ -35,6 +37,7 @@ export class GitHubActionsRunner {
35
37
  const requiredChecks = this.getRequiredChecks();
36
38
  const normalizedRequired = requiredChecks.map(normalizeCheckName);
37
39
  const hasRequired = requiredChecks.length > 0;
40
+ const requireAllChecks = !hasRequired && this.shouldRequireAllChecksOnEmptyRequiredSet();
38
41
  const relevant = hasRequired
39
42
  ? checkRuns.filter((c) => normalizedRequired.includes(normalizeCheckName(c.name)))
40
43
  : checkRuns;
@@ -52,7 +55,7 @@ export class GitHubActionsRunner {
52
55
  // (e.g. deploy-stage on main), even though listChecksForRef treats
53
56
  // those same checks as success, producing a "main_broken" block with
54
57
  // an empty failing-check list and stalling the queue.
55
- const acceptSkipped = !hasRequired;
58
+ const acceptSkipped = !hasRequired && !requireAllChecks;
56
59
  if (relevant.some((c) => {
57
60
  if (c.conclusion === "success" || c.conclusion === "neutral")
58
61
  return false;
@@ -2,6 +2,7 @@ import type { Logger } from "pino";
2
2
  import type { DiscoveredRepoSettings } from "./github-repo-discovery.ts";
3
3
  export interface GitHubPolicySnapshot {
4
4
  requiredChecks: string[];
5
+ requireAllChecksOnEmptyRequiredSet: boolean;
5
6
  fetchedAt: string | null;
6
7
  lastRefreshReason: string | null;
7
8
  lastRefreshChanged: boolean | null;
@@ -11,12 +12,14 @@ export interface GitHubPolicyRefreshResult {
11
12
  changed: boolean;
12
13
  previousRequiredChecks: string[];
13
14
  requiredChecks: string[];
15
+ requireAllChecksOnEmptyRequiredSet: boolean;
14
16
  fetchedAt: string | null;
15
17
  skippedReason?: string | undefined;
16
18
  }
17
19
  interface GitHubPolicyCacheOptions {
18
20
  repoFullName: string;
19
21
  initialRequiredChecks: string[];
22
+ initialRequireAllChecksOnEmptyRequiredSet?: boolean;
20
23
  logger: Logger;
21
24
  refreshPolicy(): Promise<DiscoveredRepoSettings>;
22
25
  issueRefreshCooldownMs?: number;
@@ -24,6 +27,7 @@ interface GitHubPolicyCacheOptions {
24
27
  export declare class GitHubPolicyCache {
25
28
  private readonly options;
26
29
  private requiredChecks;
30
+ private requireAllChecksOnEmptyRequiredSet;
27
31
  private fetchedAt;
28
32
  private lastIssueRefreshAt;
29
33
  private readonly issueRefreshCooldownMs;
@@ -32,6 +36,7 @@ export declare class GitHubPolicyCache {
32
36
  constructor(options: GitHubPolicyCacheOptions);
33
37
  getSnapshot(): GitHubPolicySnapshot;
34
38
  getRequiredChecks(): string[];
39
+ shouldRequireAllChecksOnEmptyRequiredSet(): boolean;
35
40
  refreshFromWebhook(reason: string): Promise<GitHubPolicyRefreshResult>;
36
41
  refreshOnIssue(reason: string): Promise<GitHubPolicyRefreshResult>;
37
42
  private refresh;
@@ -11,6 +11,7 @@ function equalChecks(left, right) {
11
11
  export class GitHubPolicyCache {
12
12
  options;
13
13
  requiredChecks;
14
+ requireAllChecksOnEmptyRequiredSet;
14
15
  fetchedAt;
15
16
  lastIssueRefreshAt = 0;
16
17
  issueRefreshCooldownMs;
@@ -19,12 +20,14 @@ export class GitHubPolicyCache {
19
20
  constructor(options) {
20
21
  this.options = options;
21
22
  this.requiredChecks = normalizeChecks(options.initialRequiredChecks);
23
+ this.requireAllChecksOnEmptyRequiredSet = options.initialRequireAllChecksOnEmptyRequiredSet ?? false;
22
24
  this.fetchedAt = new Date().toISOString();
23
25
  this.issueRefreshCooldownMs = options.issueRefreshCooldownMs ?? 5 * 60_000;
24
26
  }
25
27
  getSnapshot() {
26
28
  return {
27
29
  requiredChecks: [...this.requiredChecks],
30
+ requireAllChecksOnEmptyRequiredSet: this.requireAllChecksOnEmptyRequiredSet,
28
31
  fetchedAt: this.fetchedAt,
29
32
  lastRefreshReason: this.lastRefreshReason,
30
33
  lastRefreshChanged: this.lastRefreshChanged,
@@ -33,6 +36,9 @@ export class GitHubPolicyCache {
33
36
  getRequiredChecks() {
34
37
  return [...this.requiredChecks];
35
38
  }
39
+ shouldRequireAllChecksOnEmptyRequiredSet() {
40
+ return this.requireAllChecksOnEmptyRequiredSet;
41
+ }
36
42
  async refreshFromWebhook(reason) {
37
43
  return await this.refresh(reason, { force: true, issueTriggered: false });
38
44
  }
@@ -44,6 +50,7 @@ export class GitHubPolicyCache {
44
50
  changed: false,
45
51
  previousRequiredChecks: [...this.requiredChecks],
46
52
  requiredChecks: [...this.requiredChecks],
53
+ requireAllChecksOnEmptyRequiredSet: this.requireAllChecksOnEmptyRequiredSet,
47
54
  fetchedAt: this.fetchedAt,
48
55
  skippedReason: "cooldown",
49
56
  };
@@ -53,10 +60,13 @@ export class GitHubPolicyCache {
53
60
  }
54
61
  async refresh(reason, options) {
55
62
  const previousRequiredChecks = [...this.requiredChecks];
63
+ const previousRequireAllChecksOnEmptyRequiredSet = this.requireAllChecksOnEmptyRequiredSet;
56
64
  const discovered = await this.options.refreshPolicy();
57
65
  const nextRequiredChecks = normalizeChecks(discovered.requiredChecks);
58
- const changed = !equalChecks(previousRequiredChecks, nextRequiredChecks);
66
+ const changed = !equalChecks(previousRequiredChecks, nextRequiredChecks)
67
+ || previousRequireAllChecksOnEmptyRequiredSet !== discovered.requireAllChecksOnEmptyRequiredSet;
59
68
  this.requiredChecks = nextRequiredChecks;
69
+ this.requireAllChecksOnEmptyRequiredSet = discovered.requireAllChecksOnEmptyRequiredSet;
60
70
  this.fetchedAt = new Date().toISOString();
61
71
  this.lastRefreshReason = reason;
62
72
  this.lastRefreshChanged = changed;
@@ -65,6 +75,7 @@ export class GitHubPolicyCache {
65
75
  reason,
66
76
  changed,
67
77
  requiredChecks: this.requiredChecks,
78
+ requireAllChecksOnEmptyRequiredSet: this.requireAllChecksOnEmptyRequiredSet,
68
79
  policyRefreshSource: options.issueTriggered ? "issue" : (options.force ? "webhook" : "manual"),
69
80
  }, "Refreshed GitHub protection policy");
70
81
  return {
@@ -72,6 +83,7 @@ export class GitHubPolicyCache {
72
83
  changed,
73
84
  previousRequiredChecks,
74
85
  requiredChecks: [...this.requiredChecks],
86
+ requireAllChecksOnEmptyRequiredSet: this.requireAllChecksOnEmptyRequiredSet,
75
87
  fetchedAt: this.fetchedAt,
76
88
  };
77
89
  }
@@ -3,6 +3,7 @@ export interface DiscoveredRepoSettings {
3
3
  defaultBranch: string;
4
4
  branch: string;
5
5
  requiredChecks: string[];
6
+ requireAllChecksOnEmptyRequiredSet: boolean;
6
7
  warnings: string[];
7
8
  }
8
9
  export declare function discoverRepoSettings(credentials: GitHubAppCredentials, repoFullName: string, options?: {
@@ -94,14 +94,20 @@ export async function discoverRepoSettings(credentials, repoFullName, options) {
94
94
  const { requiredChecks: ruleChecks, warnings } = normalizeRequiredChecks(parseRulesResponse(rulesResponse));
95
95
  const protection = await fetchGitHubJsonOptional(`https://api.github.com/repos/${encodedRepo}/branches/${encodeURIComponent(branch)}/protection`, token);
96
96
  const protectionChecks = extractProtectionChecks(protection);
97
+ const requireAllChecksOnEmptyRequiredSet = (ruleChecks.length === 0
98
+ && protectionChecks.length === 0
99
+ && Boolean(protection?.required_status_checks));
97
100
  const requiredChecks = [...new Set([...ruleChecks, ...protectionChecks])].sort((left, right) => left.localeCompare(right));
98
101
  if (requiredChecks.length === 0) {
99
- warnings.push(`No required status checks discovered for ${branch}; Steward will treat any green check as sufficient until GitHub branch protection declares explicit required checks.`);
102
+ warnings.push(requireAllChecksOnEmptyRequiredSet
103
+ ? `GitHub requires status checks on ${branch} but does not expose explicit contexts; Steward will require all observed checks on the ref to pass until branch protection declares named required checks.`
104
+ : `No required status checks discovered for ${branch}; Steward will treat any green check as sufficient until GitHub branch protection declares explicit required checks.`);
100
105
  }
101
106
  return {
102
107
  defaultBranch,
103
108
  branch,
104
109
  requiredChecks,
110
+ requireAllChecksOnEmptyRequiredSet,
105
111
  warnings,
106
112
  };
107
113
  }
@@ -7,7 +7,7 @@ function joinItems(items) {
7
7
  }
8
8
  return items.slice(0, 5).join(", ");
9
9
  }
10
- function evaluateChecks(requiredChecks, checks) {
10
+ function evaluateChecks(requiredChecks, requireAllChecksOnEmptyRequiredSet, checks) {
11
11
  const isPassingConclusion = (conclusion) => conclusion === "success";
12
12
  if (requiredChecks.length > 0) {
13
13
  const byName = new Map(checks.map((check) => [normalizeCheckName(check.name), check]));
@@ -43,7 +43,10 @@ function evaluateChecks(requiredChecks, checks) {
43
43
  const pending = checks.filter((check) => check.conclusion === "pending").map((check) => check.name);
44
44
  const failed = checks.filter((check) => !isPassingConclusion(check.conclusion)).map((check) => check.name);
45
45
  if (checks.length === 0) {
46
- return { postMergeStatus: "unknown", summary: "no checks found yet" };
46
+ return {
47
+ postMergeStatus: requireAllChecksOnEmptyRequiredSet ? "pending" : "unknown",
48
+ summary: requireAllChecksOnEmptyRequiredSet ? "checks required but none found yet" : "no checks found yet",
49
+ };
47
50
  }
48
51
  if (failed.length > 0) {
49
52
  return {
@@ -57,11 +60,15 @@ function evaluateChecks(requiredChecks, checks) {
57
60
  summary: pending.length === 1 ? `check pending: ${pending[0]}` : `checks pending: ${joinItems(pending)}`,
58
61
  };
59
62
  }
60
- return { postMergeStatus: "pass", summary: "all checks passed" };
63
+ return {
64
+ postMergeStatus: "pass",
65
+ summary: requireAllChecksOnEmptyRequiredSet ? "all observed checks passed" : "all checks passed",
66
+ };
61
67
  }
62
68
  export async function verifyPostMergeStatus(ctx, entry) {
63
69
  const postMergeSha = entry.postMergeSha ?? entry.specSha ?? entry.headSha;
64
70
  const requiredChecks = ctx.policy.getRequiredChecks();
71
+ const requireAllChecksOnEmptyRequiredSet = ctx.policy.shouldRequireAllChecksOnEmptyRequiredSet();
65
72
  if (!postMergeSha) {
66
73
  return {
67
74
  postMergeStatus: "unknown",
@@ -76,7 +83,7 @@ export async function verifyPostMergeStatus(ctx, entry) {
76
83
  catch {
77
84
  checks = [];
78
85
  }
79
- const evaluation = evaluateChecks(requiredChecks, checks);
86
+ const evaluation = evaluateChecks(requiredChecks, requireAllChecksOnEmptyRequiredSet, checks);
80
87
  return {
81
88
  postMergeStatus: evaluation.postMergeStatus,
82
89
  postMergeSummary: evaluation.summary,
package/dist/server.js CHANGED
@@ -27,7 +27,7 @@ async function createRepoInstance(config, policy, logger, botIdentity) {
27
27
  git.setBotIdentity(botIdentity);
28
28
  if (config.autoResolvePatterns.length > 0)
29
29
  git.setAutoResolvePatterns(config.autoResolvePatterns);
30
- const ci = new GitHubActionsRunner(config.repoFullName, () => policy.getRequiredChecks());
30
+ const ci = new GitHubActionsRunner(config.repoFullName, () => policy.getRequiredChecks(), () => policy.shouldRequireAllChecksOnEmptyRequiredSet());
31
31
  const github = new GitHubPRClient(config.repoFullName);
32
32
  const eviction = new GitHubCheckRunReporter(config.repoFullName, config.server.bind, config.server.port, config.server.publicBaseUrl, config.admissionLabel, config.mergeQueueCheckName);
33
33
  const service = new MergeStewardService(config, policy, store, git, ci, github, eviction, git, logger);
@@ -220,10 +220,11 @@ export async function startMultiServer() {
220
220
  try {
221
221
  const discovery = githubAuth.mode === "app"
222
222
  ? await discoverRepoSettings(githubAuth.credentials, config.repoFullName, { baseBranch: config.baseBranch })
223
- : { defaultBranch: config.baseBranch, branch: config.baseBranch, requiredChecks: [], warnings: [] };
223
+ : { defaultBranch: config.baseBranch, branch: config.baseBranch, requiredChecks: [], requireAllChecksOnEmptyRequiredSet: false, warnings: [] };
224
224
  const policy = new GitHubPolicyCache({
225
225
  repoFullName: config.repoFullName,
226
226
  initialRequiredChecks: discovery.requiredChecks,
227
+ initialRequireAllChecksOnEmptyRequiredSet: discovery.requireAllChecksOnEmptyRequiredSet,
227
228
  logger: logger.child({ repoId: config.repoId, component: "github-policy" }),
228
229
  refreshPolicy: async () => {
229
230
  if (githubAuth.mode !== "app") {
@@ -231,6 +232,7 @@ export async function startMultiServer() {
231
232
  defaultBranch: config.baseBranch,
232
233
  branch: config.baseBranch,
233
234
  requiredChecks: [],
235
+ requireAllChecksOnEmptyRequiredSet: false,
234
236
  warnings: [],
235
237
  };
236
238
  }
@@ -243,6 +245,7 @@ export async function startMultiServer() {
243
245
  repoId: config.repoId,
244
246
  repoFullName: config.repoFullName,
245
247
  githubRequiredChecks: policy.getRequiredChecks(),
248
+ requireAllChecksOnEmptyRequiredSet: policy.shouldRequireAllChecksOnEmptyRequiredSet(),
246
249
  }, "Resolved GitHub protection requirements");
247
250
  const instance = await createRepoInstance(config, policy, logger.child({ repoId: config.repoId }), botIdentity);
248
251
  if (shuttingDown) {
@@ -138,6 +138,21 @@ export class MergeStewardQueueCommands {
138
138
  return false;
139
139
  }
140
140
  }
141
+ else if (this.policy.shouldRequireAllChecksOnEmptyRequiredSet()) {
142
+ if (checks.length === 0) {
143
+ this.logger.debug({ prNumber }, "GitHub requires checks but none are visible yet, skipping admission");
144
+ return false;
145
+ }
146
+ const hasPending = checks.some((check) => check.conclusion === "pending");
147
+ const hasFailures = checks.some((check) => check.conclusion === "failure");
148
+ if (hasPending || hasFailures) {
149
+ this.logger.debug({
150
+ prNumber,
151
+ checkNames: checks.map((check) => `${check.name}:${check.conclusion}`),
152
+ }, "GitHub requires all observed checks to pass before admission");
153
+ return false;
154
+ }
155
+ }
141
156
  else {
142
157
  const nonSteward = checks.filter((c) => !c.name.startsWith("merge-steward"));
143
158
  const hasGreen = nonSteward.some((c) => c.conclusion === "success");
package/dist/types.d.ts CHANGED
@@ -144,6 +144,7 @@ export interface QueueBlockState {
144
144
  }
145
145
  export interface GitHubPolicyState {
146
146
  requiredChecks: string[];
147
+ requireAllChecksOnEmptyRequiredSet: boolean;
147
148
  fetchedAt: string | null;
148
149
  lastRefreshReason: string | null;
149
150
  lastRefreshChanged: boolean | null;
@@ -10,5 +10,9 @@ export declare function RepoRow({ repo, selected, showCursor, width, }: {
10
10
  showCursor: boolean;
11
11
  width: number;
12
12
  }): React.JSX.Element;
13
+ export declare function pickVisibleWindow(total: number, selectedIndex: number, availableRows: number): {
14
+ start: number;
15
+ end: number;
16
+ };
13
17
  export declare function OverviewView({ model, selectedRepoId, showCursor }: ListViewProps): React.JSX.Element;
14
18
  export {};
@@ -24,25 +24,42 @@ export function RepoRow({ repo, selected, showCursor, width, }) {
24
24
  : repo.repoFullName.padEnd(repoLabelWidth, " ");
25
25
  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 }))] }));
26
26
  }
27
+ export function pickVisibleWindow(total, selectedIndex, availableRows) {
28
+ if (total === 0)
29
+ return { start: 0, end: 0 };
30
+ if (total <= availableRows)
31
+ return { start: 0, end: total };
32
+ const clamped = Math.max(0, Math.min(selectedIndex, total - 1));
33
+ let start = clamped;
34
+ let end = clamped + 1;
35
+ while (end - start < availableRows) {
36
+ if (start > 0 && (end === total || clamped - start <= end - 1 - clamped)) {
37
+ start -= 1;
38
+ }
39
+ else if (end < total) {
40
+ end += 1;
41
+ }
42
+ else {
43
+ break;
44
+ }
45
+ }
46
+ return { start, end };
47
+ }
27
48
  export function OverviewView({ model, selectedRepoId, showCursor }) {
28
49
  const { stdout } = useStdout();
29
50
  const rows = Math.max(3, stdout?.rows ?? 24);
30
51
  const width = Math.max(40, stdout?.columns ?? 80);
31
52
  const availableRows = Math.max(1, rows - 3);
32
- const selectedIndex = model.repos.findIndex((repo) => repo.repoId === selectedRepoId);
33
- const selected = selectedIndex >= 0 ? model.repos[selectedIndex] : model.repos[0];
34
- const others = model.repos.filter((repo) => repo !== selected);
35
- const ordered = [];
36
- if (selected)
37
- ordered.push(selected);
38
- ordered.push(...others);
39
- const quietLine = model.quietCount > 0 ? 1 : 0;
40
- const maxRepoLines = Math.max(1, availableRows - quietLine);
41
- const visible = ordered.slice(0, maxRepoLines);
42
- const hiddenActive = ordered.length - visible.length;
43
- const quietFooter = model.quietCount + hiddenActive;
53
+ 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);
57
+ 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;
44
61
  if (visible.length === 0) {
45
62
  return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
46
63
  }
47
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [visible.map((repo) => (_jsx(RepoRow, { repo: repo, selected: repo === selected, showCursor: showCursor, width: width - 2 }, repo.repoId))), quietFooter > 0 ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `+${quietFooter} quiet` }) })) : null] }));
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] }));
48
65
  }
@@ -197,22 +197,25 @@ export function buildDashboard(repos, opts = {}) {
197
197
  offlineMessage: null,
198
198
  };
199
199
  });
200
- const active = mapped.filter((repo) => repo.hasActivity || repo.offlineMessage);
201
- active.sort((left, right) => {
200
+ mapped.sort((left, right) => {
202
201
  if (left.offlineMessage !== null && right.offlineMessage === null)
203
202
  return 1;
204
203
  if (right.offlineMessage !== null && left.offlineMessage === null)
205
204
  return -1;
206
- const leftHasActive = left.entries.some((entry) => entry.kind === "running" || entry.kind === "queued");
207
- const rightHasActive = right.entries.some((entry) => entry.kind === "running" || entry.kind === "queued");
208
- if (leftHasActive !== rightHasActive)
209
- return leftHasActive ? -1 : 1;
205
+ const leftVisible = left.hasActivity || left.offlineMessage !== null;
206
+ const rightVisible = right.hasActivity || right.offlineMessage !== null;
207
+ if (leftVisible !== rightVisible)
208
+ return leftVisible ? -1 : 1;
209
+ const leftActive = left.entries.some((entry) => entry.kind === "running" || entry.kind === "queued");
210
+ const rightActive = right.entries.some((entry) => entry.kind === "running" || entry.kind === "queued");
211
+ if (leftActive !== rightActive)
212
+ return leftActive ? -1 : 1;
210
213
  if (left.latestActivityAt !== right.latestActivityAt)
211
214
  return right.latestActivityAt - left.latestActivityAt;
212
215
  return left.repoFullName.localeCompare(right.repoFullName);
213
216
  });
214
- const quietCount = mapped.length - active.length;
215
- return { repos: active, quietCount };
217
+ const quietCount = mapped.filter((repo) => !repo.hasActivity && repo.offlineMessage === null).length;
218
+ return { repos: mapped, quietCount };
216
219
  }
217
220
  export function clipSummary(summary, opts = {}) {
218
221
  if (!summary)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.16.2",
3
+ "version": "0.17.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": {