patchrelay 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.31.0",
4
- "commit": "5de73a74995a",
5
- "builtAt": "2026-04-01T09:38:32.968Z"
3
+ "version": "0.32.1",
4
+ "commit": "c48953273955",
5
+ "builtAt": "2026-04-01T21:29:18.620Z"
6
6
  }
@@ -24,7 +24,13 @@ export async function handleWatchCommand(params) {
24
24
  const issueKey = typeof params.parsed.flags.get("issue") === "string"
25
25
  ? String(params.parsed.flags.get("issue"))
26
26
  : undefined;
27
- const instance = render(createElement(App, { baseUrl, bearerToken, initialIssueKey: issueKey }), { stdout: process.stderr, stdin: process.stdin, patchConsole: false });
28
- await instance.waitUntilExit();
29
- return 0;
27
+ process.stderr.write("\u001b[?1049h\u001b[2J\u001b[H");
28
+ try {
29
+ const instance = render(createElement(App, { baseUrl, bearerToken, initialIssueKey: issueKey }), { stdout: process.stderr, stdin: process.stdin, patchConsole: false });
30
+ await instance.waitUntilExit();
31
+ return 0;
32
+ }
33
+ finally {
34
+ process.stderr.write("\u001b[?1049l");
35
+ }
30
36
  }
@@ -68,9 +68,10 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
68
68
  ...(initialIssueKey ? { view: "detail", activeDetailKey: initialIssueKey } : {}),
69
69
  });
70
70
  const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
71
- useWatchStream({ baseUrl, bearerToken, dispatch });
72
- useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
73
- useFeedStream({ baseUrl, bearerToken, active: state.view === "feed", dispatch });
71
+ const [frozen, setFrozen] = useState(false);
72
+ useWatchStream({ baseUrl, bearerToken, dispatch, active: !frozen });
73
+ useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch, active: !frozen });
74
+ useFeedStream({ baseUrl, bearerToken, active: state.view === "feed" && !frozen, dispatch });
74
75
  const [promptMode, setPromptMode] = useState(false);
75
76
  const [promptBuffer, setPromptBuffer] = useState("");
76
77
  const handleRetry = useCallback(() => {
@@ -133,6 +134,10 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
133
134
  exit();
134
135
  return;
135
136
  }
137
+ if (input === "x") {
138
+ setFrozen((value) => !value);
139
+ return;
140
+ }
136
141
  if (state.view === "list") {
137
142
  if (input === "j" || key.downArrow) {
138
143
  dispatch({ type: "select", index: state.selectedIndex + 1 });
@@ -197,5 +202,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
197
202
  }
198
203
  }
199
204
  });
200
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, timelineMode: state.timelineMode, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: "Operator Feed" })] }), _jsx(FeedView, { events: state.feedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt })] })) }));
205
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, timelineMode: state.timelineMode, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: "Operator Feed" })] }), _jsx(FeedView, { events: state.feedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt })] })) }));
201
206
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  const HELP_TEXT = {
4
- list: "j/k: navigate Enter: detail F: feed Tab: filter q: quit",
4
+ list: "j/k: navigate Enter: detail F: feed Tab: filter x: freeze q: quit",
5
5
  detail: "",
6
6
  feed: "Esc: list q: quit",
7
7
  };
@@ -10,7 +10,7 @@ export function HelpBar({ view, follow, detailTab, timelineMode }) {
10
10
  if (view === "detail") {
11
11
  const tabHint = detailTab === "history" ? "t: timeline" : "h: history";
12
12
  const timelineHint = detailTab === "timeline" ? `v: ${timelineMode === "verbose" ? "compact" : "verbose"}` : undefined;
13
- text = [tabHint, timelineHint, "j/k: prev/next", "Esc: list", `f: follow ${follow ? "on" : "off"}`, "p: prompt", "s: stop", "r: retry", "q: quit"]
13
+ text = [tabHint, timelineHint, "j/k: prev/next", "Esc: list", `f: follow ${follow ? "on" : "off"}`, "x: freeze", "p: prompt", "s: stop", "r: retry", "q: quit"]
14
14
  .filter(Boolean)
15
15
  .join(" ");
16
16
  }
@@ -18,6 +18,92 @@ function formatTokens(n) {
18
18
  return `${(n / 1_000).toFixed(1)}k`;
19
19
  return String(n);
20
20
  }
21
+ function formatReviewState(reviewState) {
22
+ switch (reviewState) {
23
+ case "approved":
24
+ return "approved";
25
+ case "changes_requested":
26
+ return "changes requested";
27
+ case "commented":
28
+ return "commented";
29
+ default:
30
+ return reviewState ? reviewState.replaceAll("_", " ") : null;
31
+ }
32
+ }
33
+ function formatCheckState(checkState) {
34
+ switch (checkState) {
35
+ case "passed":
36
+ case "success":
37
+ return "checks passed";
38
+ case "failed":
39
+ case "failure":
40
+ return "checks failed";
41
+ case "pending":
42
+ case "in_progress":
43
+ case "queued":
44
+ return "checks pending";
45
+ default:
46
+ return null;
47
+ }
48
+ }
49
+ function buildPrStatusSummary(issue, issueContext) {
50
+ if (issue.prNumber === undefined)
51
+ return [];
52
+ const summary = [`PR #${issue.prNumber}`];
53
+ const checkState = formatCheckState(issue.prCheckStatus);
54
+ const reviewState = formatReviewState(issue.prReviewState);
55
+ const failedCheck = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName;
56
+ if (checkState === "checks failed" && failedCheck) {
57
+ summary.push(`${failedCheck} failed`);
58
+ }
59
+ else if (checkState) {
60
+ summary.push(checkState);
61
+ }
62
+ if (reviewState) {
63
+ summary.push(`review ${reviewState}`);
64
+ }
65
+ else if (issue.factoryState === "pr_open" || issue.factoryState === "repairing_ci" || issue.factoryState === "awaiting_queue") {
66
+ summary.push("review pending");
67
+ }
68
+ if (issue.factoryState === "awaiting_queue") {
69
+ summary.push("queued for merge");
70
+ }
71
+ else if (issue.factoryState === "repairing_queue") {
72
+ summary.push("merge queue repair needed");
73
+ }
74
+ else if (issue.factoryState === "done") {
75
+ summary.push("merged");
76
+ }
77
+ else if (issue.prCheckStatus === "failed" || issue.prReviewState === undefined || issue.prReviewState === "changes_requested") {
78
+ summary.push("not mergeable");
79
+ }
80
+ return summary;
81
+ }
82
+ function resolvePrimaryBlocker(issue, issueContext) {
83
+ if (issue.blockedByCount > 0) {
84
+ return {
85
+ text: `Waiting on blockers: ${issue.blockedByKeys.join(", ")}`,
86
+ color: "yellow",
87
+ };
88
+ }
89
+ if (issue.prCheckStatus === "failed") {
90
+ const failedCheck = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName;
91
+ return {
92
+ text: failedCheck ? `Blocked by failed check: ${failedCheck}` : "Blocked by failed PR checks",
93
+ color: "red",
94
+ };
95
+ }
96
+ if (issue.prReviewState === "changes_requested") {
97
+ return { text: "Blocked by requested review changes", color: "yellow" };
98
+ }
99
+ if (issue.prNumber !== undefined && !issue.prReviewState && issue.factoryState !== "done") {
100
+ return { text: "Blocked pending review approval", color: "yellow" };
101
+ }
102
+ if (issue.factoryState === "awaiting_queue") {
103
+ return { text: "Waiting in merge queue", color: "yellow" };
104
+ }
105
+ return null;
106
+ }
21
107
  function ElapsedTime({ startedAt }) {
22
108
  const [, tick] = useReducer((c) => c + 1, 0);
23
109
  useEffect(() => {
@@ -44,5 +130,7 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, a
44
130
  const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
45
131
  const graph = useMemo(() => buildPatchRelayStateGraph(history, issue.factoryState), [history, issue.factoryState]);
46
132
  const queueObservations = useMemo(() => buildPatchRelayQueueObservations(issue, rawFeedEvents), [issue, rawFeedEvents]);
47
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.blockedByCount > 0 && _jsxs(Text, { color: "yellow", children: ["blocked by ", issue.blockedByKeys.join(", ")] }), issue.readyForExecution && !issue.activeRunType && issue.blockedByCount === 0 && _jsx(Text, { color: "blueBright", children: "ready" }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), issueContext?.latestFailureSummary && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: issueContext.latestFailureSource === "queue_eviction" ? "yellow" : "red", children: ["Latest failure: ", issueContext.latestFailureSummary, issueContext.latestFailureHeadSha ? ` @ ${issueContext.latestFailureHeadSha.slice(0, 8)}` : ""] }) })), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
133
+ const prStatusSummary = useMemo(() => buildPrStatusSummary(issue, issueContext), [issue, issueContext]);
134
+ const primaryBlocker = useMemo(() => resolvePrimaryBlocker(issue, issueContext), [issue, issueContext]);
135
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.blockedByCount > 0 && _jsxs(Text, { color: "yellow", children: ["blocked by ", issue.blockedByKeys.join(", ")] }), issue.readyForExecution && !issue.activeRunType && issue.blockedByCount === 0 && _jsx(Text, { color: "blueBright", children: "ready" }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), prStatusSummary.length > 0 && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: prStatusSummary.join(" | ") }) })), primaryBlocker && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: primaryBlocker.color, children: ["Blocked by: ", primaryBlocker.text] }) })), issueContext?.latestFailureSummary && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: issueContext.latestFailureSource === "queue_eviction" ? "yellow" : "red", children: ["Latest failure: ", issueContext.latestFailureSummary, issueContext.latestFailureHeadSha ? ` @ ${issueContext.latestFailureHeadSha.slice(0, 8)}` : ""] }) })), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
48
136
  }
@@ -7,7 +7,7 @@ import { HelpBar } from "./HelpBar.js";
7
7
  // selector(2) + key(10) + status(13) + pr(7) + ago(4) + gaps = ~36
8
8
  const FIXED_COLS = 40;
9
9
  const CHROME_ROWS = 4;
10
- export function IssueListView({ issues, allIssues, selectedIndex, connected, lastServerMessageAt, filter, totalCount, }) {
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;
@@ -15,7 +15,12 @@ export function IssueListView({ issues, allIssues, selectedIndex, connected, las
15
15
  const maxVisible = Math.max(1, rows - CHROME_ROWS);
16
16
  // Periodic refresh for elapsed times
17
17
  const [, tick] = useReducer((c) => c + 1, 0);
18
- useEffect(() => { const id = setInterval(tick, 5000); return () => clearInterval(id); }, []);
18
+ useEffect(() => {
19
+ if (frozen)
20
+ return;
21
+ const id = setInterval(tick, 5000);
22
+ return () => clearInterval(id);
23
+ }, [frozen]);
19
24
  let startIndex = 0;
20
25
  if (issues.length > maxVisible) {
21
26
  startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), issues.length - maxVisible));
@@ -23,5 +28,5 @@ export function IssueListView({ issues, allIssues, selectedIndex, connected, las
23
28
  const visible = issues.slice(startIndex, startIndex + maxVisible);
24
29
  const hiddenAbove = startIndex;
25
30
  const hiddenBelow = Math.max(0, issues.length - startIndex - maxVisible);
26
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, allIssues: allIssues }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenAbove, " more above"] }), visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenBelow, " more below"] })] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list" }) })] }));
31
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, allIssues: allIssues, frozen: frozen ?? false }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenAbove, " more above"] }), visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenBelow, " more below"] })] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list" }) })] }));
27
32
  }
@@ -44,12 +44,65 @@ function formatPr(issue) {
44
44
  if (!issue.prNumber)
45
45
  return "";
46
46
  const parts = [`#${issue.prNumber}`];
47
- if (issue.prReviewState === "approved")
48
- parts.push("\u2713");
49
- else if (issue.prReviewState === "changes_requested")
50
- parts.push("\u2717");
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);
51
56
  return parts.join("");
52
57
  }
58
+ function formatReviewState(reviewState) {
59
+ switch (reviewState) {
60
+ case "approved":
61
+ return "rev:+";
62
+ case "changes_requested":
63
+ return "rev:x";
64
+ case "commented":
65
+ return "rev:c";
66
+ case "dismissed":
67
+ return "rev:-";
68
+ default:
69
+ return null;
70
+ }
71
+ }
72
+ function formatCheckState(checkState) {
73
+ switch (checkState) {
74
+ case "passed":
75
+ case "success":
76
+ return "ci:+";
77
+ case "failed":
78
+ case "failure":
79
+ return "ci:x";
80
+ case "pending":
81
+ case "in_progress":
82
+ case "queued":
83
+ return "ci:…";
84
+ default:
85
+ return null;
86
+ }
87
+ }
88
+ function formatMergeState(issue) {
89
+ if (!issue.prNumber)
90
+ return null;
91
+ switch (issue.factoryState) {
92
+ case "awaiting_queue":
93
+ return "queue";
94
+ case "repairing_queue":
95
+ return "mq-fix";
96
+ case "done":
97
+ return "merged";
98
+ case "pr_open":
99
+ if (issue.prReviewState === "approved" && issue.prCheckStatus === "passed")
100
+ return "ready";
101
+ return "open";
102
+ default:
103
+ return null;
104
+ }
105
+ }
53
106
  function relativeTime(iso) {
54
107
  const ms = Date.now() - new Date(iso).getTime();
55
108
  if (ms < 0)
@@ -97,5 +150,5 @@ export function IssueRow({ issue, selected, titleWidth }) {
97
150
  const tw = titleWidth ?? 30;
98
151
  const title = issue.title ? truncate(issue.title, tw) : "";
99
152
  const detail = selected ? summarizeIssueStatusNote(issue.statusNote) : undefined;
100
- 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(6)}` }), _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] }));
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] }));
101
154
  }
@@ -7,10 +7,10 @@ const FILTER_LABELS = {
7
7
  "active": "active",
8
8
  "non-done": "in progress",
9
9
  };
10
- export function StatusBar({ issues, totalCount, filter, connected, lastServerMessageAt, allIssues, }) {
10
+ export function StatusBar({ issues, totalCount, filter, connected, lastServerMessageAt, allIssues, frozen, }) {
11
11
  const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
12
12
  const agg = computeAggregates(allIssues);
13
13
  const withPr = allIssues.filter((i) => i.prNumber !== undefined).length;
14
14
  const awaitingInput = allIssues.filter((i) => i.factoryState === "awaiting_input").length;
15
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter], "]"] }), _jsx(Text, { dimColor: true, children: "|" }), agg.active > 0 && _jsxs(Text, { color: "cyan", children: [agg.active, " active"] }), agg.ready > 0 && _jsxs(Text, { color: "blueBright", children: [agg.ready, " ready"] }), agg.blocked > 0 && _jsxs(Text, { color: "yellow", children: [agg.blocked, " blocked"] }), withPr > 0 && _jsxs(Text, { dimColor: true, children: [withPr, " PRs"] }), awaitingInput > 0 && _jsxs(Text, { color: "yellow", children: [awaitingInput, " awaiting input"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
15
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter], "]"] }), _jsx(Text, { dimColor: true, children: "|" }), agg.active > 0 && _jsxs(Text, { color: "cyan", children: [agg.active, " active"] }), agg.ready > 0 && _jsxs(Text, { color: "blueBright", children: [agg.ready, " ready"] }), agg.blocked > 0 && _jsxs(Text, { color: "yellow", children: [agg.blocked, " blocked"] }), withPr > 0 && _jsxs(Text, { dimColor: true, children: [withPr, " PRs"] }), awaitingInput > 0 && _jsxs(Text, { color: "yellow", children: [awaitingInput, " awaiting input"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] }), frozen && _jsx(Text, { color: "magenta", children: "frozen" })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
16
16
  }
@@ -3,7 +3,9 @@ export function useDetailStream(options) {
3
3
  const optionsRef = useRef(options);
4
4
  optionsRef.current = options;
5
5
  useEffect(() => {
6
- const { issueKey } = optionsRef.current;
6
+ const { issueKey, active } = optionsRef.current;
7
+ if (active === false)
8
+ return;
7
9
  if (!issueKey)
8
10
  return;
9
11
  const abortController = new AbortController();
@@ -19,7 +21,7 @@ export function useDetailStream(options) {
19
21
  return () => {
20
22
  abortController.abort();
21
23
  };
22
- }, [options.issueKey]);
24
+ }, [options.issueKey, options.active]);
23
25
  }
24
26
  // ─── Rehydration ──────────────────────────────────────────────────
25
27
  async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
@@ -3,6 +3,8 @@ export function useWatchStream(options) {
3
3
  const optionsRef = useRef(options);
4
4
  optionsRef.current = options;
5
5
  useEffect(() => {
6
+ if (options.active === false)
7
+ return;
6
8
  let abortController = new AbortController();
7
9
  let reconnectTimeout;
8
10
  let attempt = 0;
@@ -110,7 +112,7 @@ export function useWatchStream(options) {
110
112
  }
111
113
  clearInterval(snapshotInterval);
112
114
  };
113
- }, []);
115
+ }, [options.active]);
114
116
  }
115
117
  function processEvent(dispatch, eventType, data) {
116
118
  try {
@@ -122,6 +122,27 @@ export class LinearInstallationStore {
122
122
  .all();
123
123
  return rows.map((row) => mapProjectInstallation(row));
124
124
  }
125
+ repairProjectInstallations(projectIds) {
126
+ const repairs = [];
127
+ for (const projectId of projectIds) {
128
+ const existing = this.getProjectInstallation(projectId);
129
+ const existingInstallation = existing ? this.getLinearInstallation(existing.installationId) : undefined;
130
+ if (existing && existingInstallation) {
131
+ continue;
132
+ }
133
+ const installationId = this.resolveRepairInstallationId(projectId);
134
+ if (installationId === undefined) {
135
+ continue;
136
+ }
137
+ this.linkProjectInstallation(projectId, installationId);
138
+ repairs.push({
139
+ projectId,
140
+ installationId,
141
+ reason: existing ? "dangling" : "missing",
142
+ });
143
+ }
144
+ return repairs;
145
+ }
125
146
  unlinkProjectInstallation(projectId) {
126
147
  this.connection.prepare("DELETE FROM project_installations WHERE project_id = ?").run(projectId);
127
148
  }
@@ -136,12 +157,55 @@ export class LinearInstallationStore {
136
157
  .prepare(`
137
158
  SELECT li.*
138
159
  FROM linear_installations li
139
- INNER JOIN project_installations pi ON pi.installation_id = li.id
140
- WHERE pi.project_id = ?
160
+ WHERE li.id = COALESCE(
161
+ (
162
+ SELECT pi.installation_id
163
+ FROM project_installations pi
164
+ WHERE pi.project_id = ?
165
+ LIMIT 1
166
+ ),
167
+ (
168
+ SELECT rl.installation_id
169
+ FROM repository_links rl
170
+ WHERE rl.github_repo = ?
171
+ LIMIT 1
172
+ ),
173
+ (
174
+ SELECT li_single.id
175
+ FROM linear_installations li_single
176
+ WHERE (SELECT COUNT(*) FROM linear_installations) = 1
177
+ ORDER BY li_single.updated_at DESC, li_single.id DESC
178
+ LIMIT 1
179
+ )
180
+ )
141
181
  `)
142
- .get(projectId);
182
+ .get(projectId, projectId);
143
183
  return row ? mapLinearInstallation(row) : undefined;
144
184
  }
185
+ resolveRepairInstallationId(projectId) {
186
+ const repoLink = this.connection
187
+ .prepare(`
188
+ SELECT rl.installation_id AS installation_id
189
+ FROM repository_links rl
190
+ INNER JOIN linear_installations li ON li.id = rl.installation_id
191
+ WHERE rl.github_repo = ?
192
+ LIMIT 1
193
+ `)
194
+ .get(projectId);
195
+ if (repoLink) {
196
+ return Number(repoLink.installation_id);
197
+ }
198
+ const singleInstallation = this.connection
199
+ .prepare(`
200
+ SELECT li.id AS installation_id
201
+ FROM linear_installations li
202
+ WHERE (SELECT COUNT(*) FROM linear_installations) = 1
203
+ ORDER BY li.updated_at DESC, li.id DESC
204
+ LIMIT 1
205
+ `)
206
+ .get();
207
+ return singleInstallation ? Number(singleInstallation.installation_id) : undefined;
208
+ }
145
209
  createOAuthState(params) {
146
210
  const now = isoNow();
147
211
  const result = this.connection
@@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS issues (
12
12
  pending_run_type TEXT,
13
13
  pending_run_context_json TEXT,
14
14
  branch_name TEXT,
15
+ branch_owner TEXT NOT NULL DEFAULT 'patchrelay',
16
+ branch_ownership_changed_at TEXT,
15
17
  worktree_path TEXT,
16
18
  thread_id TEXT,
17
19
  active_run_id INTEGER,
@@ -180,6 +182,9 @@ export function runPatchRelayMigrations(connection) {
180
182
  connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
181
183
  // Add pending_merge_prep column for merge queue stewardship
182
184
  addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
185
+ // Explicit PR branch ownership hand-off between PatchRelay and MergeSteward
186
+ addColumnIfMissing(connection, "issues", "branch_owner", "TEXT NOT NULL DEFAULT 'patchrelay'");
187
+ addColumnIfMissing(connection, "issues", "branch_ownership_changed_at", "TEXT");
183
188
  // Add merge_prep_attempts for retry budget / escalation
184
189
  addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
185
190
  // Add review_fix_attempts counter
@@ -204,6 +209,11 @@ export function runPatchRelayMigrations(connection) {
204
209
  addColumnIfMissing(connection, "issues", "last_github_failure_check_url", "TEXT");
205
210
  addColumnIfMissing(connection, "issues", "last_github_failure_context_json", "TEXT");
206
211
  addColumnIfMissing(connection, "issues", "last_github_failure_at", "TEXT");
212
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_head_sha", "TEXT");
213
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_gate_check_name", "TEXT");
214
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_gate_check_status", "TEXT");
215
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_json", "TEXT");
216
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_settled_at", "TEXT");
207
217
  addColumnIfMissing(connection, "issues", "last_queue_signal_at", "TEXT");
208
218
  addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
209
219
  addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
package/dist/db.js CHANGED
@@ -189,6 +189,26 @@ export class PatchRelayDatabase {
189
189
  sets.push("last_github_failure_at = @lastGitHubFailureAt");
190
190
  values.lastGitHubFailureAt = params.lastGitHubFailureAt;
191
191
  }
192
+ if (params.lastGitHubCiSnapshotHeadSha !== undefined) {
193
+ sets.push("last_github_ci_snapshot_head_sha = @lastGitHubCiSnapshotHeadSha");
194
+ values.lastGitHubCiSnapshotHeadSha = params.lastGitHubCiSnapshotHeadSha;
195
+ }
196
+ if (params.lastGitHubCiSnapshotGateCheckName !== undefined) {
197
+ sets.push("last_github_ci_snapshot_gate_check_name = @lastGitHubCiSnapshotGateCheckName");
198
+ values.lastGitHubCiSnapshotGateCheckName = params.lastGitHubCiSnapshotGateCheckName;
199
+ }
200
+ if (params.lastGitHubCiSnapshotGateCheckStatus !== undefined) {
201
+ sets.push("last_github_ci_snapshot_gate_check_status = @lastGitHubCiSnapshotGateCheckStatus");
202
+ values.lastGitHubCiSnapshotGateCheckStatus = params.lastGitHubCiSnapshotGateCheckStatus;
203
+ }
204
+ if (params.lastGitHubCiSnapshotJson !== undefined) {
205
+ sets.push("last_github_ci_snapshot_json = @lastGitHubCiSnapshotJson");
206
+ values.lastGitHubCiSnapshotJson = params.lastGitHubCiSnapshotJson;
207
+ }
208
+ if (params.lastGitHubCiSnapshotSettledAt !== undefined) {
209
+ sets.push("last_github_ci_snapshot_settled_at = @lastGitHubCiSnapshotSettledAt");
210
+ values.lastGitHubCiSnapshotSettledAt = params.lastGitHubCiSnapshotSettledAt;
211
+ }
192
212
  if (params.lastQueueSignalAt !== undefined) {
193
213
  sets.push("last_queue_signal_at = @lastQueueSignalAt");
194
214
  values.lastQueueSignalAt = params.lastQueueSignalAt;
@@ -236,7 +256,9 @@ export class PatchRelayDatabase {
236
256
  branch_name, worktree_path, thread_id, active_run_id,
237
257
  agent_session_id,
238
258
  pr_number, pr_url, pr_state, pr_review_state, pr_check_status,
239
- last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at, last_queue_signal_at, last_queue_incident_json,
259
+ last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at,
260
+ last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
261
+ last_queue_signal_at, last_queue_incident_json,
240
262
  last_attempted_failure_head_sha, last_attempted_failure_signature,
241
263
  updated_at
242
264
  ) VALUES (
@@ -246,7 +268,9 @@ export class PatchRelayDatabase {
246
268
  @branchName, @worktreePath, @threadId, @activeRunId,
247
269
  @agentSessionId,
248
270
  @prNumber, @prUrl, @prState, @prReviewState, @prCheckStatus,
249
- @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt, @lastQueueSignalAt, @lastQueueIncidentJson,
271
+ @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
272
+ @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
273
+ @lastQueueSignalAt, @lastQueueIncidentJson,
250
274
  @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature,
251
275
  @now
252
276
  )
@@ -281,6 +305,11 @@ export class PatchRelayDatabase {
281
305
  lastGitHubFailureCheckUrl: params.lastGitHubFailureCheckUrl ?? null,
282
306
  lastGitHubFailureContextJson: params.lastGitHubFailureContextJson ?? null,
283
307
  lastGitHubFailureAt: params.lastGitHubFailureAt ?? null,
308
+ lastGitHubCiSnapshotHeadSha: params.lastGitHubCiSnapshotHeadSha ?? null,
309
+ lastGitHubCiSnapshotGateCheckName: params.lastGitHubCiSnapshotGateCheckName ?? null,
310
+ lastGitHubCiSnapshotGateCheckStatus: params.lastGitHubCiSnapshotGateCheckStatus ?? null,
311
+ lastGitHubCiSnapshotJson: params.lastGitHubCiSnapshotJson ?? null,
312
+ lastGitHubCiSnapshotSettledAt: params.lastGitHubCiSnapshotSettledAt ?? null,
284
313
  lastQueueSignalAt: params.lastQueueSignalAt ?? null,
285
314
  lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
286
315
  lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
@@ -312,6 +341,13 @@ export class PatchRelayDatabase {
312
341
  const row = this.connection.prepare("SELECT * FROM issues WHERE pr_number = ?").get(prNumber);
313
342
  return row ? mapIssueRow(row) : undefined;
314
343
  }
344
+ setBranchOwner(projectId, linearIssueId, owner) {
345
+ this.connection.prepare(`
346
+ UPDATE issues
347
+ SET branch_owner = ?, branch_ownership_changed_at = ?, updated_at = ?
348
+ WHERE project_id = ? AND linear_issue_id = ?
349
+ `).run(owner, isoNow(), isoNow(), projectId, linearIssueId);
350
+ }
315
351
  replaceIssueDependencies(params) {
316
352
  const now = isoNow();
317
353
  this.connection
@@ -381,6 +417,17 @@ export class PatchRelayDatabase {
381
417
  linearIssueId: String(row.linear_issue_id),
382
418
  }));
383
419
  }
420
+ getLatestGitHubCiSnapshot(projectId, linearIssueId) {
421
+ const issue = this.getIssue(projectId, linearIssueId);
422
+ if (!issue?.lastGitHubCiSnapshotJson)
423
+ return undefined;
424
+ try {
425
+ return JSON.parse(issue.lastGitHubCiSnapshotJson);
426
+ }
427
+ catch {
428
+ return undefined;
429
+ }
430
+ }
384
431
  countUnresolvedBlockers(projectId, linearIssueId) {
385
432
  const row = this.connection.prepare(`
386
433
  SELECT COUNT(*) AS count
@@ -601,6 +648,10 @@ function mapIssueRow(row) {
601
648
  ...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
602
649
  ...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
603
650
  ...(row.branch_name !== null ? { branchName: String(row.branch_name) } : {}),
651
+ ...(row.branch_owner !== null && row.branch_owner !== undefined ? { branchOwner: String(row.branch_owner) } : { branchOwner: "patchrelay" }),
652
+ ...(row.branch_ownership_changed_at !== null && row.branch_ownership_changed_at !== undefined
653
+ ? { branchOwnershipChangedAt: String(row.branch_ownership_changed_at) }
654
+ : {}),
604
655
  ...(row.worktree_path !== null ? { worktreePath: String(row.worktree_path) } : {}),
605
656
  ...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),
606
657
  ...(row.active_run_id !== null ? { activeRunId: Number(row.active_run_id) } : {}),
@@ -632,6 +683,21 @@ function mapIssueRow(row) {
632
683
  ...(row.last_github_failure_at !== null && row.last_github_failure_at !== undefined
633
684
  ? { lastGitHubFailureAt: String(row.last_github_failure_at) }
634
685
  : {}),
686
+ ...(row.last_github_ci_snapshot_head_sha !== null && row.last_github_ci_snapshot_head_sha !== undefined
687
+ ? { lastGitHubCiSnapshotHeadSha: String(row.last_github_ci_snapshot_head_sha) }
688
+ : {}),
689
+ ...(row.last_github_ci_snapshot_gate_check_name !== null && row.last_github_ci_snapshot_gate_check_name !== undefined
690
+ ? { lastGitHubCiSnapshotGateCheckName: String(row.last_github_ci_snapshot_gate_check_name) }
691
+ : {}),
692
+ ...(row.last_github_ci_snapshot_gate_check_status !== null && row.last_github_ci_snapshot_gate_check_status !== undefined
693
+ ? { lastGitHubCiSnapshotGateCheckStatus: String(row.last_github_ci_snapshot_gate_check_status) }
694
+ : {}),
695
+ ...(row.last_github_ci_snapshot_json !== null && row.last_github_ci_snapshot_json !== undefined
696
+ ? { lastGitHubCiSnapshotJson: String(row.last_github_ci_snapshot_json) }
697
+ : {}),
698
+ ...(row.last_github_ci_snapshot_settled_at !== null && row.last_github_ci_snapshot_settled_at !== undefined
699
+ ? { lastGitHubCiSnapshotSettledAt: String(row.last_github_ci_snapshot_settled_at) }
700
+ : {}),
635
701
  ...(row.last_queue_signal_at !== null && row.last_queue_signal_at !== undefined
636
702
  ? { lastQueueSignalAt: String(row.last_queue_signal_at) }
637
703
  : {}),