patchrelay 0.32.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/build-info.json +3 -3
- package/dist/cli/commands/watch.js +9 -3
- package/dist/cli/watch/App.js +9 -4
- package/dist/cli/watch/HelpBar.js +2 -2
- package/dist/cli/watch/IssueDetailView.js +89 -1
- package/dist/cli/watch/IssueListView.js +8 -3
- package/dist/cli/watch/IssueRow.js +58 -5
- package/dist/cli/watch/StatusBar.js +2 -2
- package/dist/cli/watch/use-detail-stream.js +4 -2
- package/dist/cli/watch/use-watch-stream.js +3 -1
- package/dist/db/linear-installation-store.js +67 -3
- package/dist/db/migrations.js +5 -0
- package/dist/db.js +11 -0
- package/dist/github-webhook-handler.js +6 -0
- package/dist/linear-client.js +3 -3
- package/dist/run-orchestrator.js +33 -15
- package/dist/service.js +4 -0
- package/dist/webhook-handler.js +15 -1
- package/package.json +3 -3
package/dist/build-info.json
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|
package/dist/cli/watch/App.js
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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(() => {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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(
|
|
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
|
-
|
|
140
|
-
|
|
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
|
package/dist/db/migrations.js
CHANGED
|
@@ -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
|
package/dist/db.js
CHANGED
|
@@ -341,6 +341,13 @@ export class PatchRelayDatabase {
|
|
|
341
341
|
const row = this.connection.prepare("SELECT * FROM issues WHERE pr_number = ?").get(prNumber);
|
|
342
342
|
return row ? mapIssueRow(row) : undefined;
|
|
343
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
|
+
}
|
|
344
351
|
replaceIssueDependencies(params) {
|
|
345
352
|
const now = isoNow();
|
|
346
353
|
this.connection
|
|
@@ -641,6 +648,10 @@ function mapIssueRow(row) {
|
|
|
641
648
|
...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
|
|
642
649
|
...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
|
|
643
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
|
+
: {}),
|
|
644
655
|
...(row.worktree_path !== null ? { worktreePath: String(row.worktree_path) } : {}),
|
|
645
656
|
...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),
|
|
646
657
|
...(row.active_run_id !== null ? { activeRunId: Number(row.active_run_id) } : {}),
|
|
@@ -146,6 +146,9 @@ export class GitHubWebhookHandler {
|
|
|
146
146
|
linearIssueId: issue.linearIssueId,
|
|
147
147
|
factoryState: newState,
|
|
148
148
|
});
|
|
149
|
+
if (newState === "awaiting_queue") {
|
|
150
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "merge_steward");
|
|
151
|
+
}
|
|
149
152
|
this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
|
|
150
153
|
const transitionedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
151
154
|
void this.emitLinearActivity(transitionedIssue, newState, event);
|
|
@@ -318,6 +321,7 @@ export class GitHubWebhookHandler {
|
|
|
318
321
|
lastQueueSignalAt: new Date().toISOString(),
|
|
319
322
|
lastQueueIncidentJson: JSON.stringify(queueRepairContext),
|
|
320
323
|
});
|
|
324
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
321
325
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
322
326
|
this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
|
|
323
327
|
this.feed?.publish({
|
|
@@ -367,6 +371,7 @@ export class GitHubWebhookHandler {
|
|
|
367
371
|
lastGitHubFailureAt: new Date().toISOString(),
|
|
368
372
|
lastQueueIncidentJson: null,
|
|
369
373
|
});
|
|
374
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
370
375
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
371
376
|
this.logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
|
|
372
377
|
this.feed?.publish({
|
|
@@ -391,6 +396,7 @@ export class GitHubWebhookHandler {
|
|
|
391
396
|
reviewerName: event.reviewerName,
|
|
392
397
|
}),
|
|
393
398
|
});
|
|
399
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
394
400
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
395
401
|
this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
|
|
396
402
|
}
|
package/dist/linear-client.js
CHANGED
|
@@ -370,9 +370,9 @@ export class DatabaseBackedLinearClientProvider {
|
|
|
370
370
|
this.logger = logger;
|
|
371
371
|
}
|
|
372
372
|
async forProject(projectId) {
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
375
|
-
return await this.forInstallationId(
|
|
373
|
+
const installation = this.db.linearInstallations.getLinearInstallationForProject(projectId);
|
|
374
|
+
if (installation) {
|
|
375
|
+
return await this.forInstallationId(installation.id);
|
|
376
376
|
}
|
|
377
377
|
return undefined;
|
|
378
378
|
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -61,11 +61,11 @@ function buildRunPrompt(issue, runType, repoPath, context) {
|
|
|
61
61
|
const snapshot = context?.ciSnapshot && typeof context.ciSnapshot === "object"
|
|
62
62
|
? context.ciSnapshot
|
|
63
63
|
: undefined;
|
|
64
|
-
lines.push("## CI Repair", "", "A full CI iteration has settled failed on your PR.
|
|
65
|
-
? `
|
|
64
|
+
lines.push("## CI Repair", "", "A full CI iteration has settled failed on your PR. Start from the specific failing check/job/step below on the latest remote PR branch tip, fix that concrete failure first, then push to the same PR branch.", snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "", snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "", snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "", context?.failureHeadSha ? `Failing head SHA: ${String(context.failureHeadSha)}` : "", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.jobName && context?.jobName !== context?.checkName ? `Failed job: ${String(context.jobName)}` : "", context?.stepName ? `Failed step: ${String(context.stepName)}` : "", context?.summary ? `Failure summary: ${String(context.summary)}` : "", Array.isArray(snapshot?.failedChecks) && snapshot.failedChecks.length > 0
|
|
65
|
+
? `Other failed checks in the settled snapshot (context only; ignore unless the logs show the same root cause):\n${snapshot.failedChecks.map((entry) => `- ${String(entry.name ?? "unknown")}${entry.summary ? `: ${String(entry.summary)}` : ""}`).join("\n")}`
|
|
66
66
|
: "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", Array.isArray(context?.annotations) && context.annotations.length > 0
|
|
67
67
|
? `Annotations:\n${context.annotations.map((entry) => `- ${String(entry)}`).join("\n")}`
|
|
68
|
-
: "", "", "
|
|
68
|
+
: "", "", "Fetch the latest remote branch state first. If the branch moved since this failure, restart from the new tip instead of pushing older work.", "Read the latest logs for the named failing check, fix that root cause, and only broaden scope when the logs show direct fallout from the same issue.", "Do not change workflows, dependency installation, or unrelated tests unless the failing logs clearly point there.", "Run focused verification for the named failure, then commit and push.", "Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.", "Do not change test expectations unless the test is genuinely wrong.", "");
|
|
69
69
|
break;
|
|
70
70
|
}
|
|
71
71
|
case "review_fix":
|
|
@@ -188,6 +188,7 @@ export class RunOrchestrator {
|
|
|
188
188
|
}
|
|
189
189
|
: {}),
|
|
190
190
|
});
|
|
191
|
+
this.db.setBranchOwner(item.projectId, item.issueId, "patchrelay");
|
|
191
192
|
return created;
|
|
192
193
|
});
|
|
193
194
|
if (!run)
|
|
@@ -293,8 +294,9 @@ export class RunOrchestrator {
|
|
|
293
294
|
* Risks mitigated:
|
|
294
295
|
* - Dirty worktree from interrupted run → stash before, pop after
|
|
295
296
|
* - Conflicts → abort rebase, throw so the run fails with a clear reason
|
|
296
|
-
* - Already up-to-date → no-op
|
|
297
|
-
* -
|
|
297
|
+
* - Already up-to-date → no-op
|
|
298
|
+
* - Keep publishing explicit: the orchestrator updates the local worktree
|
|
299
|
+
* only; the agent/run owns any later branch push.
|
|
298
300
|
*/
|
|
299
301
|
async freshenWorktree(worktreePath, project, issue) {
|
|
300
302
|
const gitBin = this.config.runner.gitBin;
|
|
@@ -331,14 +333,7 @@ export class RunOrchestrator {
|
|
|
331
333
|
this.logger.warn({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebase conflict, agent will resolve");
|
|
332
334
|
return;
|
|
333
335
|
}
|
|
334
|
-
|
|
335
|
-
const pushResult = await execCommand(gitBin, ["-C", worktreePath, "push", "--force-with-lease"], { timeoutMs: 60_000 });
|
|
336
|
-
if (pushResult.exitCode !== 0) {
|
|
337
|
-
this.logger.warn({ issueKey: issue.issueKey, stderr: pushResult.stderr?.slice(0, 300) }, "Pre-run rebase push failed, proceeding anyway");
|
|
338
|
-
}
|
|
339
|
-
else {
|
|
340
|
-
this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebased and pushed onto latest base");
|
|
341
|
-
}
|
|
336
|
+
this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebased locally onto latest base");
|
|
342
337
|
// Restore stashed changes
|
|
343
338
|
if (didStash)
|
|
344
339
|
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
@@ -470,6 +465,9 @@ export class RunOrchestrator {
|
|
|
470
465
|
}
|
|
471
466
|
: {}),
|
|
472
467
|
});
|
|
468
|
+
if (postRunState === "awaiting_queue") {
|
|
469
|
+
this.db.setBranchOwner(run.projectId, run.linearIssueId, "merge_steward");
|
|
470
|
+
}
|
|
473
471
|
});
|
|
474
472
|
// If we advanced to awaiting_queue, enqueue for merge prep
|
|
475
473
|
if (postRunState === "awaiting_queue") {
|
|
@@ -570,7 +568,7 @@ export class RunOrchestrator {
|
|
|
570
568
|
}
|
|
571
569
|
// Review approved + checks not failed — advance to awaiting_queue
|
|
572
570
|
if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
|
|
573
|
-
if (issue.factoryState !== "awaiting_queue") {
|
|
571
|
+
if (issue.factoryState !== "awaiting_queue" || issue.branchOwner !== "merge_steward") {
|
|
574
572
|
this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
|
|
575
573
|
}
|
|
576
574
|
continue;
|
|
@@ -688,6 +686,10 @@ export class RunOrchestrator {
|
|
|
688
686
|
}
|
|
689
687
|
: {}),
|
|
690
688
|
});
|
|
689
|
+
const branchOwner = this.resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
|
|
690
|
+
if (branchOwner) {
|
|
691
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
|
|
692
|
+
}
|
|
691
693
|
this.feed?.publish({
|
|
692
694
|
level: "info",
|
|
693
695
|
kind: "stage",
|
|
@@ -697,7 +699,7 @@ export class RunOrchestrator {
|
|
|
697
699
|
status: "reconciled",
|
|
698
700
|
summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
|
|
699
701
|
});
|
|
700
|
-
if (newState === "awaiting_queue") {
|
|
702
|
+
if (newState === "awaiting_queue" && issue.factoryState !== "awaiting_queue") {
|
|
701
703
|
this.requestMergeQueueAdmission(issue, issue.projectId);
|
|
702
704
|
}
|
|
703
705
|
if (options?.pendingRunType) {
|
|
@@ -920,6 +922,9 @@ export class RunOrchestrator {
|
|
|
920
922
|
}
|
|
921
923
|
: {}),
|
|
922
924
|
});
|
|
925
|
+
if (postRunState === "awaiting_queue") {
|
|
926
|
+
this.db.setBranchOwner(run.projectId, run.linearIssueId, "merge_steward");
|
|
927
|
+
}
|
|
923
928
|
});
|
|
924
929
|
if (postRunState) {
|
|
925
930
|
this.feed?.publish({
|
|
@@ -987,8 +992,21 @@ export class RunOrchestrator {
|
|
|
987
992
|
activeRunId: null,
|
|
988
993
|
factoryState: nextState,
|
|
989
994
|
});
|
|
995
|
+
const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
|
|
996
|
+
if (branchOwner) {
|
|
997
|
+
this.db.setBranchOwner(run.projectId, run.linearIssueId, branchOwner);
|
|
998
|
+
}
|
|
990
999
|
});
|
|
991
1000
|
}
|
|
1001
|
+
resolveBranchOwnerForStateTransition(newState, pendingRunType) {
|
|
1002
|
+
if (pendingRunType)
|
|
1003
|
+
return "patchrelay";
|
|
1004
|
+
if (newState === "awaiting_queue")
|
|
1005
|
+
return "merge_steward";
|
|
1006
|
+
if (newState === "repairing_ci" || newState === "repairing_queue")
|
|
1007
|
+
return "patchrelay";
|
|
1008
|
+
return undefined;
|
|
1009
|
+
}
|
|
992
1010
|
async verifyReactiveRunAdvancedBranch(run, issue) {
|
|
993
1011
|
if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
|
|
994
1012
|
return undefined;
|
package/dist/service.js
CHANGED
|
@@ -85,6 +85,10 @@ export class PatchRelayService {
|
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
87
|
async start() {
|
|
88
|
+
const repairedInstallations = this.db.linearInstallations.repairProjectInstallations(this.config.projects.map((project) => project.id));
|
|
89
|
+
for (const repair of repairedInstallations) {
|
|
90
|
+
this.logger.info({ projectId: repair.projectId, installationId: repair.installationId, reason: repair.reason }, "Repaired Linear project installation link");
|
|
91
|
+
}
|
|
88
92
|
// Verify Linear connectivity for all configured projects before starting.
|
|
89
93
|
// Auth errors do not prevent startup (the OAuth callback must be reachable
|
|
90
94
|
// for `patchrelay linear connect`), but the service reports NOT READY until at
|
package/dist/webhook-handler.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
|
|
2
2
|
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
3
|
+
import { TERMINAL_STATES } from "./factory-state.js";
|
|
3
4
|
import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "./linear-session-reporting.js";
|
|
4
5
|
import { resolveProject, triggerEventAllowed, trustedActorAllowed } from "./project-resolution.js";
|
|
5
6
|
import { normalizeWebhook } from "./webhooks.js";
|
|
@@ -161,8 +162,9 @@ export class WebhookHandler {
|
|
|
161
162
|
const hydratedIssue = await this.syncIssueDependencies(project.id, normalizedIssue);
|
|
162
163
|
const unresolvedBlockers = this.db.countUnresolvedBlockers(project.id, normalizedIssue.id);
|
|
163
164
|
const pendingRunContextJson = mergePendingImplementationContext(existingIssue?.pendingRunContextJson, normalized);
|
|
165
|
+
const terminalForAutomation = isTerminalDelegationState(existingIssue, hydratedIssue);
|
|
164
166
|
let pendingRunType;
|
|
165
|
-
if (delegated && triggerAllowed && unresolvedBlockers === 0 && !activeRun && !existingIssue?.pendingRunType) {
|
|
167
|
+
if (delegated && triggerAllowed && unresolvedBlockers === 0 && !activeRun && !existingIssue?.pendingRunType && !terminalForAutomation) {
|
|
166
168
|
pendingRunType = "implementation";
|
|
167
169
|
}
|
|
168
170
|
const clearPendingImplementation = unresolvedBlockers > 0 && existingIssue?.pendingRunType === "implementation" && !activeRun;
|
|
@@ -537,6 +539,18 @@ export class WebhookHandler {
|
|
|
537
539
|
return undefined;
|
|
538
540
|
}
|
|
539
541
|
}
|
|
542
|
+
function isResolvedLinearState(stateType, stateName) {
|
|
543
|
+
return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
|
|
544
|
+
}
|
|
545
|
+
function isTerminalDelegationState(existingIssue, hydratedIssue) {
|
|
546
|
+
if (existingIssue?.prState === "merged") {
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
if (existingIssue?.factoryState && TERMINAL_STATES.has(existingIssue.factoryState)) {
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
return isResolvedLinearState(hydratedIssue.stateType, hydratedIssue.stateName);
|
|
553
|
+
}
|
|
540
554
|
function hasCompleteIssueContext(issue) {
|
|
541
555
|
return Boolean(issue.stateName && issue.delegateId && issue.teamId && issue.teamKey);
|
|
542
556
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "patchrelay",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"prepack": "npm run build",
|
|
34
34
|
"start": "node dist/index.js serve",
|
|
35
35
|
"doctor": "node dist/index.js doctor",
|
|
36
|
-
"restart": "node dist/index.js restart
|
|
37
|
-
"deploy": "npm run build && npm install -g . && node dist/index.js restart
|
|
36
|
+
"restart": "node dist/index.js service restart",
|
|
37
|
+
"deploy": "npm run build && npm install -g . && node dist/index.js service restart",
|
|
38
38
|
"lint": "eslint .",
|
|
39
39
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
40
40
|
"check": "npm run typecheck",
|