merge-steward 0.30.3 → 0.32.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 +10 -0
- package/dist/db/sqlite-store.d.ts +1 -0
- package/dist/db/sqlite-store.js +19 -4
- package/dist/reconciler.js +7 -5
- package/dist/service-queue.js +1 -0
- package/dist/store.d.ts +2 -0
- package/dist/types.d.ts +8 -0
- package/dist/watch/ProjectDetailView.d.ts +3 -0
- package/dist/watch/ProjectDetailView.js +17 -5
- package/dist/watch/dashboard-model.d.ts +10 -0
- package/dist/watch/dashboard-model.js +33 -4
- package/dist/watch/format.d.ts +3 -0
- package/dist/watch/format.js +10 -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(`
|
|
@@ -11,6 +11,7 @@ export declare class SqliteStore implements QueueStore {
|
|
|
11
11
|
getEntryByPR(repoId: string, prNumber: number): QueueEntry | undefined;
|
|
12
12
|
listActive(repoId: string): QueueEntry[];
|
|
13
13
|
listAll(repoId: string): QueueEntry[];
|
|
14
|
+
listPostMergePending(repoId: string): QueueEntry[];
|
|
14
15
|
insert(entry: QueueEntry): void;
|
|
15
16
|
transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
|
|
16
17
|
dequeue(entryId: string): void;
|
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
|
};
|
|
@@ -122,6 +123,13 @@ export class SqliteStore {
|
|
|
122
123
|
const rows = this.conn.prepare("SELECT * FROM queue_entries WHERE repo_id = ? ORDER BY priority DESC, position ASC").all(repoId);
|
|
123
124
|
return rows.map(mapEntry);
|
|
124
125
|
}
|
|
126
|
+
listPostMergePending(repoId) {
|
|
127
|
+
const rows = this.conn.prepare(`SELECT * FROM queue_entries
|
|
128
|
+
WHERE repo_id = ? AND status = 'merged'
|
|
129
|
+
AND (post_merge_status IS NULL OR post_merge_status NOT IN ('pass', 'fail'))
|
|
130
|
+
ORDER BY position ASC`).all(repoId);
|
|
131
|
+
return rows.map(mapEntry);
|
|
132
|
+
}
|
|
125
133
|
insert(entry) {
|
|
126
134
|
this.conn.transaction(() => {
|
|
127
135
|
this.conn.prepare(`INSERT INTO queue_entries
|
|
@@ -129,20 +137,27 @@ export class SqliteStore {
|
|
|
129
137
|
priority, generation, ci_run_id, ci_retries, retry_attempts,
|
|
130
138
|
max_retries, last_failed_base_sha, issue_key, wait_detail,
|
|
131
139
|
post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
|
|
132
|
-
pr_title, base_ref_name,
|
|
140
|
+
pr_title, base_ref_name, decided_at,
|
|
133
141
|
enqueued_at, updated_at)
|
|
134
|
-
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);
|
|
135
143
|
this.writeEvent(entry.id, null, entry.status);
|
|
136
144
|
})();
|
|
137
145
|
}
|
|
138
146
|
transition(entryId, to, patch, detail) {
|
|
139
147
|
this.conn.transaction(() => {
|
|
140
|
-
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);
|
|
141
149
|
if (!current)
|
|
142
150
|
return;
|
|
143
151
|
const from = String(current.status);
|
|
152
|
+
const now = isoNow();
|
|
144
153
|
const sets = ["status = ?", "updated_at = ?"];
|
|
145
|
-
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
|
+
}
|
|
146
161
|
if (patch?.headSha !== undefined) {
|
|
147
162
|
sets.push("head_sha = ?");
|
|
148
163
|
values.push(patch.headSha);
|
package/dist/reconciler.js
CHANGED
|
@@ -11,8 +11,10 @@ import { syncQueueStateLabels } from "./reconciler-queue-labels.js";
|
|
|
11
11
|
// ─── Main reconcile loop ────────────────────────────────────────
|
|
12
12
|
export async function reconcile(ctx) {
|
|
13
13
|
const allActive = ctx.store.listActive(ctx.repoId);
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Note: do NOT early-return when the active queue is empty. A drained queue
|
|
15
|
+
// can still hold merged entries whose post-merge verification is unresolved
|
|
16
|
+
// (e.g. an externally-merged PR), and verifyMergedEntriesPostPush below is
|
|
17
|
+
// the only thing that advances them. depth=0 simply skips the active loop.
|
|
16
18
|
// Process up to speculativeDepth entries. GitHub truth checks are
|
|
17
19
|
// bounded by this window — we never scan the full queue.
|
|
18
20
|
const depth = Math.min(ctx.speculativeDepth, allActive.length);
|
|
@@ -91,10 +93,10 @@ export async function reconcile(ctx) {
|
|
|
91
93
|
await verifyMergedEntriesPostPush(ctx);
|
|
92
94
|
}
|
|
93
95
|
async function verifyMergedEntriesPostPush(ctx) {
|
|
94
|
-
|
|
96
|
+
// Targeted query (not listAll) so an idle repo with a large merged history
|
|
97
|
+
// doesn't scan every terminal row each tick — only the unresolved ones.
|
|
98
|
+
const mergedEntries = ctx.store.listPostMergePending(ctx.repoId);
|
|
95
99
|
for (const entry of mergedEntries) {
|
|
96
|
-
if (entry.postMergeStatus === "pass" || entry.postMergeStatus === "fail")
|
|
97
|
-
continue;
|
|
98
100
|
const postMergeSha = entry.postMergeSha ?? entry.specSha ?? entry.headSha;
|
|
99
101
|
if (!postMergeSha) {
|
|
100
102
|
continue;
|
package/dist/service-queue.js
CHANGED
package/dist/store.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface QueueStore {
|
|
|
10
10
|
getEntryByPR(repoId: string, prNumber: number): QueueEntry | undefined;
|
|
11
11
|
listActive(repoId: string): QueueEntry[];
|
|
12
12
|
listAll(repoId: string): QueueEntry[];
|
|
13
|
+
/** Merged entries whose post-merge verification is unresolved (not pass/fail). */
|
|
14
|
+
listPostMergePending(repoId: string): QueueEntry[];
|
|
13
15
|
insert(entry: QueueEntry): void;
|
|
14
16
|
transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
|
|
15
17
|
dequeue(entryId: string): void;
|
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;
|
|
@@ -20,6 +26,9 @@ export function buildContentLines(repo, width) {
|
|
|
20
26
|
if (index > 0)
|
|
21
27
|
lines.push({ kind: "blank" });
|
|
22
28
|
lines.push({ kind: "entry-header", entry });
|
|
29
|
+
if (entry.stackedOnPr != null) {
|
|
30
|
+
lines.push({ kind: "stack-line", text: `↳ stacked on #${entry.stackedOnPr} (merges after it)` });
|
|
31
|
+
}
|
|
23
32
|
if (entry.summary) {
|
|
24
33
|
const summaryText = clipSummary(entry.summary, {
|
|
25
34
|
maxLines: 3,
|
|
@@ -35,16 +44,19 @@ export function buildContentLines(repo, width) {
|
|
|
35
44
|
function EntryHeaderRow({ entry, width }) {
|
|
36
45
|
const idText = `#${entry.prNumber}`.padEnd(PR_ID_WIDTH, " ");
|
|
37
46
|
const paddedPhrase = entry.phrase.padEnd(PR_PHRASE_WIDTH, " ");
|
|
38
|
-
const
|
|
39
|
-
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));
|
|
40
49
|
const title = entry.title && entry.title !== entry.phrase && titleSpace >= 8
|
|
41
50
|
? truncate(entry.title, titleSpace)
|
|
42
51
|
: "";
|
|
43
|
-
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] }));
|
|
44
53
|
}
|
|
45
54
|
function renderLine(line, key, width) {
|
|
46
55
|
if (line.kind === "blank")
|
|
47
56
|
return _jsx(Box, { children: _jsx(Text, { children: " " }) }, key);
|
|
57
|
+
if (line.kind === "stack-line") {
|
|
58
|
+
return (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { color: "gray", children: line.text })] }, key));
|
|
59
|
+
}
|
|
48
60
|
if (line.kind === "summary-line") {
|
|
49
61
|
return (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { dimColor: true, children: line.text })] }, key));
|
|
50
62
|
}
|
|
@@ -20,6 +20,16 @@ export interface DashboardToken {
|
|
|
20
20
|
color: DashboardTokenColor;
|
|
21
21
|
kind: DashboardTokenKind;
|
|
22
22
|
eventAt: number;
|
|
23
|
+
/** Queue position; active entries are ordered by this so the stack/queue order shows. */
|
|
24
|
+
position: number;
|
|
25
|
+
/** Whether the entry is still in flight (not merged/evicted/dequeued). */
|
|
26
|
+
active: boolean;
|
|
27
|
+
/** PR number this entry's speculative spec is stacked on, when that parent is still active. */
|
|
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;
|
|
23
33
|
}
|
|
24
34
|
export interface DashboardPrEntry extends DashboardToken {
|
|
25
35
|
phrase: string;
|
|
@@ -121,12 +121,19 @@ 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))
|
|
128
128
|
.sort((a, b) => a.position - b.position)[0] ?? null;
|
|
129
129
|
const queueBlocked = queueBlockMatchesEntry(snapshot.queueBlock, head);
|
|
130
|
+
// Resolve speculative stacking: an entry's spec can be built on top of
|
|
131
|
+
// another entry's spec (specBasedOn -> that entry's id). Map ids to PR
|
|
132
|
+
// numbers so the views can show "stacked on #N". Only surface the link
|
|
133
|
+
// when the parent is still active, since a merged parent collapses the stack.
|
|
134
|
+
const idToEntry = new Map();
|
|
135
|
+
for (const entry of latest)
|
|
136
|
+
idToEntry.set(entry.id, entry);
|
|
130
137
|
const byPr = new Map();
|
|
131
138
|
for (const entry of latest) {
|
|
132
139
|
const active = isActive(entry.status);
|
|
@@ -140,13 +147,26 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
|
|
|
140
147
|
const glyph = GLYPH[kind];
|
|
141
148
|
const color = COLOR[kind];
|
|
142
149
|
const phrase = entryPhrase(entry, { isHead, queueBlocked });
|
|
150
|
+
const parent = entry.specBasedOn ? idToEntry.get(entry.specBasedOn) ?? null : null;
|
|
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);
|
|
143
158
|
const item = {
|
|
144
159
|
prNumber: entry.prNumber,
|
|
145
160
|
glyph,
|
|
146
161
|
color,
|
|
147
162
|
kind,
|
|
148
163
|
phrase,
|
|
149
|
-
eventAt: timestamp(entry.updatedAt),
|
|
164
|
+
eventAt: decidedAt ?? timestamp(entry.updatedAt),
|
|
165
|
+
position: entry.position,
|
|
166
|
+
active,
|
|
167
|
+
stackedOnPr,
|
|
168
|
+
durationMs,
|
|
169
|
+
recencyAt: decidedAt,
|
|
150
170
|
};
|
|
151
171
|
const summary = entrySummary(entry);
|
|
152
172
|
if (summary)
|
|
@@ -156,7 +176,16 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
|
|
|
156
176
|
item.title = title;
|
|
157
177
|
byPr.set(entry.prNumber, item);
|
|
158
178
|
}
|
|
179
|
+
// Active entries first, in queue/stack order (by position, head first) so
|
|
180
|
+
// speculative stacks read top-to-bottom; decided entries follow, newest-first.
|
|
159
181
|
return [...byPr.values()].sort((left, right) => {
|
|
182
|
+
if (left.active !== right.active)
|
|
183
|
+
return left.active ? -1 : 1;
|
|
184
|
+
if (left.active) {
|
|
185
|
+
if (left.position !== right.position)
|
|
186
|
+
return left.position - right.position;
|
|
187
|
+
return left.prNumber - right.prNumber;
|
|
188
|
+
}
|
|
160
189
|
const leftOrder = tokenSortOrder(left.kind);
|
|
161
190
|
const rightOrder = tokenSortOrder(right.kind);
|
|
162
191
|
if (leftOrder !== rightOrder)
|
|
@@ -188,12 +217,12 @@ export function buildDashboard(repos, opts = {}) {
|
|
|
188
217
|
offlineMessage: message,
|
|
189
218
|
};
|
|
190
219
|
}
|
|
191
|
-
const entries = repoEntriesFromSnapshot(snapshot, cutoff);
|
|
220
|
+
const entries = repoEntriesFromSnapshot(snapshot, cutoff, now);
|
|
192
221
|
const latestActivityAt = entries.reduce((max, entry) => Math.max(max, entry.eventAt), 0);
|
|
193
222
|
return {
|
|
194
223
|
repoId: repo.repoId,
|
|
195
224
|
repoFullName: repo.repoFullName,
|
|
196
|
-
tokens: entries.map(({ prNumber, glyph, color, kind, eventAt }) => ({ prNumber, glyph, color, kind, eventAt })),
|
|
225
|
+
tokens: entries.map(({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr, durationMs, recencyAt }) => ({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr, durationMs, recencyAt })),
|
|
197
226
|
entries,
|
|
198
227
|
latestActivityAt,
|
|
199
228
|
hasActivity: entries.length > 0,
|
package/dist/watch/format.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export declare function formatRepoTokenText(token: {
|
|
|
11
11
|
prNumber: number;
|
|
12
12
|
glyph: string;
|
|
13
13
|
eventAt: number;
|
|
14
|
+
stackedOnPr?: number | null;
|
|
15
|
+
durationMs?: number | null;
|
|
16
|
+
recencyAt?: number | null;
|
|
14
17
|
}): string;
|
|
15
18
|
export declare function mergeWaitState(entry: {
|
|
16
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",
|
|
@@ -49,7 +50,15 @@ export function formatTokenAge(eventAt) {
|
|
|
49
50
|
return relativeTime(eventAt).padStart(4, " ");
|
|
50
51
|
}
|
|
51
52
|
export function formatRepoTokenText(token) {
|
|
52
|
-
|
|
53
|
+
// A leading connector marks an entry whose spec is speculatively stacked on
|
|
54
|
+
// the PR shown just before it, so the queue order reads as a stack.
|
|
55
|
+
const stackMark = token.stackedOnPr != null ? "↳" : "";
|
|
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}`;
|
|
53
62
|
}
|
|
54
63
|
export function mergeWaitState(entry) {
|
|
55
64
|
if (!entry || entry.status !== "merging" || !entry.waitDetail) {
|