merge-steward 0.31.0 → 0.32.1
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 +10 -0
- package/dist/db/sqlite-store.js +12 -4
- package/dist/github/pr-client.js +11 -12
- package/dist/service-queue.js +8 -1
- package/dist/types.d.ts +8 -0
- package/dist/watch/ProjectDetailView.js +11 -5
- package/dist/watch/dashboard-model.d.ts +4 -0
- package/dist/watch/dashboard-model.js +12 -4
- package/dist/watch/format.d.ts +2 -0
- package/dist/watch/format.js +7 -1
- package/package.json +1 -1
package/dist/db/schema.js
CHANGED
|
@@ -29,6 +29,7 @@ export function ensureSchema(connection) {
|
|
|
29
29
|
post_merge_sha TEXT,
|
|
30
30
|
post_merge_summary TEXT,
|
|
31
31
|
post_merge_checked_at TEXT,
|
|
32
|
+
decided_at TEXT,
|
|
32
33
|
enqueued_at TEXT NOT NULL,
|
|
33
34
|
updated_at TEXT NOT NULL
|
|
34
35
|
)
|
|
@@ -81,11 +82,20 @@ export function ensureSchema(connection) {
|
|
|
81
82
|
// Plan §5.3: cached identity for patch-id-aware updateHead.
|
|
82
83
|
ensureColumn(connection, "queue_entries", "head_patch_id", "TEXT");
|
|
83
84
|
ensureColumn(connection, "queue_entries", "spec_tree_id", "TEXT");
|
|
85
|
+
ensureColumn(connection, "queue_entries", "decided_at", "TEXT");
|
|
84
86
|
connection.exec(`
|
|
85
87
|
UPDATE queue_entries
|
|
86
88
|
SET post_merge_status = 'pending'
|
|
87
89
|
WHERE status = 'merged'
|
|
88
90
|
AND post_merge_status IS NULL
|
|
91
|
+
`);
|
|
92
|
+
// Backfill decided_at for already-terminal rows so historic entries show a
|
|
93
|
+
// sensible duration/age; updated_at is the best estimate we have for them.
|
|
94
|
+
connection.exec(`
|
|
95
|
+
UPDATE queue_entries
|
|
96
|
+
SET decided_at = updated_at
|
|
97
|
+
WHERE decided_at IS NULL
|
|
98
|
+
AND status IN ('merged', 'evicted', 'dequeued')
|
|
89
99
|
`);
|
|
90
100
|
// Must match TERMINAL_STATUSES in types.ts: merged, evicted, dequeued
|
|
91
101
|
connection.exec(`
|
package/dist/db/sqlite-store.js
CHANGED
|
@@ -33,6 +33,7 @@ function mapEntry(row) {
|
|
|
33
33
|
baseRefName: row.base_ref_name === null || row.base_ref_name === undefined ? null : String(row.base_ref_name),
|
|
34
34
|
headPatchId: row.head_patch_id === null || row.head_patch_id === undefined ? null : String(row.head_patch_id),
|
|
35
35
|
specTreeId: row.spec_tree_id === null || row.spec_tree_id === undefined ? null : String(row.spec_tree_id),
|
|
36
|
+
decidedAt: row.decided_at === null || row.decided_at === undefined ? null : String(row.decided_at),
|
|
36
37
|
enqueuedAt: String(row.enqueued_at),
|
|
37
38
|
updatedAt: String(row.updated_at),
|
|
38
39
|
};
|
|
@@ -136,20 +137,27 @@ export class SqliteStore {
|
|
|
136
137
|
priority, generation, ci_run_id, ci_retries, retry_attempts,
|
|
137
138
|
max_retries, last_failed_base_sha, issue_key, wait_detail,
|
|
138
139
|
post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
|
|
139
|
-
pr_title, base_ref_name,
|
|
140
|
+
pr_title, base_ref_name, decided_at,
|
|
140
141
|
enqueued_at, updated_at)
|
|
141
|
-
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.baseRefName ?? null, entry.enqueuedAt, entry.updatedAt);
|
|
142
|
+
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.baseRefName ?? null, entry.decidedAt ?? null, entry.enqueuedAt, entry.updatedAt);
|
|
142
143
|
this.writeEvent(entry.id, null, entry.status);
|
|
143
144
|
})();
|
|
144
145
|
}
|
|
145
146
|
transition(entryId, to, patch, detail) {
|
|
146
147
|
this.conn.transaction(() => {
|
|
147
|
-
const current = this.conn.prepare("SELECT status FROM queue_entries WHERE id = ?").get(entryId);
|
|
148
|
+
const current = this.conn.prepare("SELECT status, decided_at FROM queue_entries WHERE id = ?").get(entryId);
|
|
148
149
|
if (!current)
|
|
149
150
|
return;
|
|
150
151
|
const from = String(current.status);
|
|
152
|
+
const now = isoNow();
|
|
151
153
|
const sets = ["status = ?", "updated_at = ?"];
|
|
152
|
-
const values = [to,
|
|
154
|
+
const values = [to, now];
|
|
155
|
+
// Stamp decidedAt the first time the entry becomes terminal; never move it
|
|
156
|
+
// afterward (post-merge re-verification keeps transitioning to 'merged').
|
|
157
|
+
if (TERMINAL_STATUSES.includes(to) && (current.decided_at === null || current.decided_at === undefined)) {
|
|
158
|
+
sets.push("decided_at = ?");
|
|
159
|
+
values.push(now);
|
|
160
|
+
}
|
|
153
161
|
if (patch?.headSha !== undefined) {
|
|
154
162
|
sets.push("head_sha = ?");
|
|
155
163
|
values.push(patch.headSha);
|
package/dist/github/pr-client.js
CHANGED
|
@@ -132,19 +132,15 @@ export class GitHubPRClient {
|
|
|
132
132
|
}
|
|
133
133
|
async listLabels(prNumber) {
|
|
134
134
|
const result = await exec("gh", [
|
|
135
|
-
"
|
|
136
|
-
|
|
137
|
-
"--
|
|
135
|
+
"api",
|
|
136
|
+
`repos/${this.repoFullName}/issues/${prNumber}/labels`,
|
|
137
|
+
"--paginate",
|
|
138
|
+
"--jq", ".[].name",
|
|
138
139
|
], { allowNonZero: true, githubRepoFullName: this.repoFullName });
|
|
139
|
-
if (result.exitCode !== 0)
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
const data = JSON.parse(result.stdout);
|
|
143
|
-
return data.labels.map((l) => l.name);
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
return [];
|
|
140
|
+
if (result.exitCode !== 0) {
|
|
141
|
+
throw new Error(`Failed to list labels for PR #${prNumber}: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
147
142
|
}
|
|
143
|
+
return result.stdout.split(/\r?\n/).map((label) => label.trim()).filter(Boolean);
|
|
148
144
|
}
|
|
149
145
|
async setLabels(prNumber, opts) {
|
|
150
146
|
const args = ["pr", "edit", String(prNumber), "--repo", this.repoFullName];
|
|
@@ -155,7 +151,10 @@ export class GitHubPRClient {
|
|
|
155
151
|
// Nothing to change beyond the base args — skip the call.
|
|
156
152
|
if (args.length === 5)
|
|
157
153
|
return;
|
|
158
|
-
await exec("gh", args, { allowNonZero: true, githubRepoFullName: this.repoFullName });
|
|
154
|
+
const result = await exec("gh", args, { allowNonZero: true, githubRepoFullName: this.repoFullName });
|
|
155
|
+
if (result.exitCode !== 0) {
|
|
156
|
+
throw new Error(`Failed to edit labels for PR #${prNumber}: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
157
|
+
}
|
|
159
158
|
}
|
|
160
159
|
}
|
|
161
160
|
/** Map GitHub REST API check-run status/conclusion to our union. */
|
package/dist/service-queue.js
CHANGED
|
@@ -67,6 +67,7 @@ export class MergeStewardQueueCommands {
|
|
|
67
67
|
specTreeId: null,
|
|
68
68
|
prTitle: params.prTitle ?? null,
|
|
69
69
|
baseRefName: params.baseRefName ?? null,
|
|
70
|
+
decidedAt: null,
|
|
70
71
|
enqueuedAt: new Date().toISOString(),
|
|
71
72
|
updatedAt: new Date().toISOString(),
|
|
72
73
|
};
|
|
@@ -145,7 +146,13 @@ export class MergeStewardQueueCommands {
|
|
|
145
146
|
this.logger.debug({ prNumber, reviewDecision: status.reviewDecision }, "PR review gate is not satisfied, skipping admission");
|
|
146
147
|
return false;
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
+
let labels = [];
|
|
150
|
+
try {
|
|
151
|
+
labels = await this.github.listLabels(prNumber);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
this.logger.debug({ prNumber, err: error }, "Could not read labels for priority detection; continuing without priority label");
|
|
155
|
+
}
|
|
149
156
|
const priority = labels.includes(this.config.priorityQueueLabel) ? 1 : 0;
|
|
150
157
|
const checks = await this.github.listChecks(prNumber);
|
|
151
158
|
const requiredChecks = this.policy.getRequiredChecks();
|
package/dist/types.d.ts
CHANGED
|
@@ -74,6 +74,14 @@ export interface QueueEntry {
|
|
|
74
74
|
specTreeId: string | null;
|
|
75
75
|
enqueuedAt: string;
|
|
76
76
|
updatedAt: string;
|
|
77
|
+
/**
|
|
78
|
+
* Set once, when the entry first reaches a terminal status
|
|
79
|
+
* (merged/evicted/dequeued), and never bumped afterward — unlike
|
|
80
|
+
* updatedAt, which post-merge re-verification keeps moving. Lets the
|
|
81
|
+
* dashboard report an accurate "how long it took" (decidedAt - enqueuedAt)
|
|
82
|
+
* and "how long ago" (now - decidedAt). Null while still in flight.
|
|
83
|
+
*/
|
|
84
|
+
decidedAt: string | null;
|
|
77
85
|
}
|
|
78
86
|
export type FailureClass = "main_broken" | "branch_local" | "integration_conflict" | "policy_blocked";
|
|
79
87
|
export type CheckConclusion = "success" | "failure" | "pending";
|
|
@@ -2,11 +2,17 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text, useStdout } from "ink";
|
|
3
3
|
import { RepoRow } from "./OverviewView.js";
|
|
4
4
|
import { clipSummary } from "./dashboard-model.js";
|
|
5
|
-
import {
|
|
5
|
+
import { relativeTime } from "./format.js";
|
|
6
|
+
import { formatDurationMs } from "../runtime-format.js";
|
|
6
7
|
const PR_ID_WIDTH = 7;
|
|
7
8
|
const PR_PHRASE_WIDTH = 20;
|
|
8
9
|
const SUMMARY_INDENT = 9;
|
|
9
|
-
const
|
|
10
|
+
const TIMING_WIDTH = 22;
|
|
11
|
+
// "took 7m · 3m ago" for a finished entry; "running 12m" while in flight.
|
|
12
|
+
function timingLabel(entry) {
|
|
13
|
+
const duration = formatDurationMs(entry.durationMs);
|
|
14
|
+
return entry.recencyAt != null ? `took ${duration} · ${relativeTime(entry.recencyAt)} ago` : `running ${duration}`;
|
|
15
|
+
}
|
|
10
16
|
function truncate(value, maxWidth) {
|
|
11
17
|
if (value.length <= maxWidth)
|
|
12
18
|
return value;
|
|
@@ -38,12 +44,12 @@ export function buildContentLines(repo, width) {
|
|
|
38
44
|
function EntryHeaderRow({ entry, width }) {
|
|
39
45
|
const idText = `#${entry.prNumber}`.padEnd(PR_ID_WIDTH, " ");
|
|
40
46
|
const paddedPhrase = entry.phrase.padEnd(PR_PHRASE_WIDTH, " ");
|
|
41
|
-
const
|
|
42
|
-
const titleSpace = Math.max(0, width - (PR_ID_WIDTH + 3 + PR_PHRASE_WIDTH + 3 +
|
|
47
|
+
const timing = timingLabel(entry).padEnd(TIMING_WIDTH, " ");
|
|
48
|
+
const titleSpace = Math.max(0, width - (PR_ID_WIDTH + 3 + PR_PHRASE_WIDTH + 3 + TIMING_WIDTH));
|
|
43
49
|
const title = entry.title && entry.title !== entry.phrase && titleSpace >= 8
|
|
44
50
|
? truncate(entry.title, titleSpace)
|
|
45
51
|
: "";
|
|
46
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: entry.color, children: idText }), _jsx(Text, { color: entry.color, children: entry.glyph }), _jsx(Text, { children: ` ${paddedPhrase}` }), _jsx(Text, { dimColor: true, children: ` ${
|
|
52
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: entry.color, children: idText }), _jsx(Text, { color: entry.color, children: entry.glyph }), _jsx(Text, { children: ` ${paddedPhrase}` }), _jsx(Text, { dimColor: true, children: ` ${timing}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }));
|
|
47
53
|
}
|
|
48
54
|
function renderLine(line, key, width) {
|
|
49
55
|
if (line.kind === "blank")
|
|
@@ -26,6 +26,10 @@ export interface DashboardToken {
|
|
|
26
26
|
active: boolean;
|
|
27
27
|
/** PR number this entry's speculative spec is stacked on, when that parent is still active. */
|
|
28
28
|
stackedOnPr: number | null;
|
|
29
|
+
/** How long the queue processing took: enqueue -> decided (terminal) or enqueue -> now (active). */
|
|
30
|
+
durationMs: number;
|
|
31
|
+
/** Completion instant (decidedAt) the views age against; null while in flight. */
|
|
32
|
+
recencyAt: number | null;
|
|
29
33
|
}
|
|
30
34
|
export interface DashboardPrEntry extends DashboardToken {
|
|
31
35
|
phrase: string;
|
|
@@ -121,7 +121,7 @@ function overrideKind(kind, override) {
|
|
|
121
121
|
return "error";
|
|
122
122
|
return kind;
|
|
123
123
|
}
|
|
124
|
-
function repoEntriesFromSnapshot(snapshot, cutoff) {
|
|
124
|
+
function repoEntriesFromSnapshot(snapshot, cutoff, now) {
|
|
125
125
|
const latest = pickLatestPerPR(snapshot.entries);
|
|
126
126
|
const head = latest
|
|
127
127
|
.filter((entry) => isActive(entry.status))
|
|
@@ -149,16 +149,24 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
|
|
|
149
149
|
const phrase = entryPhrase(entry, { isHead, queueBlocked });
|
|
150
150
|
const parent = entry.specBasedOn ? idToEntry.get(entry.specBasedOn) ?? null : null;
|
|
151
151
|
const stackedOnPr = parent && isActive(parent.status) ? parent.prNumber : null;
|
|
152
|
+
// decidedAt is the terminal-transition time (never bumped by post-merge
|
|
153
|
+
// re-checks). Duration = enqueue -> decided (or -> now while in flight);
|
|
154
|
+
// recencyAt = the completion instant the views age against.
|
|
155
|
+
const enqueuedAt = timestamp(entry.enqueuedAt);
|
|
156
|
+
const decidedAt = active ? null : timestamp(entry.decidedAt) || timestamp(entry.updatedAt);
|
|
157
|
+
const durationMs = Math.max(0, (decidedAt ?? now) - enqueuedAt);
|
|
152
158
|
const item = {
|
|
153
159
|
prNumber: entry.prNumber,
|
|
154
160
|
glyph,
|
|
155
161
|
color,
|
|
156
162
|
kind,
|
|
157
163
|
phrase,
|
|
158
|
-
eventAt: timestamp(entry.updatedAt),
|
|
164
|
+
eventAt: decidedAt ?? timestamp(entry.updatedAt),
|
|
159
165
|
position: entry.position,
|
|
160
166
|
active,
|
|
161
167
|
stackedOnPr,
|
|
168
|
+
durationMs,
|
|
169
|
+
recencyAt: decidedAt,
|
|
162
170
|
};
|
|
163
171
|
const summary = entrySummary(entry);
|
|
164
172
|
if (summary)
|
|
@@ -209,12 +217,12 @@ export function buildDashboard(repos, opts = {}) {
|
|
|
209
217
|
offlineMessage: message,
|
|
210
218
|
};
|
|
211
219
|
}
|
|
212
|
-
const entries = repoEntriesFromSnapshot(snapshot, cutoff);
|
|
220
|
+
const entries = repoEntriesFromSnapshot(snapshot, cutoff, now);
|
|
213
221
|
const latestActivityAt = entries.reduce((max, entry) => Math.max(max, entry.eventAt), 0);
|
|
214
222
|
return {
|
|
215
223
|
repoId: repo.repoId,
|
|
216
224
|
repoFullName: repo.repoFullName,
|
|
217
|
-
tokens: entries.map(({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr }) => ({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr })),
|
|
225
|
+
tokens: entries.map(({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr, durationMs, recencyAt }) => ({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr, durationMs, recencyAt })),
|
|
218
226
|
entries,
|
|
219
227
|
latestActivityAt,
|
|
220
228
|
hasActivity: entries.length > 0,
|
package/dist/watch/format.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export declare function formatRepoTokenText(token: {
|
|
|
12
12
|
glyph: string;
|
|
13
13
|
eventAt: number;
|
|
14
14
|
stackedOnPr?: number | null;
|
|
15
|
+
durationMs?: number | null;
|
|
16
|
+
recencyAt?: number | null;
|
|
15
17
|
}): string;
|
|
16
18
|
export declare function mergeWaitState(entry: {
|
|
17
19
|
status: QueueEntryStatus;
|
package/dist/watch/format.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatDurationMs } from "../runtime-format.js";
|
|
1
2
|
const QUEUE_SYMBOLS = {
|
|
2
3
|
inProgress: "\u25cf",
|
|
3
4
|
checkPassed: "\u2713",
|
|
@@ -52,7 +53,12 @@ export function formatRepoTokenText(token) {
|
|
|
52
53
|
// A leading connector marks an entry whose spec is speculatively stacked on
|
|
53
54
|
// the PR shown just before it, so the queue order reads as a stack.
|
|
54
55
|
const stackMark = token.stackedOnPr != null ? "↳" : "";
|
|
55
|
-
|
|
56
|
+
// Two times: how long it took (duration) and, for finished entries, how long
|
|
57
|
+
// ago it finished (recency), joined as "took·ago" e.g. "7m·3m". In-flight
|
|
58
|
+
// entries show only the running duration.
|
|
59
|
+
const duration = token.durationMs != null ? formatDurationMs(token.durationMs) : relativeTime(token.eventAt);
|
|
60
|
+
const timing = token.recencyAt != null ? `${duration}·${relativeTime(token.recencyAt)}` : duration;
|
|
61
|
+
return `${stackMark}#${token.prNumber} ${token.glyph} ${timing}`;
|
|
56
62
|
}
|
|
57
63
|
export function mergeWaitState(entry) {
|
|
58
64
|
if (!entry || entry.status !== "merging" || !entry.waitDetail) {
|