patchrelay 0.32.1 → 0.32.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.32.1",
4
- "commit": "c48953273955",
5
- "builtAt": "2026-04-01T21:29:18.620Z"
3
+ "version": "0.32.3",
4
+ "commit": "fbdb9010e964",
5
+ "builtAt": "2026-04-01T22:29:47.921Z"
6
6
  }
@@ -59,6 +59,17 @@ function buildPrStatusSummary(issue, issueContext) {
59
59
  else if (checkState) {
60
60
  summary.push(checkState);
61
61
  }
62
+ if (issue.prChecksSummary?.total) {
63
+ if (issue.prChecksSummary.failed > 0) {
64
+ summary.push(`${issue.prChecksSummary.failed}/${issue.prChecksSummary.total} checks failing`);
65
+ }
66
+ else if (issue.prChecksSummary.pending > 0) {
67
+ summary.push(`${issue.prChecksSummary.completed}/${issue.prChecksSummary.total} checks settled`);
68
+ }
69
+ else {
70
+ summary.push(`${issue.prChecksSummary.passed}/${issue.prChecksSummary.total} checks passed`);
71
+ }
72
+ }
62
73
  if (reviewState) {
63
74
  summary.push(`review ${reviewState}`);
64
75
  }
@@ -86,22 +97,31 @@ function resolvePrimaryBlocker(issue, issueContext) {
86
97
  color: "yellow",
87
98
  };
88
99
  }
89
- if (issue.prCheckStatus === "failed") {
90
- const failedCheck = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName;
100
+ if (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
101
+ const failedChecks = issue.prChecksSummary?.failedNames ?? [];
102
+ const failedCheck = issueContext?.latestFailureCheckName
103
+ ?? issue.latestFailureCheckName
104
+ ?? (failedChecks.length > 0 ? failedChecks.slice(0, 2).join(", ") : undefined);
91
105
  return {
92
106
  text: failedCheck ? `Blocked by failed check: ${failedCheck}` : "Blocked by failed PR checks",
93
107
  color: "red",
94
108
  };
95
109
  }
110
+ if (issue.prCheckStatus === "pending" || issue.prCheckStatus === "in_progress" || issue.prCheckStatus === "queued") {
111
+ return { text: "Waiting for PR checks to finish", color: "yellow" };
112
+ }
96
113
  if (issue.prReviewState === "changes_requested") {
97
114
  return { text: "Blocked by requested review changes", color: "yellow" };
98
115
  }
99
- if (issue.prNumber !== undefined && !issue.prReviewState && issue.factoryState !== "done") {
100
- return { text: "Blocked pending review approval", color: "yellow" };
116
+ if (issue.factoryState === "repairing_queue") {
117
+ return { text: "Blocked by merge queue refresh failure", color: "yellow" };
101
118
  }
102
119
  if (issue.factoryState === "awaiting_queue") {
103
120
  return { text: "Waiting in merge queue", color: "yellow" };
104
121
  }
122
+ if (issue.prNumber !== undefined && !issue.prReviewState && issue.factoryState !== "done") {
123
+ return { text: "Blocked pending review approval", color: "yellow" };
124
+ }
105
125
  return null;
106
126
  }
107
127
  function ElapsedTime({ startedAt }) {
@@ -4,15 +4,15 @@ import { Box, Text, useStdout } from "ink";
4
4
  import { IssueRow } from "./IssueRow.js";
5
5
  import { StatusBar } from "./StatusBar.js";
6
6
  import { HelpBar } from "./HelpBar.js";
7
- // selector(2) + key(10) + status(13) + pr(7) + ago(4) + gaps = ~36
8
- const FIXED_COLS = 40;
7
+ const FIXED_COLS = 8;
9
8
  const CHROME_ROWS = 4;
9
+ const ISSUE_ROW_HEIGHT = 4;
10
10
  export function IssueListView({ issues, allIssues, selectedIndex, connected, lastServerMessageAt, filter, totalCount, frozen, }) {
11
11
  const { stdout } = useStdout();
12
12
  const cols = stdout?.columns ?? 80;
13
13
  const rows = stdout?.rows ?? 24;
14
14
  const titleWidth = Math.max(0, cols - FIXED_COLS);
15
- const maxVisible = Math.max(1, rows - CHROME_ROWS);
15
+ const maxVisible = Math.max(1, Math.floor((rows - CHROME_ROWS) / ISSUE_ROW_HEIGHT));
16
16
  // Periodic refresh for elapsed times
17
17
  const [, tick] = useReducer((c) => c + 1, 0);
18
18
  useEffect(() => {
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { summarizeIssueStatusNote } from "./issue-status-note.js";
4
+ import { progressBar, relativeTime, truncate } from "./format-utils.js";
4
5
  const STATE_COLORS = {
5
6
  blocked: "yellow",
6
7
  ready: "blueBright",
@@ -20,16 +21,16 @@ const STATE_SHORT = {
20
21
  blocked: "blocked",
21
22
  ready: "ready",
22
23
  delegated: "delegated",
23
- implementing: "impl",
24
+ implementing: "implementing",
24
25
  pr_open: "pr open",
25
- changes_requested: "review fix",
26
- repairing_ci: "ci fix",
27
- awaiting_queue: "await queue",
28
- repairing_queue: "merge fix",
26
+ changes_requested: "review changes",
27
+ repairing_ci: "repairing checks",
28
+ awaiting_queue: "queued for merge",
29
+ repairing_queue: "repairing merge queue",
29
30
  done: "done",
30
31
  failed: "failed",
31
32
  escalated: "escalated",
32
- awaiting_input: "await input",
33
+ awaiting_input: "awaiting input",
33
34
  };
34
35
  const STATUS_SHORT = {
35
36
  running: "\u25b8",
@@ -40,115 +41,220 @@ const STATUS_SHORT = {
40
41
  function stateColor(state) {
41
42
  return STATE_COLORS[state] ?? "white";
42
43
  }
43
- function formatPr(issue) {
44
- if (!issue.prNumber)
45
- return "";
46
- const parts = [`#${issue.prNumber}`];
47
- const review = formatReviewState(issue.prReviewState);
48
- const checks = formatCheckState(issue.prCheckStatus);
49
- const merge = formatMergeState(issue);
50
- if (review)
51
- parts.push(review);
52
- if (checks)
53
- parts.push(checks);
54
- if (merge)
55
- parts.push(merge);
56
- return parts.join("");
44
+ const TERMINAL_STATES = new Set(["done", "failed", "escalated", "awaiting_input"]);
45
+ function formatStatus(issue) {
46
+ const effectiveState = issue.blockedByCount > 0 && !issue.activeRunType
47
+ ? "blocked"
48
+ : issue.readyForExecution && !issue.activeRunType
49
+ ? "ready"
50
+ : issue.factoryState;
51
+ const state = STATE_SHORT[effectiveState] ?? effectiveState;
52
+ // Terminal states: just the label, no run symbol
53
+ if (TERMINAL_STATES.has(issue.factoryState))
54
+ return state;
55
+ // Active/in-progress: show run status symbol
56
+ const status = issue.activeRunType ? "running" : issue.latestRunStatus;
57
+ const statusSym = status ? (STATUS_SHORT[status] ?? "") : "";
58
+ if (statusSym)
59
+ return `${state} ${statusSym}`;
60
+ return state;
57
61
  }
58
- function formatReviewState(reviewState) {
62
+ function buildStatusChips(issue) {
63
+ const effectiveState = issue.blockedByCount > 0 && !issue.activeRunType
64
+ ? "blocked"
65
+ : issue.readyForExecution && !issue.activeRunType
66
+ ? "ready"
67
+ : issue.factoryState;
68
+ const chips = [{
69
+ text: `${stateIcon(effectiveState)} ${STATE_SHORT[effectiveState] ?? effectiveState}`,
70
+ color: stateColor(effectiveState),
71
+ }];
72
+ if (issue.prNumber !== undefined) {
73
+ chips.push({ text: `PR #${issue.prNumber}`, color: "cyan" });
74
+ }
75
+ const reviewChip = buildReviewChip(issue.prReviewState);
76
+ if (reviewChip)
77
+ chips.push(reviewChip);
78
+ const checkChip = buildCheckChip(issue.prCheckStatus);
79
+ if (checkChip)
80
+ chips.push(checkChip);
81
+ const checksProgressChip = buildChecksProgressChip(issue);
82
+ if (checksProgressChip)
83
+ chips.push(checksProgressChip);
84
+ const mergeChip = buildMergeChip(issue);
85
+ if (mergeChip)
86
+ chips.push(mergeChip);
87
+ if (issue.blockedByCount > 0) {
88
+ chips.push({
89
+ text: `blocked by ${issue.blockedByKeys.join(", ")}`,
90
+ color: "yellow",
91
+ });
92
+ }
93
+ return chips;
94
+ }
95
+ function stateIcon(state) {
96
+ switch (state) {
97
+ case "implementing":
98
+ case "repairing_ci":
99
+ case "repairing_queue":
100
+ return "\u25b8";
101
+ case "awaiting_queue":
102
+ return "\u25a4";
103
+ case "done":
104
+ return "\u2713";
105
+ case "failed":
106
+ case "escalated":
107
+ return "\u2717";
108
+ case "blocked":
109
+ return "!";
110
+ case "ready":
111
+ return "+";
112
+ default:
113
+ return "\u2022";
114
+ }
115
+ }
116
+ function buildReviewChip(reviewState) {
59
117
  switch (reviewState) {
60
118
  case "approved":
61
- return "rev:+";
119
+ return { text: "\u2713 review approved", color: "green" };
62
120
  case "changes_requested":
63
- return "rev:x";
121
+ return { text: "\u2717 changes requested", color: "yellow" };
64
122
  case "commented":
65
- return "rev:c";
123
+ return { text: "\u2022 review commented", color: "yellow" };
66
124
  case "dismissed":
67
- return "rev:-";
125
+ return { text: "\u2013 review dismissed", color: "yellow" };
68
126
  default:
69
127
  return null;
70
128
  }
71
129
  }
72
- function formatCheckState(checkState) {
130
+ function buildCheckChip(checkState) {
73
131
  switch (checkState) {
74
132
  case "passed":
75
133
  case "success":
76
- return "ci:+";
134
+ return { text: "\u2713 checks passed", color: "green" };
77
135
  case "failed":
78
136
  case "failure":
79
- return "ci:x";
137
+ return { text: "\u2717 checks failed", color: "red" };
80
138
  case "pending":
81
139
  case "in_progress":
82
140
  case "queued":
83
- return "ci:…";
141
+ return { text: "\u25cf checks running", color: "yellow" };
84
142
  default:
85
143
  return null;
86
144
  }
87
145
  }
88
- function formatMergeState(issue) {
89
- if (!issue.prNumber)
146
+ function buildChecksProgressChip(issue) {
147
+ const summary = issue.prChecksSummary;
148
+ if (!summary || summary.total <= 0)
149
+ return null;
150
+ const text = summary.failed > 0
151
+ ? `checks ${summary.failed}/${summary.total} failed`
152
+ : summary.pending > 0
153
+ ? `checks ${summary.completed}/${summary.total} settled`
154
+ : `checks ${summary.passed}/${summary.total} passed`;
155
+ const color = summary.failed > 0 ? "red" : summary.pending > 0 ? "yellow" : "green";
156
+ return { text, color };
157
+ }
158
+ function buildMergeChip(issue) {
159
+ if (issue.prNumber === undefined)
90
160
  return null;
91
161
  switch (issue.factoryState) {
92
162
  case "awaiting_queue":
93
- return "queue";
163
+ return { text: "\u25a4 queued for merge", color: "cyan" };
94
164
  case "repairing_queue":
95
- return "mq-fix";
165
+ return { text: "! merge queue repair", color: "yellow" };
96
166
  case "done":
97
- return "merged";
167
+ return { text: "\u2713 merged", color: "green" };
98
168
  case "pr_open":
99
- if (issue.prReviewState === "approved" && issue.prCheckStatus === "passed")
100
- return "ready";
101
- return "open";
169
+ if (issue.prReviewState === "approved" && issue.prCheckStatus === "passed") {
170
+ return { text: "\u2713 merge ready", color: "green" };
171
+ }
172
+ return { text: "\u2022 PR open", color: "cyan" };
102
173
  default:
103
174
  return null;
104
175
  }
105
176
  }
106
- function relativeTime(iso) {
107
- const ms = Date.now() - new Date(iso).getTime();
108
- if (ms < 0)
109
- return "now";
110
- const seconds = Math.floor(ms / 1000);
111
- if (seconds < 60)
112
- return `${seconds}s`;
113
- const minutes = Math.floor(seconds / 60);
114
- if (minutes < 60)
115
- return `${minutes}m`;
116
- const hours = Math.floor(minutes / 60);
117
- if (hours < 24)
118
- return `${hours}h`;
119
- const days = Math.floor(hours / 24);
120
- return `${days}d`;
121
- }
122
- function truncate(text, max) {
123
- if (max <= 0)
124
- return "";
125
- return text.length > max ? text.slice(0, max) : text;
177
+ function buildPrimaryBlocker(issue) {
178
+ if (issue.blockedByCount > 0) {
179
+ return {
180
+ text: `Waiting on ${issue.blockedByKeys.join(", ")}`,
181
+ color: "yellow",
182
+ };
183
+ }
184
+ if (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
185
+ const failedChecks = issue.prChecksSummary?.failedNames ?? [];
186
+ const failedCheck = issue.latestFailureCheckName
187
+ ?? (failedChecks.length > 0 ? failedChecks.slice(0, 2).join(", ") : undefined)
188
+ ?? "PR checks";
189
+ return {
190
+ text: `${failedCheck} failed`,
191
+ color: "red",
192
+ };
193
+ }
194
+ if (issue.prCheckStatus === "pending" || issue.prCheckStatus === "in_progress" || issue.prCheckStatus === "queued") {
195
+ return {
196
+ text: "Waiting for PR checks to finish",
197
+ color: "yellow",
198
+ };
199
+ }
200
+ if (issue.prReviewState === "changes_requested") {
201
+ return {
202
+ text: "Review changes requested",
203
+ color: "yellow",
204
+ };
205
+ }
206
+ if (issue.factoryState === "repairing_queue") {
207
+ return {
208
+ text: "Merge queue reported a branch refresh failure",
209
+ color: "yellow",
210
+ };
211
+ }
212
+ if (issue.factoryState === "awaiting_queue") {
213
+ return {
214
+ text: "Waiting for merge queue turn",
215
+ color: "yellow",
216
+ };
217
+ }
218
+ if (issue.prNumber !== undefined && !issue.prReviewState && issue.factoryState !== "done") {
219
+ return {
220
+ text: "Waiting for review approval",
221
+ color: "yellow",
222
+ };
223
+ }
224
+ return null;
126
225
  }
127
- const TERMINAL_STATES = new Set(["done", "failed", "escalated", "awaiting_input"]);
128
- function formatStatus(issue) {
129
- const effectiveState = issue.blockedByCount > 0 && !issue.activeRunType
130
- ? "blocked"
131
- : issue.readyForExecution && !issue.activeRunType
132
- ? "ready"
133
- : issue.factoryState;
134
- const state = STATE_SHORT[effectiveState] ?? effectiveState;
135
- // Terminal states: just the label, no run symbol
136
- if (TERMINAL_STATES.has(issue.factoryState))
137
- return state;
138
- // Active/in-progress: show run status symbol
139
- const status = issue.activeRunType ? "running" : issue.latestRunStatus;
140
- const statusSym = status ? (STATUS_SHORT[status] ?? "") : "";
141
- if (statusSym)
142
- return `${state} ${statusSym}`;
143
- return state;
226
+ function buildPipelineProgress(issue) {
227
+ switch (issue.factoryState) {
228
+ case "delegated":
229
+ return { current: 1, total: 4, label: "delegated" };
230
+ case "implementing":
231
+ return { current: 1, total: 4, label: "implementing" };
232
+ case "pr_open":
233
+ case "changes_requested":
234
+ case "repairing_ci":
235
+ return { current: 2, total: 4, label: "pr checks" };
236
+ case "awaiting_queue":
237
+ case "repairing_queue":
238
+ return { current: 3, total: 4, label: "merge queue" };
239
+ case "done":
240
+ return { current: 4, total: 4, label: "merged" };
241
+ case "failed":
242
+ case "escalated":
243
+ case "awaiting_input":
244
+ return { current: 4, total: 4, label: "stopped" };
245
+ default:
246
+ return { current: 1, total: 4, label: "queued" };
247
+ }
144
248
  }
145
249
  export function IssueRow({ issue, selected, titleWidth }) {
146
250
  const key = issue.issueKey ?? issue.projectId;
147
- const status = formatStatus(issue);
148
- const pr = formatPr(issue);
149
251
  const ago = relativeTime(issue.updatedAt);
150
- const tw = titleWidth ?? 30;
252
+ const tw = titleWidth ?? 40;
151
253
  const title = issue.title ? truncate(issue.title, tw) : "";
152
254
  const detail = selected ? summarizeIssueStatusNote(issue.statusNote) : undefined;
153
- return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key.padEnd(9)}` }), _jsx(Text, { color: stateColor(issue.blockedByCount > 0 && !issue.activeRunType ? "blocked" : issue.readyForExecution && !issue.activeRunType ? "ready" : issue.factoryState), children: ` ${status.padEnd(12)}` }), _jsx(Text, { dimColor: true, children: ` ${pr.padEnd(26)}` }), _jsx(Text, { dimColor: true, children: ` ${ago.padStart(3)}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }), detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null] }));
255
+ const status = formatStatus(issue);
256
+ const chips = buildStatusChips(issue);
257
+ const blocker = buildPrimaryBlocker(issue);
258
+ const pipeline = buildPipelineProgress(issue);
259
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${ago}` }), _jsx(Text, { dimColor: true, children: ` ${status}` })] }), _jsx(Box, { paddingLeft: 2, flexWrap: "wrap", children: title ? _jsx(Text, { children: title }) : null }), _jsx(Box, { paddingLeft: 2, flexWrap: "wrap", children: chips.map((chip, index) => (_jsx(Box, { marginRight: 1, children: _jsxs(Text, { color: chip.color, children: ["[", chip.text, "]"] }) }, `${key}-chip-${index}`))) }), _jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: progressBar(pipeline.current, pipeline.total, 8) }), _jsx(Text, { dimColor: true, children: pipeline.label }), blocker ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "|" }), _jsx(Text, { color: blocker.color, children: blocker.text })] })) : null] }), detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null] }));
154
260
  }
package/dist/service.js CHANGED
@@ -35,6 +35,53 @@ function extractStatusNote(summaryJson, reportJson) {
35
35
  }
36
36
  return undefined;
37
37
  }
38
+ export function parseCiSnapshotSummary(snapshotJson) {
39
+ if (!snapshotJson)
40
+ return undefined;
41
+ try {
42
+ const snapshot = JSON.parse(snapshotJson);
43
+ const rawChecks = Array.isArray(snapshot.checks) ? snapshot.checks : [];
44
+ const checks = collapseEffectiveChecks(rawChecks);
45
+ if (checks.length === 0)
46
+ return undefined;
47
+ let passed = 0;
48
+ let failed = 0;
49
+ let pending = 0;
50
+ const failedNames = [];
51
+ for (const check of checks) {
52
+ if (check.status === "success")
53
+ passed++;
54
+ else if (check.status === "failure") {
55
+ failed++;
56
+ failedNames.push(check.name);
57
+ }
58
+ else
59
+ pending++;
60
+ }
61
+ return {
62
+ total: checks.length,
63
+ completed: passed + failed,
64
+ passed,
65
+ failed,
66
+ pending,
67
+ overall: snapshot.gateCheckStatus,
68
+ ...(failedNames.length > 0 ? { failedNames } : {}),
69
+ };
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ function collapseEffectiveChecks(checks) {
76
+ const effective = new Map();
77
+ for (const check of checks) {
78
+ const name = typeof check?.name === "string" ? check.name.trim() : "";
79
+ if (!name || effective.has(name))
80
+ continue;
81
+ effective.set(name, check);
82
+ }
83
+ return [...effective.values()];
84
+ }
38
85
  export class PatchRelayService {
39
86
  config;
40
87
  db;
@@ -221,6 +268,7 @@ export class PatchRelayService {
221
268
  i.current_linear_state, i.factory_state, i.updated_at,
222
269
  i.pending_run_type,
223
270
  i.pr_number, i.pr_review_state, i.pr_check_status,
271
+ i.last_github_ci_snapshot_json,
224
272
  i.last_github_failure_source,
225
273
  i.last_github_failure_head_sha,
226
274
  i.last_github_failure_check_name,
@@ -267,6 +315,7 @@ export class PatchRelayService {
267
315
  .all();
268
316
  return rows.map((row) => {
269
317
  const failureContext = parseGitHubFailureContext(typeof row.last_github_failure_context_json === "string" ? row.last_github_failure_context_json : undefined);
318
+ const prChecksSummary = parseCiSnapshotSummary(typeof row.last_github_ci_snapshot_json === "string" ? row.last_github_ci_snapshot_json : undefined);
270
319
  const statusNote = extractStatusNote(typeof row.latest_run_summary_json === "string" ? row.latest_run_summary_json : undefined, typeof row.latest_run_report_json === "string" ? row.latest_run_report_json : undefined);
271
320
  const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
272
321
  const blockedByCount = Number(row.blocked_by_count ?? 0);
@@ -299,6 +348,7 @@ export class PatchRelayService {
299
348
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
300
349
  ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
301
350
  ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
351
+ ...(prChecksSummary ? { prChecksSummary } : {}),
302
352
  ...(row.last_github_failure_source !== null ? { latestFailureSource: String(row.last_github_failure_source) } : {}),
303
353
  ...(row.last_github_failure_head_sha !== null ? { latestFailureHeadSha: String(row.last_github_failure_head_sha) } : {}),
304
354
  ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.32.1",
3
+ "version": "0.32.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {