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.
- package/dist/cli/commands/queue.js +1 -0
- package/dist/github/actions-runner.d.ts +2 -1
- package/dist/github/actions-runner.js +5 -2
- package/dist/github-policy.d.ts +5 -0
- package/dist/github-policy.js +13 -1
- package/dist/github-repo-discovery.d.ts +1 -0
- package/dist/github-repo-discovery.js +7 -1
- package/dist/reconciler-post-merge.js +11 -4
- package/dist/server.js +5 -2
- package/dist/service-queue.js +15 -0
- package/dist/types.d.ts +1 -0
- package/dist/watch/OverviewView.d.ts +4 -0
- package/dist/watch/OverviewView.js +30 -13
- package/dist/watch/dashboard-model.js +11 -8
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/github-policy.d.ts
CHANGED
|
@@ -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;
|
package/dist/github-policy.js
CHANGED
|
@@ -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(
|
|
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 {
|
|
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 {
|
|
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) {
|
package/dist/service-queue.js
CHANGED
|
@@ -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
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
207
|
-
const
|
|
208
|
-
if (
|
|
209
|
-
return
|
|
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.
|
|
215
|
-
return { repos:
|
|
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)
|