patchrelay 0.17.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-session-plan.js +17 -17
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/App.js +18 -1
- package/dist/cli/watch/FeedView.js +32 -0
- package/dist/cli/watch/HelpBar.js +9 -3
- package/dist/cli/watch/IssueDetailView.js +69 -3
- package/dist/cli/watch/IssueListView.js +2 -2
- package/dist/cli/watch/StatusBar.js +4 -2
- package/dist/cli/watch/TimelineRow.js +11 -2
- package/dist/cli/watch/use-detail-stream.js +19 -0
- package/dist/cli/watch/use-feed-stream.js +92 -0
- package/dist/cli/watch/watch-state.js +40 -0
- package/dist/config.js +6 -0
- package/dist/db/migrations.js +4 -0
- package/dist/db.js +28 -4
- package/dist/github-webhook-handler.js +13 -2
- package/dist/issue-query-service.js +18 -1
- package/dist/linear-client.js +12 -0
- package/dist/run-orchestrator.js +45 -28
- package/dist/webhook-handler.js +3 -0
- package/dist/webhooks.js +8 -0
- package/package.json +1 -1
|
@@ -14,33 +14,33 @@ export function formatRunTypeLabel(runType) {
|
|
|
14
14
|
function implementationPlan() {
|
|
15
15
|
return [
|
|
16
16
|
{ content: "Prepare workspace", status: "pending" },
|
|
17
|
-
{ content: "
|
|
18
|
-
{ content: "
|
|
19
|
-
{ content: "
|
|
17
|
+
{ content: "Implementing", status: "pending" },
|
|
18
|
+
{ content: "Awaiting verification", status: "pending" },
|
|
19
|
+
{ content: "Merge", status: "pending" },
|
|
20
20
|
];
|
|
21
21
|
}
|
|
22
22
|
function reviewFixPlan() {
|
|
23
23
|
return [
|
|
24
24
|
{ content: "Prepare workspace", status: "completed" },
|
|
25
|
-
{ content: "
|
|
26
|
-
{ content: "
|
|
27
|
-
{ content: "
|
|
25
|
+
{ content: "Addressing review feedback", status: "pending" },
|
|
26
|
+
{ content: "Awaiting re-verification", status: "pending" },
|
|
27
|
+
{ content: "Merge", status: "pending" },
|
|
28
28
|
];
|
|
29
29
|
}
|
|
30
30
|
function ciRepairPlan(attempt) {
|
|
31
31
|
return [
|
|
32
32
|
{ content: "Prepare workspace", status: "completed" },
|
|
33
|
-
{ content: "
|
|
34
|
-
{ content: `
|
|
35
|
-
{ content: "
|
|
33
|
+
{ content: "Implementing", status: "completed" },
|
|
34
|
+
{ content: `Repairing checks (${attemptLabel(attempt)})`, status: "pending" },
|
|
35
|
+
{ content: "Merge", status: "pending" },
|
|
36
36
|
];
|
|
37
37
|
}
|
|
38
38
|
function queueRepairPlan(attempt) {
|
|
39
39
|
return [
|
|
40
40
|
{ content: "Prepare workspace", status: "completed" },
|
|
41
|
-
{ content: "
|
|
42
|
-
{ content: "
|
|
43
|
-
{ content: `
|
|
41
|
+
{ content: "Implementing", status: "completed" },
|
|
42
|
+
{ content: "Verification passed", status: "completed" },
|
|
43
|
+
{ content: `Repairing merge (${attemptLabel(attempt)})`, status: "pending" },
|
|
44
44
|
];
|
|
45
45
|
}
|
|
46
46
|
function awaitingInputPlan() {
|
|
@@ -101,9 +101,9 @@ export function buildAgentSessionPlan(params) {
|
|
|
101
101
|
case "awaiting_queue":
|
|
102
102
|
return setStatuses([
|
|
103
103
|
{ content: "Prepare workspace", status: "completed" },
|
|
104
|
-
{ content: "
|
|
105
|
-
{ content: "
|
|
106
|
-
{ content: "
|
|
104
|
+
{ content: "Implementing", status: "completed" },
|
|
105
|
+
{ content: "Verification passed", status: "completed" },
|
|
106
|
+
{ content: "Awaiting merge", status: "inProgress" },
|
|
107
107
|
], ["completed", "completed", "completed", "inProgress"]);
|
|
108
108
|
case "repairing_queue":
|
|
109
109
|
return setStatuses(queueRepairPlan(params.queueRepairAttempts ?? 1), ["completed", "completed", "completed", "inProgress"]);
|
|
@@ -116,8 +116,8 @@ export function buildAgentSessionPlan(params) {
|
|
|
116
116
|
case "done":
|
|
117
117
|
return setStatuses([
|
|
118
118
|
{ content: "Prepare workspace", status: "completed" },
|
|
119
|
-
{ content: "
|
|
120
|
-
{ content: "
|
|
119
|
+
{ content: "Implementing", status: "completed" },
|
|
120
|
+
{ content: "Verification passed", status: "completed" },
|
|
121
121
|
{ content: "Merged", status: "completed" },
|
|
122
122
|
], ["completed", "completed", "completed", "completed"]);
|
|
123
123
|
}
|
package/dist/build-info.json
CHANGED
package/dist/cli/watch/App.js
CHANGED
|
@@ -4,8 +4,10 @@ import { Box, useApp, useInput } from "ink";
|
|
|
4
4
|
import { watchReducer, initialWatchState, filterIssues } from "./watch-state.js";
|
|
5
5
|
import { useWatchStream } from "./use-watch-stream.js";
|
|
6
6
|
import { useDetailStream } from "./use-detail-stream.js";
|
|
7
|
+
import { useFeedStream } from "./use-feed-stream.js";
|
|
7
8
|
import { IssueListView } from "./IssueListView.js";
|
|
8
9
|
import { IssueDetailView } from "./IssueDetailView.js";
|
|
10
|
+
import { FeedView } from "./FeedView.js";
|
|
9
11
|
async function postRetry(baseUrl, issueKey, bearerToken) {
|
|
10
12
|
const headers = { "content-type": "application/json" };
|
|
11
13
|
if (bearerToken)
|
|
@@ -25,6 +27,7 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
25
27
|
const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
|
|
26
28
|
useWatchStream({ baseUrl, bearerToken, dispatch });
|
|
27
29
|
useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
|
|
30
|
+
useFeedStream({ baseUrl, bearerToken, active: state.view === "feed", dispatch });
|
|
28
31
|
const handleRetry = useCallback(() => {
|
|
29
32
|
if (state.activeDetailKey) {
|
|
30
33
|
void postRetry(baseUrl, state.activeDetailKey, bearerToken);
|
|
@@ -51,6 +54,9 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
51
54
|
else if (key.tab) {
|
|
52
55
|
dispatch({ type: "cycle-filter" });
|
|
53
56
|
}
|
|
57
|
+
else if (input === "F" || input === "f") {
|
|
58
|
+
dispatch({ type: "enter-feed" });
|
|
59
|
+
}
|
|
54
60
|
}
|
|
55
61
|
else if (state.view === "detail") {
|
|
56
62
|
if (key.escape || key.backspace || key.delete) {
|
|
@@ -62,7 +68,18 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
62
68
|
else if (input === "r") {
|
|
63
69
|
handleRetry();
|
|
64
70
|
}
|
|
71
|
+
else if (input === "j" || key.downArrow) {
|
|
72
|
+
dispatch({ type: "detail-navigate", direction: "next", filtered });
|
|
73
|
+
}
|
|
74
|
+
else if (input === "k" || key.upArrow) {
|
|
75
|
+
dispatch({ type: "detail-navigate", direction: "prev", filtered });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else if (state.view === "feed") {
|
|
79
|
+
if (key.escape || key.backspace || key.delete) {
|
|
80
|
+
dispatch({ type: "exit-feed" });
|
|
81
|
+
}
|
|
65
82
|
}
|
|
66
83
|
});
|
|
67
|
-
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan })) }));
|
|
84
|
+
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, allIssues: filtered, activeDetailKey: state.activeDetailKey })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
|
|
68
85
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { HelpBar } from "./HelpBar.js";
|
|
4
|
+
const TAIL_SIZE = 30;
|
|
5
|
+
const LEVEL_COLORS = {
|
|
6
|
+
info: "white",
|
|
7
|
+
warn: "yellow",
|
|
8
|
+
error: "red",
|
|
9
|
+
};
|
|
10
|
+
const KIND_COLORS = {
|
|
11
|
+
stage: "cyan",
|
|
12
|
+
turn: "yellow",
|
|
13
|
+
github: "green",
|
|
14
|
+
webhook: "blue",
|
|
15
|
+
agent: "magenta",
|
|
16
|
+
service: "white",
|
|
17
|
+
workflow: "cyan",
|
|
18
|
+
linear: "blue",
|
|
19
|
+
};
|
|
20
|
+
function formatTime(iso) {
|
|
21
|
+
return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
|
|
22
|
+
}
|
|
23
|
+
function FeedEventRow({ event }) {
|
|
24
|
+
const kindColor = KIND_COLORS[event.kind] ?? "white";
|
|
25
|
+
const levelColor = LEVEL_COLORS[event.level] ?? "white";
|
|
26
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(event.at) }), _jsx(Text, { color: kindColor, children: event.kind.padEnd(10) }), event.issueKey && _jsx(Text, { bold: true, children: event.issueKey.padEnd(10) }), event.stage && _jsx(Text, { color: "cyan", children: event.stage.padEnd(16) }), _jsx(Text, { color: levelColor, children: event.summary })] }));
|
|
27
|
+
}
|
|
28
|
+
export function FeedView({ events, connected }) {
|
|
29
|
+
const visible = events.length > TAIL_SIZE ? events.slice(-TAIL_SIZE) : events;
|
|
30
|
+
const skipped = events.length - visible.length;
|
|
31
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "Operator Feed" }), _jsx(Text, { color: connected ? "green" : "red", children: connected ? "\u25cf connected" : "\u25cb disconnected" })] }), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No feed events yet." })) : (_jsxs(Box, { flexDirection: "column", children: [skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier events"] }), visible.map((event) => (_jsx(FeedEventRow, { event: event }, event.id)))] })), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), _jsx(HelpBar, { view: "feed" })] }));
|
|
32
|
+
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
const HELP_TEXT = {
|
|
4
|
+
list: "j/k: navigate Enter: detail F: feed Tab: filter q: quit",
|
|
5
|
+
detail: "",
|
|
6
|
+
feed: "Esc: list q: quit",
|
|
7
|
+
};
|
|
3
8
|
export function HelpBar({ view, follow }) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
const text = view === "detail"
|
|
10
|
+
? `j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} r: retry q: quit`
|
|
11
|
+
: HELP_TEXT[view];
|
|
12
|
+
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
|
|
7
13
|
}
|
|
@@ -35,10 +35,76 @@ function planStepColor(status) {
|
|
|
35
35
|
return "yellow";
|
|
36
36
|
return "white";
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
// ─── Compact Issue Sidebar (#4 split-pane) ───────────────────────
|
|
39
|
+
const SIDEBAR_STATE_COLORS = {
|
|
40
|
+
delegated: "blue", preparing: "blue",
|
|
41
|
+
implementing: "yellow", awaiting_input: "yellow",
|
|
42
|
+
pr_open: "cyan", awaiting_review: "cyan",
|
|
43
|
+
changes_requested: "magenta", repairing_ci: "magenta", repairing_queue: "magenta",
|
|
44
|
+
awaiting_queue: "green", done: "green",
|
|
45
|
+
failed: "red", escalated: "red",
|
|
46
|
+
};
|
|
47
|
+
function CompactSidebar({ issues, activeKey }) {
|
|
48
|
+
return (_jsxs(Box, { flexDirection: "column", width: 28, borderStyle: "single", borderColor: "gray", paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "Issues" }), issues.map((issue) => {
|
|
49
|
+
const key = issue.issueKey ?? issue.projectId;
|
|
50
|
+
const isCurrent = key === activeKey;
|
|
51
|
+
const stateColor = SIDEBAR_STATE_COLORS[issue.factoryState] ?? "white";
|
|
52
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isCurrent ? "blueBright" : "white", bold: isCurrent, children: isCurrent ? "\u25b8" : " " }), _jsx(Text, { bold: isCurrent, children: key.padEnd(10) }), _jsx(Text, { color: stateColor, children: issue.factoryState.slice(0, 12) })] }, key));
|
|
53
|
+
})] }));
|
|
54
|
+
}
|
|
55
|
+
// ─── Issue Context Panel (#5) ────────────────────────────────────
|
|
56
|
+
const PRIORITY_LABELS = {
|
|
57
|
+
0: { label: "none", color: "" },
|
|
58
|
+
1: { label: "urgent", color: "red" },
|
|
59
|
+
2: { label: "high", color: "yellow" },
|
|
60
|
+
3: { label: "medium", color: "cyan" },
|
|
61
|
+
4: { label: "low", color: "" },
|
|
62
|
+
};
|
|
63
|
+
function ContextPanel({ issue, ctx }) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (ctx.priority != null && ctx.priority > 0) {
|
|
66
|
+
const p = PRIORITY_LABELS[ctx.priority] ?? { label: String(ctx.priority), color: "" };
|
|
67
|
+
parts.push({ label: "priority", value: p.label, color: p.color });
|
|
68
|
+
}
|
|
69
|
+
if (ctx.estimate != null) {
|
|
70
|
+
parts.push({ label: "estimate", value: String(ctx.estimate), color: "" });
|
|
71
|
+
}
|
|
72
|
+
if (ctx.currentLinearState) {
|
|
73
|
+
parts.push({ label: "linear", value: ctx.currentLinearState, color: "" });
|
|
74
|
+
}
|
|
75
|
+
if (issue.prNumber) {
|
|
76
|
+
const prInfo = `#${issue.prNumber}${issue.prReviewState === "approved" ? " \u2713" : issue.prReviewState === "changes_requested" ? " \u2717" : ""}${issue.prCheckStatus ? ` ci:${issue.prCheckStatus}` : ""}`;
|
|
77
|
+
const prColor = issue.prReviewState === "approved" ? "green" : issue.prReviewState === "changes_requested" ? "red" : "";
|
|
78
|
+
parts.push({ label: "pr", value: prInfo, color: prColor });
|
|
79
|
+
}
|
|
80
|
+
if (ctx.runCount > 0) {
|
|
81
|
+
parts.push({ label: "runs", value: String(ctx.runCount), color: "" });
|
|
82
|
+
}
|
|
83
|
+
const retries = [
|
|
84
|
+
ctx.ciRepairAttempts > 0 ? `ci:${ctx.ciRepairAttempts}` : "",
|
|
85
|
+
ctx.queueRepairAttempts > 0 ? `queue:${ctx.queueRepairAttempts}` : "",
|
|
86
|
+
ctx.reviewFixAttempts > 0 ? `review:${ctx.reviewFixAttempts}` : "",
|
|
87
|
+
].filter(Boolean).join(" ");
|
|
88
|
+
if (retries) {
|
|
89
|
+
parts.push({ label: "retries", value: retries, color: "yellow" });
|
|
90
|
+
}
|
|
91
|
+
if (ctx.branchName) {
|
|
92
|
+
parts.push({ label: "branch", value: ctx.branchName, color: "" });
|
|
93
|
+
}
|
|
94
|
+
const hasDescription = Boolean(ctx.description);
|
|
95
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { gap: 2, flexWrap: "wrap", children: parts.map((p) => (_jsxs(Text, { dimColor: true, children: [p.label, ": ", p.color ? _jsx(Text, { color: p.color, children: p.value }) : _jsx(Text, { dimColor: true, children: p.value })] }, p.label))) }), hasDescription && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [ctx.description.slice(0, 200), ctx.description.length > 200 ? "\u2026" : ""] }))] }));
|
|
96
|
+
}
|
|
97
|
+
// ─── Detail Panel (right side of split) ──────────────────────────
|
|
98
|
+
function DetailPanel({ issue, timeline, follow, activeRunStartedAt, tokenUsage, diffSummary, plan, issueContext, }) {
|
|
39
99
|
if (!issue) {
|
|
40
|
-
return
|
|
100
|
+
return _jsx(Text, { color: "red", children: "Issue not found." });
|
|
41
101
|
}
|
|
42
102
|
const key = issue.issueKey ?? issue.projectId;
|
|
43
|
-
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.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), _jsxs(Box, { gap: 2, children: [tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(tokenUsage.inputTokens), " in / ", formatTokens(tokenUsage.outputTokens), " out"] })), diffSummary && diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", diffSummary.filesChanged, " file", diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", diffSummary.linesAdded, " -", diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", children: 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(Text, { dimColor: true, children: "
|
|
103
|
+
return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), _jsxs(Box, { gap: 2, children: [tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(tokenUsage.inputTokens), " in / ", formatTokens(tokenUsage.outputTokens), " out"] })), diffSummary && diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", diffSummary.filesChanged, " file", diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", diffSummary.linesAdded, " -", diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), issueContext && _jsx(ContextPanel, { issue: issue, ctx: issueContext }), plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", children: 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(Text, { dimColor: true, children: "\u2500".repeat(60) }), _jsx(Timeline, { entries: timeline, follow: follow })] }));
|
|
104
|
+
}
|
|
105
|
+
// ─── Main Detail View (split layout) ─────────────────────────────
|
|
106
|
+
export function IssueDetailView(props) {
|
|
107
|
+
const { allIssues, activeDetailKey, follow, ...detailProps } = props;
|
|
108
|
+
const showSidebar = allIssues.length > 1;
|
|
109
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [showSidebar && _jsx(CompactSidebar, { issues: allIssues, activeKey: activeDetailKey }), _jsx(DetailPanel, { ...detailProps, follow: follow })] }), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
|
|
44
110
|
}
|
|
@@ -3,6 +3,6 @@ import { Box, Text } from "ink";
|
|
|
3
3
|
import { IssueRow } from "./IssueRow.js";
|
|
4
4
|
import { StatusBar } from "./StatusBar.js";
|
|
5
5
|
import { HelpBar } from "./HelpBar.js";
|
|
6
|
-
export function IssueListView({ issues, selectedIndex, connected, filter, totalCount }) {
|
|
7
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected }), _jsx(Text, { dimColor: true, children: "
|
|
6
|
+
export function IssueListView({ issues, allIssues, selectedIndex, connected, filter, totalCount }) {
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, allIssues: allIssues }), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsx(Box, { flexDirection: "column", children: issues.map((issue, index) => (_jsx(IssueRow, { issue: issue, selected: index === selectedIndex }, issue.issueKey ?? `${issue.projectId}-${index}`))) })), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), _jsx(HelpBar, { view: "list" })] }));
|
|
8
8
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { computeAggregates } from "./watch-state.js";
|
|
3
4
|
const FILTER_LABELS = {
|
|
4
5
|
"all": "all",
|
|
5
6
|
"active": "active",
|
|
6
7
|
"non-done": "in progress",
|
|
7
8
|
};
|
|
8
|
-
export function StatusBar({ issues, totalCount, filter, connected }) {
|
|
9
|
+
export function StatusBar({ issues, totalCount, filter, connected, allIssues }) {
|
|
9
10
|
const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
|
|
10
|
-
|
|
11
|
+
const agg = computeAggregates(allIssues);
|
|
12
|
+
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: "yellow", children: [agg.active, " active"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] })] }), _jsx(Text, { color: connected ? "green" : "red", children: connected ? "\u25cf connected" : "\u25cb disconnected" })] }));
|
|
11
13
|
}
|
|
@@ -28,15 +28,24 @@ function FeedRow({ entry }) {
|
|
|
28
28
|
const statusLabel = feed.status ?? feed.feedKind;
|
|
29
29
|
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: "cyan", children: statusLabel.padEnd(16) }), _jsx(Text, { children: feed.summary })] }));
|
|
30
30
|
}
|
|
31
|
+
const RUN_TYPE_LABELS = {
|
|
32
|
+
implementation: "implementing",
|
|
33
|
+
ci_repair: "repairing checks",
|
|
34
|
+
review_fix: "addressing feedback",
|
|
35
|
+
queue_repair: "repairing merge",
|
|
36
|
+
};
|
|
37
|
+
function runLabel(runType) {
|
|
38
|
+
return RUN_TYPE_LABELS[runType] ?? runType;
|
|
39
|
+
}
|
|
31
40
|
function RunStartRow({ entry }) {
|
|
32
41
|
const run = entry.run;
|
|
33
|
-
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: "yellow", children: run.runType.padEnd(
|
|
42
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: "yellow", children: runLabel(run.runType).padEnd(20) }), _jsx(Text, { bold: true, children: "started" })] }));
|
|
34
43
|
}
|
|
35
44
|
function RunEndRow({ entry }) {
|
|
36
45
|
const run = entry.run;
|
|
37
46
|
const color = run.status === "completed" ? "green" : "red";
|
|
38
47
|
const duration = run.endedAt ? formatDuration(run.startedAt, run.endedAt) : "";
|
|
39
|
-
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: color, children: run.runType.padEnd(
|
|
48
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: color, children: runLabel(run.runType).padEnd(20) }), _jsx(Text, { bold: true, color: color, children: run.status }), duration ? _jsxs(Text, { dimColor: true, children: ["(", duration, ")"] }) : null] }));
|
|
40
49
|
}
|
|
41
50
|
function ItemRow({ entry }) {
|
|
42
51
|
const item = entry.item;
|
|
@@ -38,12 +38,31 @@ async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
|
|
|
38
38
|
threadId: r.threadId,
|
|
39
39
|
...(r.report ? { report: r.report } : {}),
|
|
40
40
|
}));
|
|
41
|
+
let issueContext = null;
|
|
42
|
+
if (data.issue) {
|
|
43
|
+
const i = data.issue;
|
|
44
|
+
issueContext = {
|
|
45
|
+
description: typeof i.description === "string" ? i.description : undefined,
|
|
46
|
+
currentLinearState: typeof i.currentLinearState === "string" ? i.currentLinearState : undefined,
|
|
47
|
+
issueUrl: typeof i.issueUrl === "string" ? i.issueUrl : undefined,
|
|
48
|
+
worktreePath: typeof i.worktreePath === "string" ? i.worktreePath : undefined,
|
|
49
|
+
branchName: typeof i.branchName === "string" ? i.branchName : undefined,
|
|
50
|
+
prUrl: typeof i.prUrl === "string" ? i.prUrl : undefined,
|
|
51
|
+
priority: typeof i.priority === "number" ? i.priority : undefined,
|
|
52
|
+
estimate: typeof i.estimate === "number" ? i.estimate : undefined,
|
|
53
|
+
ciRepairAttempts: typeof i.ciRepairAttempts === "number" ? i.ciRepairAttempts : 0,
|
|
54
|
+
queueRepairAttempts: typeof i.queueRepairAttempts === "number" ? i.queueRepairAttempts : 0,
|
|
55
|
+
reviewFixAttempts: typeof i.reviewFixAttempts === "number" ? i.reviewFixAttempts : 0,
|
|
56
|
+
runCount: runs.length,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
41
59
|
dispatch({
|
|
42
60
|
type: "timeline-rehydrate",
|
|
43
61
|
runs,
|
|
44
62
|
feedEvents: data.feedEvents ?? [],
|
|
45
63
|
liveThread: data.liveThread ?? null,
|
|
46
64
|
activeRunId: data.activeRunId ?? null,
|
|
65
|
+
issueContext,
|
|
47
66
|
});
|
|
48
67
|
}
|
|
49
68
|
catch {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
export function useFeedStream(options) {
|
|
3
|
+
const optionsRef = useRef(options);
|
|
4
|
+
optionsRef.current = options;
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
if (!options.active)
|
|
7
|
+
return;
|
|
8
|
+
const abortController = new AbortController();
|
|
9
|
+
const { baseUrl, bearerToken, dispatch } = optionsRef.current;
|
|
10
|
+
void (async () => {
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL("/api/feed", baseUrl);
|
|
13
|
+
url.searchParams.set("follow", "1");
|
|
14
|
+
url.searchParams.set("limit", "100");
|
|
15
|
+
const headers = { accept: "text/event-stream" };
|
|
16
|
+
if (bearerToken)
|
|
17
|
+
headers.authorization = `Bearer ${bearerToken}`;
|
|
18
|
+
const response = await fetch(url, { headers, signal: abortController.signal });
|
|
19
|
+
if (!response.ok || !response.body)
|
|
20
|
+
return;
|
|
21
|
+
const reader = response.body.getReader();
|
|
22
|
+
const decoder = new TextDecoder();
|
|
23
|
+
let buffer = "";
|
|
24
|
+
let eventType = "";
|
|
25
|
+
let dataLines = [];
|
|
26
|
+
let initialBatch = [];
|
|
27
|
+
let snapshotSent = false;
|
|
28
|
+
while (true) {
|
|
29
|
+
const { done, value } = await reader.read();
|
|
30
|
+
if (done)
|
|
31
|
+
break;
|
|
32
|
+
buffer += decoder.decode(value, { stream: true });
|
|
33
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
34
|
+
while (newlineIndex !== -1) {
|
|
35
|
+
const rawLine = buffer.slice(0, newlineIndex);
|
|
36
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
37
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
38
|
+
if (!line) {
|
|
39
|
+
if (dataLines.length > 0 && eventType === "feed") {
|
|
40
|
+
try {
|
|
41
|
+
const event = JSON.parse(dataLines.join("\n"));
|
|
42
|
+
if (!snapshotSent) {
|
|
43
|
+
initialBatch.push(event);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
dispatch({ type: "feed-new-event", event });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch { /* ignore parse errors */ }
|
|
50
|
+
dataLines = [];
|
|
51
|
+
eventType = "";
|
|
52
|
+
}
|
|
53
|
+
// After processing a batch of initial events, flush snapshot
|
|
54
|
+
if (!snapshotSent && initialBatch.length > 0) {
|
|
55
|
+
// Use a microtask to batch initial events
|
|
56
|
+
const batch = initialBatch;
|
|
57
|
+
initialBatch = [];
|
|
58
|
+
snapshotSent = true;
|
|
59
|
+
dispatch({ type: "feed-snapshot", events: batch });
|
|
60
|
+
}
|
|
61
|
+
newlineIndex = buffer.indexOf("\n");
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (line.startsWith(":")) {
|
|
65
|
+
// Keepalive or comment - flush initial batch if pending
|
|
66
|
+
if (!snapshotSent && initialBatch.length > 0) {
|
|
67
|
+
snapshotSent = true;
|
|
68
|
+
dispatch({ type: "feed-snapshot", events: initialBatch });
|
|
69
|
+
initialBatch = [];
|
|
70
|
+
}
|
|
71
|
+
newlineIndex = buffer.indexOf("\n");
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (line.startsWith("event:")) {
|
|
75
|
+
eventType = line.slice(6).trim();
|
|
76
|
+
}
|
|
77
|
+
else if (line.startsWith("data:")) {
|
|
78
|
+
dataLines.push(line.slice(5).trimStart());
|
|
79
|
+
}
|
|
80
|
+
newlineIndex = buffer.indexOf("\n");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Stream ended or aborted
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
return () => {
|
|
89
|
+
abortController.abort();
|
|
90
|
+
};
|
|
91
|
+
}, [options.active]);
|
|
92
|
+
}
|
|
@@ -6,6 +6,7 @@ const DETAIL_INITIAL = {
|
|
|
6
6
|
tokenUsage: null,
|
|
7
7
|
diffSummary: null,
|
|
8
8
|
plan: null,
|
|
9
|
+
issueContext: null,
|
|
9
10
|
};
|
|
10
11
|
export const initialWatchState = {
|
|
11
12
|
connected: false,
|
|
@@ -16,6 +17,7 @@ export const initialWatchState = {
|
|
|
16
17
|
filter: "non-done",
|
|
17
18
|
follow: true,
|
|
18
19
|
...DETAIL_INITIAL,
|
|
20
|
+
feedEvents: [],
|
|
19
21
|
};
|
|
20
22
|
const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
|
|
21
23
|
export function filterIssues(issues, filter) {
|
|
@@ -28,6 +30,22 @@ export function filterIssues(issues, filter) {
|
|
|
28
30
|
return issues.filter((i) => !TERMINAL_FACTORY_STATES.has(i.factoryState));
|
|
29
31
|
}
|
|
30
32
|
}
|
|
33
|
+
const DONE_STATES = new Set(["done"]);
|
|
34
|
+
const FAILED_STATES = new Set(["failed", "escalated"]);
|
|
35
|
+
export function computeAggregates(issues) {
|
|
36
|
+
let active = 0;
|
|
37
|
+
let done = 0;
|
|
38
|
+
let failed = 0;
|
|
39
|
+
for (const issue of issues) {
|
|
40
|
+
if (issue.activeRunType)
|
|
41
|
+
active++;
|
|
42
|
+
if (DONE_STATES.has(issue.factoryState))
|
|
43
|
+
done++;
|
|
44
|
+
if (FAILED_STATES.has(issue.factoryState))
|
|
45
|
+
failed++;
|
|
46
|
+
}
|
|
47
|
+
return { active, done, failed, total: issues.length };
|
|
48
|
+
}
|
|
31
49
|
function nextFilter(filter) {
|
|
32
50
|
switch (filter) {
|
|
33
51
|
case "non-done": return "active";
|
|
@@ -59,6 +77,19 @@ export function watchReducer(state, action) {
|
|
|
59
77
|
return { ...state, view: "detail", activeDetailKey: action.issueKey, ...DETAIL_INITIAL };
|
|
60
78
|
case "exit-detail":
|
|
61
79
|
return { ...state, view: "list", activeDetailKey: null, ...DETAIL_INITIAL };
|
|
80
|
+
case "detail-navigate": {
|
|
81
|
+
const list = action.filtered;
|
|
82
|
+
if (list.length === 0)
|
|
83
|
+
return state;
|
|
84
|
+
const curIdx = list.findIndex((i) => i.issueKey === state.activeDetailKey);
|
|
85
|
+
const nextIdx = action.direction === "next"
|
|
86
|
+
? (curIdx + 1) % list.length
|
|
87
|
+
: (curIdx - 1 + list.length) % list.length;
|
|
88
|
+
const nextIssue = list[nextIdx];
|
|
89
|
+
if (!nextIssue?.issueKey || nextIssue.issueKey === state.activeDetailKey)
|
|
90
|
+
return state;
|
|
91
|
+
return { ...state, activeDetailKey: nextIssue.issueKey, selectedIndex: nextIdx, ...DETAIL_INITIAL };
|
|
92
|
+
}
|
|
62
93
|
case "timeline-rehydrate": {
|
|
63
94
|
const timeline = buildTimelineFromRehydration(action.runs, action.feedEvents, action.liveThread, action.activeRunId);
|
|
64
95
|
const activeRun = action.runs.find((r) => r.id === action.activeRunId);
|
|
@@ -67,6 +98,7 @@ export function watchReducer(state, action) {
|
|
|
67
98
|
timeline,
|
|
68
99
|
activeRunId: action.activeRunId,
|
|
69
100
|
activeRunStartedAt: activeRun?.startedAt ?? null,
|
|
101
|
+
issueContext: action.issueContext,
|
|
70
102
|
};
|
|
71
103
|
}
|
|
72
104
|
case "codex-notification":
|
|
@@ -75,6 +107,14 @@ export function watchReducer(state, action) {
|
|
|
75
107
|
return { ...state, filter: nextFilter(state.filter), selectedIndex: 0 };
|
|
76
108
|
case "toggle-follow":
|
|
77
109
|
return { ...state, follow: !state.follow };
|
|
110
|
+
case "enter-feed":
|
|
111
|
+
return { ...state, view: "feed", activeDetailKey: null, ...DETAIL_INITIAL };
|
|
112
|
+
case "exit-feed":
|
|
113
|
+
return { ...state, view: "list" };
|
|
114
|
+
case "feed-snapshot":
|
|
115
|
+
return { ...state, feedEvents: action.events };
|
|
116
|
+
case "feed-new-event":
|
|
117
|
+
return { ...state, feedEvents: [...state.feedEvents, action.event] };
|
|
78
118
|
}
|
|
79
119
|
}
|
|
80
120
|
// ─── Feed Event → Issue List + Timeline ───────────────────────────
|
package/dist/config.js
CHANGED
|
@@ -30,6 +30,10 @@ const projectSchema = z.object({
|
|
|
30
30
|
allow_labels: z.array(z.string().min(1)).default([]),
|
|
31
31
|
trigger_events: z.array(z.string().min(1)).min(1).optional(),
|
|
32
32
|
branch_prefix: z.string().min(1).optional(),
|
|
33
|
+
/** Check names that are review gates (AI Review, quality analysis). Default: code class. */
|
|
34
|
+
review_checks: z.array(z.string().min(1)).default([]),
|
|
35
|
+
/** Check names that are policy gates (conventional title, release policy). Default: code class. */
|
|
36
|
+
gate_checks: z.array(z.string().min(1)).default([]),
|
|
33
37
|
github: z.object({
|
|
34
38
|
webhook_secret: z.string().min(1).optional(),
|
|
35
39
|
repo_full_name: z.string().min(1).optional(),
|
|
@@ -394,6 +398,8 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
394
398
|
issueKeyPrefixes: project.issue_key_prefixes,
|
|
395
399
|
linearTeamIds: project.linear_team_ids,
|
|
396
400
|
allowLabels: project.allow_labels,
|
|
401
|
+
reviewChecks: project.review_checks,
|
|
402
|
+
gateChecks: project.gate_checks,
|
|
397
403
|
triggerEvents: normalizeTriggerEvents(parsed.linear.oauth.actor, repoSettings?.trigger_events ??
|
|
398
404
|
project.trigger_events),
|
|
399
405
|
branchPrefix: repoSettings?.branch_prefix ?? project.branch_prefix ?? defaultBranchPrefix(project.id),
|
package/dist/db/migrations.js
CHANGED
|
@@ -137,6 +137,10 @@ export function runPatchRelayMigrations(connection) {
|
|
|
137
137
|
addColumnIfMissing(connection, "issues", "review_fix_attempts", "INTEGER NOT NULL DEFAULT 0");
|
|
138
138
|
// Collapse awaiting_review into pr_open (state normalization)
|
|
139
139
|
connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
|
|
140
|
+
// Add Linear issue description, priority, estimate
|
|
141
|
+
addColumnIfMissing(connection, "issues", "description", "TEXT");
|
|
142
|
+
addColumnIfMissing(connection, "issues", "priority", "INTEGER");
|
|
143
|
+
addColumnIfMissing(connection, "issues", "estimate", "REAL");
|
|
140
144
|
}
|
|
141
145
|
function addColumnIfMissing(connection, table, column, definition) {
|
|
142
146
|
const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
|
package/dist/db.js
CHANGED
|
@@ -81,10 +81,22 @@ export class PatchRelayDatabase {
|
|
|
81
81
|
sets.push("title = COALESCE(@title, title)");
|
|
82
82
|
values.title = params.title;
|
|
83
83
|
}
|
|
84
|
+
if (params.description !== undefined) {
|
|
85
|
+
sets.push("description = COALESCE(@description, description)");
|
|
86
|
+
values.description = params.description;
|
|
87
|
+
}
|
|
84
88
|
if (params.url !== undefined) {
|
|
85
89
|
sets.push("url = COALESCE(@url, url)");
|
|
86
90
|
values.url = params.url;
|
|
87
91
|
}
|
|
92
|
+
if (params.priority !== undefined) {
|
|
93
|
+
sets.push("priority = @priority");
|
|
94
|
+
values.priority = params.priority;
|
|
95
|
+
}
|
|
96
|
+
if (params.estimate !== undefined) {
|
|
97
|
+
sets.push("estimate = @estimate");
|
|
98
|
+
values.estimate = params.estimate;
|
|
99
|
+
}
|
|
88
100
|
if (params.currentLinearState !== undefined) {
|
|
89
101
|
sets.push("current_linear_state = COALESCE(@currentLinearState, current_linear_state)");
|
|
90
102
|
values.currentLinearState = params.currentLinearState;
|
|
@@ -166,13 +178,15 @@ export class PatchRelayDatabase {
|
|
|
166
178
|
else {
|
|
167
179
|
this.connection.prepare(`
|
|
168
180
|
INSERT INTO issues (
|
|
169
|
-
project_id, linear_issue_id, issue_key, title, url,
|
|
181
|
+
project_id, linear_issue_id, issue_key, title, description, url,
|
|
182
|
+
priority, estimate,
|
|
170
183
|
current_linear_state, factory_state, pending_run_type, pending_run_context_json,
|
|
171
184
|
branch_name, worktree_path, thread_id, active_run_id,
|
|
172
185
|
agent_session_id,
|
|
173
186
|
updated_at
|
|
174
187
|
) VALUES (
|
|
175
|
-
@projectId, @linearIssueId, @issueKey, @title, @url,
|
|
188
|
+
@projectId, @linearIssueId, @issueKey, @title, @description, @url,
|
|
189
|
+
@priority, @estimate,
|
|
176
190
|
@currentLinearState, @factoryState, @pendingRunType, @pendingRunContextJson,
|
|
177
191
|
@branchName, @worktreePath, @threadId, @activeRunId,
|
|
178
192
|
@agentSessionId,
|
|
@@ -183,7 +197,10 @@ export class PatchRelayDatabase {
|
|
|
183
197
|
linearIssueId: params.linearIssueId,
|
|
184
198
|
issueKey: params.issueKey ?? null,
|
|
185
199
|
title: params.title ?? null,
|
|
200
|
+
description: params.description ?? null,
|
|
186
201
|
url: params.url ?? null,
|
|
202
|
+
priority: params.priority ?? null,
|
|
203
|
+
estimate: params.estimate ?? null,
|
|
187
204
|
currentLinearState: params.currentLinearState ?? null,
|
|
188
205
|
factoryState: params.factoryState ?? "delegated",
|
|
189
206
|
pendingRunType: params.pendingRunType ?? null,
|
|
@@ -229,9 +246,13 @@ export class PatchRelayDatabase {
|
|
|
229
246
|
* Issues idle in pr_open with no active run — candidates for state
|
|
230
247
|
* advancement based on stored PR metadata (missed GitHub webhooks).
|
|
231
248
|
*/
|
|
232
|
-
|
|
249
|
+
listIdleNonTerminalIssues() {
|
|
233
250
|
const rows = this.connection
|
|
234
|
-
.prepare(
|
|
251
|
+
.prepare(`SELECT * FROM issues
|
|
252
|
+
WHERE factory_state NOT IN ('done', 'escalated', 'failed', 'awaiting_input')
|
|
253
|
+
AND active_run_id IS NULL
|
|
254
|
+
AND pending_run_type IS NULL
|
|
255
|
+
AND pr_number IS NOT NULL`)
|
|
235
256
|
.all();
|
|
236
257
|
return rows.map(mapIssueRow);
|
|
237
258
|
}
|
|
@@ -375,7 +396,10 @@ function mapIssueRow(row) {
|
|
|
375
396
|
linearIssueId: String(row.linear_issue_id),
|
|
376
397
|
...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
|
|
377
398
|
...(row.title !== null ? { title: String(row.title) } : {}),
|
|
399
|
+
...(row.description !== null && row.description !== undefined ? { description: String(row.description) } : {}),
|
|
378
400
|
...(row.url !== null ? { url: String(row.url) } : {}),
|
|
401
|
+
...(row.priority !== null && row.priority !== undefined ? { priority: Number(row.priority) } : {}),
|
|
402
|
+
...(row.estimate !== null && row.estimate !== undefined ? { estimate: Number(row.estimate) } : {}),
|
|
379
403
|
...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
|
|
380
404
|
factoryState: String(row.factory_state ?? "delegated"),
|
|
381
405
|
...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
|
|
@@ -182,10 +182,11 @@ export class GitHubWebhookHandler {
|
|
|
182
182
|
detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
|
|
183
183
|
});
|
|
184
184
|
if (!isMetadataOnlyCheckEvent(event)) {
|
|
185
|
-
this.
|
|
185
|
+
const project = this.config.projects.find((p) => p.id === freshIssue.projectId);
|
|
186
|
+
this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
186
187
|
}
|
|
187
188
|
}
|
|
188
|
-
maybeEnqueueReactiveRun(issue, event) {
|
|
189
|
+
maybeEnqueueReactiveRun(issue, event, project) {
|
|
189
190
|
// Don't trigger if there's already an active run
|
|
190
191
|
if (issue.activeRunId !== undefined)
|
|
191
192
|
return;
|
|
@@ -197,6 +198,7 @@ export class GitHubWebhookHandler {
|
|
|
197
198
|
pendingRunContextJson: JSON.stringify({
|
|
198
199
|
checkName: event.checkName,
|
|
199
200
|
checkUrl: event.checkUrl,
|
|
201
|
+
checkClass: resolveCheckClass(event.checkName, project),
|
|
200
202
|
}),
|
|
201
203
|
});
|
|
202
204
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
@@ -281,3 +283,12 @@ export class GitHubWebhookHandler {
|
|
|
281
283
|
}
|
|
282
284
|
}
|
|
283
285
|
}
|
|
286
|
+
function resolveCheckClass(checkName, project) {
|
|
287
|
+
if (!checkName || !project)
|
|
288
|
+
return "code";
|
|
289
|
+
if (project.reviewChecks.some((name) => checkName.includes(name)))
|
|
290
|
+
return "review";
|
|
291
|
+
if (project.gateChecks.some((name) => checkName.includes(name)))
|
|
292
|
+
return "gate";
|
|
293
|
+
return "code";
|
|
294
|
+
}
|
|
@@ -79,7 +79,24 @@ export class IssueQueryService {
|
|
|
79
79
|
if (activeRun?.threadId) {
|
|
80
80
|
liveThread = await this.codex.readThread(activeRun.threadId, true).catch(() => undefined);
|
|
81
81
|
}
|
|
82
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
issue: {
|
|
84
|
+
...issue,
|
|
85
|
+
...(fullIssue?.description ? { description: fullIssue.description } : {}),
|
|
86
|
+
...(fullIssue?.branchName ? { branchName: fullIssue.branchName } : {}),
|
|
87
|
+
...(fullIssue?.worktreePath ? { worktreePath: fullIssue.worktreePath } : {}),
|
|
88
|
+
...(fullIssue?.prUrl ? { prUrl: fullIssue.prUrl } : {}),
|
|
89
|
+
...(fullIssue?.priority != null ? { priority: fullIssue.priority } : {}),
|
|
90
|
+
...(fullIssue?.estimate != null ? { estimate: fullIssue.estimate } : {}),
|
|
91
|
+
ciRepairAttempts: fullIssue?.ciRepairAttempts ?? 0,
|
|
92
|
+
queueRepairAttempts: fullIssue?.queueRepairAttempts ?? 0,
|
|
93
|
+
reviewFixAttempts: fullIssue?.reviewFixAttempts ?? 0,
|
|
94
|
+
},
|
|
95
|
+
runs,
|
|
96
|
+
feedEvents,
|
|
97
|
+
liveThread,
|
|
98
|
+
activeRunId,
|
|
99
|
+
};
|
|
83
100
|
}
|
|
84
101
|
async getActiveRunStatus(issueKey) {
|
|
85
102
|
return await this.runStatusProvider.getActiveRunStatus(issueKey);
|
package/dist/linear-client.js
CHANGED
|
@@ -14,7 +14,10 @@ export class LinearGraphqlClient {
|
|
|
14
14
|
id
|
|
15
15
|
identifier
|
|
16
16
|
title
|
|
17
|
+
description
|
|
17
18
|
url
|
|
19
|
+
priority
|
|
20
|
+
estimate
|
|
18
21
|
delegate {
|
|
19
22
|
id
|
|
20
23
|
name
|
|
@@ -68,7 +71,10 @@ export class LinearGraphqlClient {
|
|
|
68
71
|
id
|
|
69
72
|
identifier
|
|
70
73
|
title
|
|
74
|
+
description
|
|
71
75
|
url
|
|
76
|
+
priority
|
|
77
|
+
estimate
|
|
72
78
|
delegate {
|
|
73
79
|
id
|
|
74
80
|
name
|
|
@@ -205,7 +211,10 @@ export class LinearGraphqlClient {
|
|
|
205
211
|
id
|
|
206
212
|
identifier
|
|
207
213
|
title
|
|
214
|
+
description
|
|
208
215
|
url
|
|
216
|
+
priority
|
|
217
|
+
estimate
|
|
209
218
|
state {
|
|
210
219
|
id
|
|
211
220
|
name
|
|
@@ -293,7 +302,10 @@ export class LinearGraphqlClient {
|
|
|
293
302
|
id: issue.id,
|
|
294
303
|
...(issue.identifier ? { identifier: issue.identifier } : {}),
|
|
295
304
|
...(issue.title ? { title: issue.title } : {}),
|
|
305
|
+
...(issue.description ? { description: issue.description } : {}),
|
|
296
306
|
...(issue.url ? { url: issue.url } : {}),
|
|
307
|
+
...(issue.priority != null ? { priority: issue.priority } : {}),
|
|
308
|
+
...(issue.estimate != null ? { estimate: issue.estimate } : {}),
|
|
297
309
|
...(issue.state?.id ? { stateId: issue.state.id } : {}),
|
|
298
310
|
...(issue.state?.name ? { stateName: issue.state.name } : {}),
|
|
299
311
|
...(issue.team?.id ? { teamId: issue.team.id } : {}),
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -410,53 +410,70 @@ export class RunOrchestrator {
|
|
|
410
410
|
}
|
|
411
411
|
// Advance issues stuck in pr_open whose stored PR metadata already
|
|
412
412
|
// shows they should transition (e.g. approved PR, missed webhook).
|
|
413
|
-
await this.
|
|
413
|
+
await this.reconcileIdleIssues();
|
|
414
414
|
}
|
|
415
|
-
async
|
|
416
|
-
for (const issue of this.db.
|
|
415
|
+
async reconcileIdleIssues() {
|
|
416
|
+
for (const issue of this.db.listIdleNonTerminalIssues()) {
|
|
417
|
+
// PR already merged — advance to done regardless of current state
|
|
417
418
|
if (issue.prState === "merged") {
|
|
418
419
|
this.advanceIdleIssue(issue, "done");
|
|
419
420
|
continue;
|
|
420
421
|
}
|
|
421
|
-
|
|
422
|
+
// Review approved + checks not failed — advance to awaiting_queue
|
|
423
|
+
if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
|
|
422
424
|
this.advanceIdleIssue(issue, "awaiting_queue");
|
|
423
425
|
continue;
|
|
424
426
|
}
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
427
|
+
// Checks failed + idle (not already in a repair state) — enqueue ci_repair
|
|
428
|
+
if (issue.prCheckStatus === "failed" && issue.factoryState !== "repairing_ci") {
|
|
429
|
+
this.advanceIdleIssue(issue, "repairing_ci", "ci_repair");
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
// Awaiting queue with stale pending merge prep — re-enqueue
|
|
433
|
+
if (issue.factoryState === "awaiting_queue" && issue.pendingMergePrep) {
|
|
434
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
429
435
|
continue;
|
|
430
|
-
try {
|
|
431
|
-
const { stdout } = await execCommand("gh", [
|
|
432
|
-
"pr", "view", String(issue.prNumber),
|
|
433
|
-
"--repo", project.github.repoFullName,
|
|
434
|
-
"--json", "state,reviewDecision",
|
|
435
|
-
], { timeoutMs: 10_000 });
|
|
436
|
-
const pr = JSON.parse(stdout);
|
|
437
|
-
if (pr.state === "MERGED") {
|
|
438
|
-
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
439
|
-
this.advanceIdleIssue(issue, "done");
|
|
440
|
-
}
|
|
441
|
-
else if (pr.reviewDecision === "APPROVED") {
|
|
442
|
-
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
|
|
443
|
-
this.advanceIdleIssue(issue, "awaiting_queue");
|
|
444
|
-
}
|
|
445
436
|
}
|
|
446
|
-
|
|
447
|
-
|
|
437
|
+
// For pr_open issues with no review decision, check GitHub for stale metadata
|
|
438
|
+
if (issue.factoryState === "pr_open" && !issue.prReviewState) {
|
|
439
|
+
await this.reconcileFromGitHub(issue);
|
|
448
440
|
}
|
|
449
441
|
}
|
|
450
442
|
}
|
|
451
|
-
|
|
452
|
-
this.
|
|
443
|
+
async reconcileFromGitHub(issue) {
|
|
444
|
+
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
445
|
+
if (!project?.github?.repoFullName || !issue.prNumber)
|
|
446
|
+
return;
|
|
447
|
+
try {
|
|
448
|
+
const { stdout } = await execCommand("gh", [
|
|
449
|
+
"pr", "view", String(issue.prNumber),
|
|
450
|
+
"--repo", project.github.repoFullName,
|
|
451
|
+
"--json", "state,reviewDecision",
|
|
452
|
+
], { timeoutMs: 10_000 });
|
|
453
|
+
const pr = JSON.parse(stdout);
|
|
454
|
+
if (pr.state === "MERGED") {
|
|
455
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
456
|
+
this.advanceIdleIssue(issue, "done");
|
|
457
|
+
}
|
|
458
|
+
else if (pr.reviewDecision === "APPROVED") {
|
|
459
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
|
|
460
|
+
this.advanceIdleIssue(issue, "awaiting_queue");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
advanceIdleIssue(issue, newState, pendingRunType) {
|
|
468
|
+
this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType }, "Reconciliation: advancing idle issue");
|
|
453
469
|
this.db.upsertIssue({
|
|
454
470
|
projectId: issue.projectId,
|
|
455
471
|
linearIssueId: issue.linearIssueId,
|
|
456
472
|
factoryState: newState,
|
|
457
473
|
...(newState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
|
|
474
|
+
...(pendingRunType ? { pendingRunType: pendingRunType } : {}),
|
|
458
475
|
});
|
|
459
|
-
if (newState === "awaiting_queue") {
|
|
476
|
+
if (newState === "awaiting_queue" || pendingRunType) {
|
|
460
477
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
461
478
|
}
|
|
462
479
|
}
|
package/dist/webhook-handler.js
CHANGED
|
@@ -155,7 +155,10 @@ export class WebhookHandler {
|
|
|
155
155
|
linearIssueId: normalizedIssue.id,
|
|
156
156
|
...(normalizedIssue.identifier ? { issueKey: normalizedIssue.identifier } : {}),
|
|
157
157
|
...(normalizedIssue.title ? { title: normalizedIssue.title } : {}),
|
|
158
|
+
...(normalizedIssue.description ? { description: normalizedIssue.description } : {}),
|
|
158
159
|
...(normalizedIssue.url ? { url: normalizedIssue.url } : {}),
|
|
160
|
+
...(normalizedIssue.priority != null ? { priority: normalizedIssue.priority } : {}),
|
|
161
|
+
...(normalizedIssue.estimate != null ? { estimate: normalizedIssue.estimate } : {}),
|
|
159
162
|
...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
|
|
160
163
|
...(pendingRunType ? { pendingRunType, factoryState: "delegated" } : {}),
|
|
161
164
|
...((pendingRunType || existingIssue?.pendingRunType === "implementation") && pendingRunContextJson
|
package/dist/webhooks.js
CHANGED
|
@@ -207,10 +207,16 @@ function extractIssueMetadata(payload) {
|
|
|
207
207
|
const stateType = getString(stateRecord ?? {}, "type");
|
|
208
208
|
const delegateId = getString(issueRecord, "delegateId") ?? getString(delegateRecord ?? {}, "id");
|
|
209
209
|
const delegateName = getString(delegateRecord ?? {}, "name");
|
|
210
|
+
const description = getString(issueRecord, "description");
|
|
211
|
+
const rawPriority = issueRecord.priority;
|
|
212
|
+
const priority = typeof rawPriority === "number" ? rawPriority : undefined;
|
|
213
|
+
const rawEstimate = issueRecord.estimate;
|
|
214
|
+
const estimate = typeof rawEstimate === "number" ? rawEstimate : undefined;
|
|
210
215
|
return {
|
|
211
216
|
id,
|
|
212
217
|
...(identifier ? { identifier } : {}),
|
|
213
218
|
...(title ? { title } : {}),
|
|
219
|
+
...(description ? { description } : {}),
|
|
214
220
|
...(url ? { url } : {}),
|
|
215
221
|
...(teamId ? { teamId } : {}),
|
|
216
222
|
...(teamKey ? { teamKey } : {}),
|
|
@@ -219,6 +225,8 @@ function extractIssueMetadata(payload) {
|
|
|
219
225
|
...(stateType ? { stateType } : {}),
|
|
220
226
|
...(delegateId ? { delegateId } : {}),
|
|
221
227
|
...(delegateName ? { delegateName } : {}),
|
|
228
|
+
...(priority != null ? { priority } : {}),
|
|
229
|
+
...(estimate != null ? { estimate } : {}),
|
|
222
230
|
labelNames: extractLabelNames(issueRecord),
|
|
223
231
|
};
|
|
224
232
|
}
|