patchrelay 0.29.3 → 0.30.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/watch/App.js +5 -1
- package/dist/cli/watch/IssueDetailView.js +1 -1
- package/dist/cli/watch/IssueRow.js +11 -2
- package/dist/cli/watch/StatusBar.js +1 -1
- package/dist/cli/watch/use-watch-stream.js +24 -0
- package/dist/cli/watch/watch-state.js +7 -1
- package/dist/db/migrations.js +17 -0
- package/dist/db/shared.js +1 -0
- package/dist/db.js +122 -3
- package/dist/http.js +3 -0
- package/dist/linear-client.js +81 -110
- package/dist/service.js +39 -2
- package/dist/webhook-handler.js +103 -21
- package/dist/webhooks.js +6 -3
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/cli/watch/App.js
CHANGED
|
@@ -35,7 +35,11 @@ async function postStop(baseUrl, issueKey, bearerToken) {
|
|
|
35
35
|
headers,
|
|
36
36
|
signal: AbortSignal.timeout(5000),
|
|
37
37
|
});
|
|
38
|
-
|
|
38
|
+
const result = await response.json();
|
|
39
|
+
if (result.ok === undefined && result.stopped === true) {
|
|
40
|
+
return { ...result, ok: true };
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
39
43
|
}
|
|
40
44
|
catch {
|
|
41
45
|
return { reason: "request failed" };
|
|
@@ -44,5 +44,5 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, a
|
|
|
44
44
|
const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
|
|
45
45
|
const graph = useMemo(() => buildPatchRelayStateGraph(history, issue.factoryState), [history, issue.factoryState]);
|
|
46
46
|
const queueObservations = useMemo(() => buildPatchRelayQueueObservations(issue, rawFeedEvents), [issue, rawFeedEvents]);
|
|
47
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.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 }), 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 }) })] }));
|
|
47
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.blockedByCount > 0 && _jsxs(Text, { color: "yellow", children: ["blocked by ", issue.blockedByKeys.join(", ")] }), issue.readyForExecution && !issue.activeRunType && issue.blockedByCount === 0 && _jsx(Text, { color: "blueBright", children: "ready" }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), 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
48
|
}
|
|
@@ -2,6 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { summarizeIssueStatusNote } from "./issue-status-note.js";
|
|
4
4
|
const STATE_COLORS = {
|
|
5
|
+
blocked: "yellow",
|
|
6
|
+
ready: "blueBright",
|
|
5
7
|
delegated: "cyan",
|
|
6
8
|
implementing: "cyan",
|
|
7
9
|
pr_open: "cyan",
|
|
@@ -15,6 +17,8 @@ const STATE_COLORS = {
|
|
|
15
17
|
awaiting_input: "yellow",
|
|
16
18
|
};
|
|
17
19
|
const STATE_SHORT = {
|
|
20
|
+
blocked: "blocked",
|
|
21
|
+
ready: "ready",
|
|
18
22
|
delegated: "delegated",
|
|
19
23
|
implementing: "impl",
|
|
20
24
|
pr_open: "pr open",
|
|
@@ -69,7 +73,12 @@ function truncate(text, max) {
|
|
|
69
73
|
}
|
|
70
74
|
const TERMINAL_STATES = new Set(["done", "failed", "escalated", "awaiting_input"]);
|
|
71
75
|
function formatStatus(issue) {
|
|
72
|
-
const
|
|
76
|
+
const effectiveState = issue.blockedByCount > 0 && !issue.activeRunType
|
|
77
|
+
? "blocked"
|
|
78
|
+
: issue.readyForExecution && !issue.activeRunType
|
|
79
|
+
? "ready"
|
|
80
|
+
: issue.factoryState;
|
|
81
|
+
const state = STATE_SHORT[effectiveState] ?? effectiveState;
|
|
73
82
|
// Terminal states: just the label, no run symbol
|
|
74
83
|
if (TERMINAL_STATES.has(issue.factoryState))
|
|
75
84
|
return state;
|
|
@@ -88,5 +97,5 @@ export function IssueRow({ issue, selected, titleWidth }) {
|
|
|
88
97
|
const tw = titleWidth ?? 30;
|
|
89
98
|
const title = issue.title ? truncate(issue.title, tw) : "";
|
|
90
99
|
const detail = selected ? summarizeIssueStatusNote(issue.statusNote) : undefined;
|
|
91
|
-
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.factoryState), children: ` ${status.padEnd(12)}` }), _jsx(Text, { dimColor: true, children: ` ${pr.padEnd(6)}` }), _jsx(Text, { dimColor: true, children: ` ${ago.padStart(3)}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }), detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null] }));
|
|
100
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key.padEnd(9)}` }), _jsx(Text, { color: stateColor(issue.blockedByCount > 0 && !issue.activeRunType ? "blocked" : issue.readyForExecution && !issue.activeRunType ? "ready" : issue.factoryState), children: ` ${status.padEnd(12)}` }), _jsx(Text, { dimColor: true, children: ` ${pr.padEnd(6)}` }), _jsx(Text, { dimColor: true, children: ` ${ago.padStart(3)}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }), detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null] }));
|
|
92
101
|
}
|
|
@@ -12,5 +12,5 @@ export function StatusBar({ issues, totalCount, filter, connected, lastServerMes
|
|
|
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"] }), 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"] })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
|
|
16
16
|
}
|
|
@@ -6,6 +6,19 @@ export function useWatchStream(options) {
|
|
|
6
6
|
let abortController = new AbortController();
|
|
7
7
|
let reconnectTimeout;
|
|
8
8
|
let attempt = 0;
|
|
9
|
+
const fetchIssueSnapshot = async () => {
|
|
10
|
+
const { baseUrl, bearerToken, dispatch } = optionsRef.current;
|
|
11
|
+
const headers = { accept: "application/json" };
|
|
12
|
+
if (bearerToken) {
|
|
13
|
+
headers.authorization = `Bearer ${bearerToken}`;
|
|
14
|
+
}
|
|
15
|
+
const response = await fetch(new URL("/api/watch/issues", baseUrl), { headers });
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
throw new Error(`Issue snapshot failed: ${response.status}`);
|
|
18
|
+
}
|
|
19
|
+
const payload = await response.json();
|
|
20
|
+
dispatch({ type: "issues-snapshot", issues: Array.isArray(payload.issues) ? payload.issues : [], receivedAt: Date.now() });
|
|
21
|
+
};
|
|
9
22
|
const connect = () => {
|
|
10
23
|
abortController = new AbortController();
|
|
11
24
|
const { baseUrl, bearerToken, issueFilter, dispatch } = optionsRef.current;
|
|
@@ -24,6 +37,12 @@ export function useWatchStream(options) {
|
|
|
24
37
|
}
|
|
25
38
|
dispatch({ type: "connected" });
|
|
26
39
|
attempt = 0;
|
|
40
|
+
try {
|
|
41
|
+
await fetchIssueSnapshot();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Keep the stream alive even if the snapshot endpoint temporarily fails.
|
|
45
|
+
}
|
|
27
46
|
const reader = response.body.getReader();
|
|
28
47
|
const decoder = new TextDecoder();
|
|
29
48
|
let buffer = "";
|
|
@@ -80,11 +99,16 @@ export function useWatchStream(options) {
|
|
|
80
99
|
});
|
|
81
100
|
};
|
|
82
101
|
connect();
|
|
102
|
+
void fetchIssueSnapshot().catch(() => undefined);
|
|
103
|
+
const snapshotInterval = setInterval(() => {
|
|
104
|
+
void fetchIssueSnapshot().catch(() => undefined);
|
|
105
|
+
}, 5000);
|
|
83
106
|
return () => {
|
|
84
107
|
abortController.abort();
|
|
85
108
|
if (reconnectTimeout !== undefined) {
|
|
86
109
|
clearTimeout(reconnectTimeout);
|
|
87
110
|
}
|
|
111
|
+
clearInterval(snapshotInterval);
|
|
88
112
|
};
|
|
89
113
|
}, []);
|
|
90
114
|
}
|
|
@@ -46,17 +46,23 @@ const DONE_STATES = new Set(["done"]);
|
|
|
46
46
|
const FAILED_STATES = new Set(["failed", "escalated"]);
|
|
47
47
|
export function computeAggregates(issues) {
|
|
48
48
|
let active = 0;
|
|
49
|
+
let blocked = 0;
|
|
50
|
+
let ready = 0;
|
|
49
51
|
let done = 0;
|
|
50
52
|
let failed = 0;
|
|
51
53
|
for (const issue of issues) {
|
|
52
54
|
if (issue.activeRunType)
|
|
53
55
|
active++;
|
|
56
|
+
if (!issue.activeRunType && issue.blockedByCount > 0)
|
|
57
|
+
blocked++;
|
|
58
|
+
if (!issue.activeRunType && issue.readyForExecution)
|
|
59
|
+
ready++;
|
|
54
60
|
if (DONE_STATES.has(issue.factoryState))
|
|
55
61
|
done++;
|
|
56
62
|
if (FAILED_STATES.has(issue.factoryState))
|
|
57
63
|
failed++;
|
|
58
64
|
}
|
|
59
|
-
return { active, done, failed, total: issues.length };
|
|
65
|
+
return { active, blocked, ready, done, failed, total: issues.length };
|
|
60
66
|
}
|
|
61
67
|
function nextFilter(filter) {
|
|
62
68
|
switch (filter) {
|
package/dist/db/migrations.js
CHANGED
|
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS issues (
|
|
|
7
7
|
title TEXT,
|
|
8
8
|
url TEXT,
|
|
9
9
|
current_linear_state TEXT,
|
|
10
|
+
current_linear_state_type TEXT,
|
|
10
11
|
factory_state TEXT NOT NULL DEFAULT 'delegated',
|
|
11
12
|
pending_run_type TEXT,
|
|
12
13
|
pending_run_context_json TEXT,
|
|
@@ -145,6 +146,18 @@ CREATE TABLE IF NOT EXISTS operator_feed_events (
|
|
|
145
146
|
status TEXT
|
|
146
147
|
);
|
|
147
148
|
|
|
149
|
+
CREATE TABLE IF NOT EXISTS issue_dependencies (
|
|
150
|
+
project_id TEXT NOT NULL,
|
|
151
|
+
linear_issue_id TEXT NOT NULL,
|
|
152
|
+
blocker_linear_issue_id TEXT NOT NULL,
|
|
153
|
+
blocker_issue_key TEXT,
|
|
154
|
+
blocker_title TEXT,
|
|
155
|
+
blocker_current_linear_state TEXT,
|
|
156
|
+
blocker_current_linear_state_type TEXT,
|
|
157
|
+
updated_at TEXT NOT NULL,
|
|
158
|
+
PRIMARY KEY (project_id, linear_issue_id, blocker_linear_issue_id)
|
|
159
|
+
);
|
|
160
|
+
|
|
148
161
|
CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, linear_issue_id);
|
|
149
162
|
CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
|
|
150
163
|
CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
|
|
@@ -158,6 +171,8 @@ CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_eve
|
|
|
158
171
|
CREATE INDEX IF NOT EXISTS idx_repository_links_installation ON repository_links(installation_id, github_repo);
|
|
159
172
|
CREATE INDEX IF NOT EXISTS idx_linear_catalog_teams_installation ON linear_catalog_teams(installation_id, team_key, team_name);
|
|
160
173
|
CREATE INDEX IF NOT EXISTS idx_linear_catalog_projects_installation ON linear_catalog_projects(installation_id, project_name);
|
|
174
|
+
CREATE INDEX IF NOT EXISTS idx_issue_dependencies_issue ON issue_dependencies(project_id, linear_issue_id);
|
|
175
|
+
CREATE INDEX IF NOT EXISTS idx_issue_dependencies_blocker ON issue_dependencies(project_id, blocker_linear_issue_id);
|
|
161
176
|
`;
|
|
162
177
|
export function runPatchRelayMigrations(connection) {
|
|
163
178
|
connection.exec(schema);
|
|
@@ -175,6 +190,8 @@ export function runPatchRelayMigrations(connection) {
|
|
|
175
190
|
addColumnIfMissing(connection, "issues", "description", "TEXT");
|
|
176
191
|
addColumnIfMissing(connection, "issues", "priority", "INTEGER");
|
|
177
192
|
addColumnIfMissing(connection, "issues", "estimate", "REAL");
|
|
193
|
+
addColumnIfMissing(connection, "issues", "current_linear_state_type", "TEXT");
|
|
194
|
+
addColumnIfMissing(connection, "issue_dependencies", "blocker_current_linear_state_type", "TEXT");
|
|
178
195
|
// Zombie/stale recovery backoff
|
|
179
196
|
addColumnIfMissing(connection, "issues", "zombie_recovery_attempts", "INTEGER NOT NULL DEFAULT 0");
|
|
180
197
|
addColumnIfMissing(connection, "issues", "last_zombie_recovery_at", "TEXT");
|
package/dist/db/shared.js
CHANGED
package/dist/db.js
CHANGED
|
@@ -104,6 +104,10 @@ export class PatchRelayDatabase {
|
|
|
104
104
|
sets.push("current_linear_state = COALESCE(@currentLinearState, current_linear_state)");
|
|
105
105
|
values.currentLinearState = params.currentLinearState;
|
|
106
106
|
}
|
|
107
|
+
if (params.currentLinearStateType !== undefined) {
|
|
108
|
+
sets.push("current_linear_state_type = COALESCE(@currentLinearStateType, current_linear_state_type)");
|
|
109
|
+
values.currentLinearStateType = params.currentLinearStateType;
|
|
110
|
+
}
|
|
107
111
|
if (params.factoryState !== undefined) {
|
|
108
112
|
sets.push("factory_state = @factoryState");
|
|
109
113
|
values.factoryState = params.factoryState;
|
|
@@ -207,7 +211,7 @@ export class PatchRelayDatabase {
|
|
|
207
211
|
INSERT INTO issues (
|
|
208
212
|
project_id, linear_issue_id, issue_key, title, description, url,
|
|
209
213
|
priority, estimate,
|
|
210
|
-
current_linear_state, factory_state, pending_run_type, pending_run_context_json,
|
|
214
|
+
current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
|
|
211
215
|
branch_name, worktree_path, thread_id, active_run_id,
|
|
212
216
|
agent_session_id,
|
|
213
217
|
pr_number, pr_url, pr_state, pr_review_state, pr_check_status,
|
|
@@ -216,7 +220,7 @@ export class PatchRelayDatabase {
|
|
|
216
220
|
) VALUES (
|
|
217
221
|
@projectId, @linearIssueId, @issueKey, @title, @description, @url,
|
|
218
222
|
@priority, @estimate,
|
|
219
|
-
@currentLinearState, @factoryState, @pendingRunType, @pendingRunContextJson,
|
|
223
|
+
@currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
|
|
220
224
|
@branchName, @worktreePath, @threadId, @activeRunId,
|
|
221
225
|
@agentSessionId,
|
|
222
226
|
@prNumber, @prUrl, @prState, @prReviewState, @prCheckStatus,
|
|
@@ -233,6 +237,7 @@ export class PatchRelayDatabase {
|
|
|
233
237
|
priority: params.priority ?? null,
|
|
234
238
|
estimate: params.estimate ?? null,
|
|
235
239
|
currentLinearState: params.currentLinearState ?? null,
|
|
240
|
+
currentLinearStateType: params.currentLinearStateType ?? null,
|
|
236
241
|
factoryState: params.factoryState ?? "delegated",
|
|
237
242
|
pendingRunType: params.pendingRunType ?? null,
|
|
238
243
|
pendingRunContextJson: params.pendingRunContextJson ?? null,
|
|
@@ -279,9 +284,111 @@ export class PatchRelayDatabase {
|
|
|
279
284
|
const row = this.connection.prepare("SELECT * FROM issues WHERE pr_number = ?").get(prNumber);
|
|
280
285
|
return row ? mapIssueRow(row) : undefined;
|
|
281
286
|
}
|
|
287
|
+
replaceIssueDependencies(params) {
|
|
288
|
+
const now = isoNow();
|
|
289
|
+
this.connection
|
|
290
|
+
.prepare("DELETE FROM issue_dependencies WHERE project_id = ? AND linear_issue_id = ?")
|
|
291
|
+
.run(params.projectId, params.linearIssueId);
|
|
292
|
+
if (params.blockers.length === 0) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const insert = this.connection.prepare(`
|
|
296
|
+
INSERT INTO issue_dependencies (
|
|
297
|
+
project_id,
|
|
298
|
+
linear_issue_id,
|
|
299
|
+
blocker_linear_issue_id,
|
|
300
|
+
blocker_issue_key,
|
|
301
|
+
blocker_title,
|
|
302
|
+
blocker_current_linear_state,
|
|
303
|
+
blocker_current_linear_state_type,
|
|
304
|
+
updated_at
|
|
305
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
306
|
+
`);
|
|
307
|
+
for (const blocker of params.blockers) {
|
|
308
|
+
insert.run(params.projectId, params.linearIssueId, blocker.blockerLinearIssueId, blocker.blockerIssueKey ?? null, blocker.blockerTitle ?? null, blocker.blockerCurrentLinearState ?? null, blocker.blockerCurrentLinearStateType ?? null, now);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
listIssueDependencies(projectId, linearIssueId) {
|
|
312
|
+
const rows = this.connection.prepare(`
|
|
313
|
+
SELECT
|
|
314
|
+
d.project_id,
|
|
315
|
+
d.linear_issue_id,
|
|
316
|
+
d.blocker_linear_issue_id,
|
|
317
|
+
COALESCE(blockers.issue_key, d.blocker_issue_key) AS blocker_issue_key,
|
|
318
|
+
COALESCE(blockers.title, d.blocker_title) AS blocker_title,
|
|
319
|
+
COALESCE(blockers.current_linear_state, d.blocker_current_linear_state) AS blocker_current_linear_state,
|
|
320
|
+
COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type) AS blocker_current_linear_state_type,
|
|
321
|
+
d.updated_at
|
|
322
|
+
FROM issue_dependencies d
|
|
323
|
+
LEFT JOIN issues blockers
|
|
324
|
+
ON blockers.project_id = d.project_id
|
|
325
|
+
AND blockers.linear_issue_id = d.blocker_linear_issue_id
|
|
326
|
+
WHERE d.project_id = ? AND d.linear_issue_id = ?
|
|
327
|
+
ORDER BY COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id) ASC
|
|
328
|
+
`).all(projectId, linearIssueId);
|
|
329
|
+
return rows.map((row) => ({
|
|
330
|
+
projectId: String(row.project_id),
|
|
331
|
+
linearIssueId: String(row.linear_issue_id),
|
|
332
|
+
blockerLinearIssueId: String(row.blocker_linear_issue_id),
|
|
333
|
+
...(row.blocker_issue_key !== null && row.blocker_issue_key !== undefined ? { blockerIssueKey: String(row.blocker_issue_key) } : {}),
|
|
334
|
+
...(row.blocker_title !== null && row.blocker_title !== undefined ? { blockerTitle: String(row.blocker_title) } : {}),
|
|
335
|
+
...(row.blocker_current_linear_state !== null && row.blocker_current_linear_state !== undefined
|
|
336
|
+
? { blockerCurrentLinearState: String(row.blocker_current_linear_state) }
|
|
337
|
+
: {}),
|
|
338
|
+
...(row.blocker_current_linear_state_type !== null && row.blocker_current_linear_state_type !== undefined
|
|
339
|
+
? { blockerCurrentLinearStateType: String(row.blocker_current_linear_state_type) }
|
|
340
|
+
: {}),
|
|
341
|
+
updatedAt: String(row.updated_at),
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
listDependents(projectId, blockerLinearIssueId) {
|
|
345
|
+
const rows = this.connection.prepare(`
|
|
346
|
+
SELECT project_id, linear_issue_id
|
|
347
|
+
FROM issue_dependencies
|
|
348
|
+
WHERE project_id = ? AND blocker_linear_issue_id = ?
|
|
349
|
+
ORDER BY linear_issue_id ASC
|
|
350
|
+
`).all(projectId, blockerLinearIssueId);
|
|
351
|
+
return rows.map((row) => ({
|
|
352
|
+
projectId: String(row.project_id),
|
|
353
|
+
linearIssueId: String(row.linear_issue_id),
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
countUnresolvedBlockers(projectId, linearIssueId) {
|
|
357
|
+
const row = this.connection.prepare(`
|
|
358
|
+
SELECT COUNT(*) AS count
|
|
359
|
+
FROM issue_dependencies d
|
|
360
|
+
LEFT JOIN issues blockers
|
|
361
|
+
ON blockers.project_id = d.project_id
|
|
362
|
+
AND blockers.linear_issue_id = d.blocker_linear_issue_id
|
|
363
|
+
WHERE d.project_id = ? AND d.linear_issue_id = ?
|
|
364
|
+
AND (
|
|
365
|
+
COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
|
|
366
|
+
AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
|
|
367
|
+
)
|
|
368
|
+
`).get(projectId, linearIssueId);
|
|
369
|
+
return Number(row?.count ?? 0);
|
|
370
|
+
}
|
|
282
371
|
listIssuesReadyForExecution() {
|
|
283
372
|
const rows = this.connection
|
|
284
|
-
.prepare(
|
|
373
|
+
.prepare(`
|
|
374
|
+
SELECT i.project_id, i.linear_issue_id
|
|
375
|
+
FROM issues i
|
|
376
|
+
WHERE i.pending_run_type IS NOT NULL
|
|
377
|
+
AND i.active_run_id IS NULL
|
|
378
|
+
AND NOT EXISTS (
|
|
379
|
+
SELECT 1
|
|
380
|
+
FROM issue_dependencies d
|
|
381
|
+
LEFT JOIN issues blockers
|
|
382
|
+
ON blockers.project_id = d.project_id
|
|
383
|
+
AND blockers.linear_issue_id = d.blocker_linear_issue_id
|
|
384
|
+
WHERE d.project_id = i.project_id
|
|
385
|
+
AND d.linear_issue_id = i.linear_issue_id
|
|
386
|
+
AND (
|
|
387
|
+
COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
|
|
388
|
+
AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
`)
|
|
285
392
|
.all();
|
|
286
393
|
return rows.map((row) => ({
|
|
287
394
|
projectId: String(row.project_id),
|
|
@@ -399,6 +506,8 @@ export class PatchRelayDatabase {
|
|
|
399
506
|
}
|
|
400
507
|
// ─── View builders ──────────────────────────────────────────────
|
|
401
508
|
issueToTrackedIssue(issue) {
|
|
509
|
+
const blockedBy = this.listIssueDependencies(issue.projectId, issue.linearIssueId);
|
|
510
|
+
const unresolvedBlockedBy = blockedBy.filter((entry) => !isResolvedLinearState(entry.blockerCurrentLinearStateType, entry.blockerCurrentLinearState));
|
|
402
511
|
return {
|
|
403
512
|
id: issue.id,
|
|
404
513
|
projectId: issue.projectId,
|
|
@@ -408,6 +517,10 @@ export class PatchRelayDatabase {
|
|
|
408
517
|
...(issue.url ? { issueUrl: issue.url } : {}),
|
|
409
518
|
...(issue.currentLinearState ? { currentLinearState: issue.currentLinearState } : {}),
|
|
410
519
|
factoryState: issue.factoryState,
|
|
520
|
+
blockedByCount: unresolvedBlockedBy.length,
|
|
521
|
+
blockedByKeys: unresolvedBlockedBy
|
|
522
|
+
.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId),
|
|
523
|
+
readyForExecution: issue.pendingRunType !== undefined && issue.activeRunId === undefined && unresolvedBlockedBy.length === 0,
|
|
411
524
|
...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
|
|
412
525
|
...(issue.agentSessionId ? { activeAgentSessionId: issue.agentSessionId } : {}),
|
|
413
526
|
updatedAt: issue.updatedAt,
|
|
@@ -447,6 +560,9 @@ function mapIssueRow(row) {
|
|
|
447
560
|
...(row.priority !== null && row.priority !== undefined ? { priority: Number(row.priority) } : {}),
|
|
448
561
|
...(row.estimate !== null && row.estimate !== undefined ? { estimate: Number(row.estimate) } : {}),
|
|
449
562
|
...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
|
|
563
|
+
...(row.current_linear_state_type !== null && row.current_linear_state_type !== undefined
|
|
564
|
+
? { currentLinearStateType: String(row.current_linear_state_type) }
|
|
565
|
+
: {}),
|
|
450
566
|
factoryState: String(row.factory_state ?? "delegated"),
|
|
451
567
|
...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
|
|
452
568
|
...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
|
|
@@ -505,3 +621,6 @@ function mapRunRow(row) {
|
|
|
505
621
|
...(row.ended_at !== null ? { endedAt: String(row.ended_at) } : {}),
|
|
506
622
|
};
|
|
507
623
|
}
|
|
624
|
+
function isResolvedLinearState(stateType, stateName) {
|
|
625
|
+
return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
|
|
626
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -378,6 +378,9 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
378
378
|
reply.raw.on("error", cleanup);
|
|
379
379
|
request.raw.on("close", cleanup);
|
|
380
380
|
});
|
|
381
|
+
app.get("/api/watch/issues", async (_request, reply) => {
|
|
382
|
+
return reply.send({ ok: true, issues: service.listTrackedIssues() });
|
|
383
|
+
});
|
|
381
384
|
app.get("/api/watch", async (request, reply) => {
|
|
382
385
|
reply.hijack();
|
|
383
386
|
reply.raw.writeHead(200, {
|
package/dist/linear-client.js
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
import { refreshLinearOAuthToken } from "./linear-oauth.js";
|
|
2
2
|
import { decryptSecret, encryptSecret } from "./token-crypto.js";
|
|
3
|
+
const LINEAR_ISSUE_SELECTION = `
|
|
4
|
+
id
|
|
5
|
+
identifier
|
|
6
|
+
title
|
|
7
|
+
description
|
|
8
|
+
url
|
|
9
|
+
priority
|
|
10
|
+
estimate
|
|
11
|
+
delegate {
|
|
12
|
+
id
|
|
13
|
+
name
|
|
14
|
+
}
|
|
15
|
+
state {
|
|
16
|
+
id
|
|
17
|
+
name
|
|
18
|
+
type
|
|
19
|
+
}
|
|
20
|
+
labels {
|
|
21
|
+
nodes {
|
|
22
|
+
id
|
|
23
|
+
name
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
blockedBy {
|
|
27
|
+
nodes {
|
|
28
|
+
id
|
|
29
|
+
identifier
|
|
30
|
+
title
|
|
31
|
+
state {
|
|
32
|
+
id
|
|
33
|
+
name
|
|
34
|
+
type
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
blocks {
|
|
39
|
+
nodes {
|
|
40
|
+
id
|
|
41
|
+
identifier
|
|
42
|
+
title
|
|
43
|
+
state {
|
|
44
|
+
id
|
|
45
|
+
name
|
|
46
|
+
type
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
team {
|
|
51
|
+
id
|
|
52
|
+
key
|
|
53
|
+
states {
|
|
54
|
+
nodes {
|
|
55
|
+
id
|
|
56
|
+
name
|
|
57
|
+
type
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
labels {
|
|
61
|
+
nodes {
|
|
62
|
+
id
|
|
63
|
+
name
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
`;
|
|
3
68
|
export class LinearGraphqlClient {
|
|
4
69
|
options;
|
|
5
70
|
logger;
|
|
@@ -11,44 +76,7 @@ export class LinearGraphqlClient {
|
|
|
11
76
|
const response = await this.request(`
|
|
12
77
|
query PatchRelayIssue($id: String!) {
|
|
13
78
|
issue(id: $id) {
|
|
14
|
-
|
|
15
|
-
identifier
|
|
16
|
-
title
|
|
17
|
-
description
|
|
18
|
-
url
|
|
19
|
-
priority
|
|
20
|
-
estimate
|
|
21
|
-
delegate {
|
|
22
|
-
id
|
|
23
|
-
name
|
|
24
|
-
}
|
|
25
|
-
state {
|
|
26
|
-
id
|
|
27
|
-
name
|
|
28
|
-
}
|
|
29
|
-
labels {
|
|
30
|
-
nodes {
|
|
31
|
-
id
|
|
32
|
-
name
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
team {
|
|
36
|
-
id
|
|
37
|
-
key
|
|
38
|
-
states {
|
|
39
|
-
nodes {
|
|
40
|
-
id
|
|
41
|
-
name
|
|
42
|
-
type
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
labels {
|
|
46
|
-
nodes {
|
|
47
|
-
id
|
|
48
|
-
name
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
79
|
+
${LINEAR_ISSUE_SELECTION}
|
|
52
80
|
}
|
|
53
81
|
}
|
|
54
82
|
`, { id: issueId });
|
|
@@ -68,44 +96,7 @@ export class LinearGraphqlClient {
|
|
|
68
96
|
issueUpdate(id: $id, input: { stateId: $stateId }) {
|
|
69
97
|
success
|
|
70
98
|
issue {
|
|
71
|
-
|
|
72
|
-
identifier
|
|
73
|
-
title
|
|
74
|
-
description
|
|
75
|
-
url
|
|
76
|
-
priority
|
|
77
|
-
estimate
|
|
78
|
-
delegate {
|
|
79
|
-
id
|
|
80
|
-
name
|
|
81
|
-
}
|
|
82
|
-
state {
|
|
83
|
-
id
|
|
84
|
-
name
|
|
85
|
-
}
|
|
86
|
-
labels {
|
|
87
|
-
nodes {
|
|
88
|
-
id
|
|
89
|
-
name
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
team {
|
|
93
|
-
id
|
|
94
|
-
key
|
|
95
|
-
states {
|
|
96
|
-
nodes {
|
|
97
|
-
id
|
|
98
|
-
name
|
|
99
|
-
type
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
labels {
|
|
103
|
-
nodes {
|
|
104
|
-
id
|
|
105
|
-
name
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
99
|
+
${LINEAR_ISSUE_SELECTION}
|
|
109
100
|
}
|
|
110
101
|
}
|
|
111
102
|
}
|
|
@@ -208,40 +199,7 @@ export class LinearGraphqlClient {
|
|
|
208
199
|
issueUpdate(id: $id, input: { addedLabelIds: $addedLabelIds, removedLabelIds: $removedLabelIds }) {
|
|
209
200
|
success
|
|
210
201
|
issue {
|
|
211
|
-
|
|
212
|
-
identifier
|
|
213
|
-
title
|
|
214
|
-
description
|
|
215
|
-
url
|
|
216
|
-
priority
|
|
217
|
-
estimate
|
|
218
|
-
state {
|
|
219
|
-
id
|
|
220
|
-
name
|
|
221
|
-
}
|
|
222
|
-
labels {
|
|
223
|
-
nodes {
|
|
224
|
-
id
|
|
225
|
-
name
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
team {
|
|
229
|
-
id
|
|
230
|
-
key
|
|
231
|
-
states {
|
|
232
|
-
nodes {
|
|
233
|
-
id
|
|
234
|
-
name
|
|
235
|
-
type
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
labels {
|
|
239
|
-
nodes {
|
|
240
|
-
id
|
|
241
|
-
name
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
202
|
+
${LINEAR_ISSUE_SELECTION}
|
|
245
203
|
}
|
|
246
204
|
}
|
|
247
205
|
}
|
|
@@ -360,6 +318,7 @@ export class LinearGraphqlClient {
|
|
|
360
318
|
...(issue.estimate != null ? { estimate: issue.estimate } : {}),
|
|
361
319
|
...(issue.state?.id ? { stateId: issue.state.id } : {}),
|
|
362
320
|
...(issue.state?.name ? { stateName: issue.state.name } : {}),
|
|
321
|
+
...(issue.state?.type ? { stateType: issue.state.type } : {}),
|
|
363
322
|
...(issue.team?.id ? { teamId: issue.team.id } : {}),
|
|
364
323
|
...(issue.team?.key ? { teamKey: issue.team.key } : {}),
|
|
365
324
|
...(issue.delegate?.id ? { delegateId: issue.delegate.id } : {}),
|
|
@@ -372,6 +331,8 @@ export class LinearGraphqlClient {
|
|
|
372
331
|
labelIds: labels.map((label) => label.id),
|
|
373
332
|
labels,
|
|
374
333
|
teamLabels,
|
|
334
|
+
blockedBy: (issue.blockedBy?.nodes ?? []).map(mapIssueRelation),
|
|
335
|
+
blocks: (issue.blocks?.nodes ?? []).map(mapIssueRelation),
|
|
375
336
|
};
|
|
376
337
|
}
|
|
377
338
|
resolveLabelIds(issue, names) {
|
|
@@ -389,6 +350,16 @@ export class LinearGraphqlClient {
|
|
|
389
350
|
return labelIds;
|
|
390
351
|
}
|
|
391
352
|
}
|
|
353
|
+
function mapIssueRelation(raw) {
|
|
354
|
+
return {
|
|
355
|
+
id: raw.id,
|
|
356
|
+
...(raw.identifier ? { identifier: raw.identifier } : {}),
|
|
357
|
+
...(raw.title ? { title: raw.title } : {}),
|
|
358
|
+
...(raw.state?.id ? { stateId: raw.state.id } : {}),
|
|
359
|
+
...(raw.state?.name ? { stateName: raw.state.name } : {}),
|
|
360
|
+
...(raw.state?.type ? { stateType: raw.state.type } : {}),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
392
363
|
export class DatabaseBackedLinearClientProvider {
|
|
393
364
|
config;
|
|
394
365
|
db;
|
package/dist/service.js
CHANGED
|
@@ -214,12 +214,39 @@ export class PatchRelayService {
|
|
|
214
214
|
.prepare(`SELECT
|
|
215
215
|
i.project_id, i.linear_issue_id, i.issue_key, i.title,
|
|
216
216
|
i.current_linear_state, i.factory_state, i.updated_at,
|
|
217
|
+
i.pending_run_type,
|
|
217
218
|
i.pr_number, i.pr_review_state, i.pr_check_status,
|
|
218
219
|
active_run.run_type AS active_run_type,
|
|
219
220
|
latest_run.run_type AS latest_run_type,
|
|
220
221
|
latest_run.status AS latest_run_status,
|
|
221
222
|
latest_run.summary_json AS latest_run_summary_json,
|
|
222
|
-
latest_run.report_json AS latest_run_report_json
|
|
223
|
+
latest_run.report_json AS latest_run_report_json,
|
|
224
|
+
(
|
|
225
|
+
SELECT COUNT(*)
|
|
226
|
+
FROM issue_dependencies d
|
|
227
|
+
LEFT JOIN issues blockers
|
|
228
|
+
ON blockers.project_id = d.project_id
|
|
229
|
+
AND blockers.linear_issue_id = d.blocker_linear_issue_id
|
|
230
|
+
WHERE d.project_id = i.project_id
|
|
231
|
+
AND d.linear_issue_id = i.linear_issue_id
|
|
232
|
+
AND (
|
|
233
|
+
COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
|
|
234
|
+
AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
|
|
235
|
+
)
|
|
236
|
+
) AS blocked_by_count,
|
|
237
|
+
(
|
|
238
|
+
SELECT json_group_array(COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id))
|
|
239
|
+
FROM issue_dependencies d
|
|
240
|
+
LEFT JOIN issues blockers
|
|
241
|
+
ON blockers.project_id = d.project_id
|
|
242
|
+
AND blockers.linear_issue_id = d.blocker_linear_issue_id
|
|
243
|
+
WHERE d.project_id = i.project_id
|
|
244
|
+
AND d.linear_issue_id = i.linear_issue_id
|
|
245
|
+
AND (
|
|
246
|
+
COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
|
|
247
|
+
AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
|
|
248
|
+
)
|
|
249
|
+
) AS blocked_by_keys_json
|
|
223
250
|
FROM issues i
|
|
224
251
|
LEFT JOIN runs active_run ON active_run.id = i.active_run_id
|
|
225
252
|
LEFT JOIN runs latest_run ON latest_run.id = (
|
|
@@ -231,14 +258,24 @@ export class PatchRelayService {
|
|
|
231
258
|
.all();
|
|
232
259
|
return rows.map((row) => {
|
|
233
260
|
const statusNote = extractStatusNote(typeof row.latest_run_summary_json === "string" ? row.latest_run_summary_json : undefined, typeof row.latest_run_report_json === "string" ? row.latest_run_report_json : undefined);
|
|
261
|
+
const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
|
|
262
|
+
const blockedByCount = Number(row.blocked_by_count ?? 0);
|
|
263
|
+
const readyForExecution = row.pending_run_type !== null && row.pending_run_type !== undefined && row.active_run_type === null && blockedByCount === 0;
|
|
264
|
+
const statusNoteWithBlockers = blockedByCount > 0
|
|
265
|
+
? `Blocked by ${blockedByKeys.join(", ")}`
|
|
266
|
+
: statusNote;
|
|
234
267
|
return {
|
|
235
268
|
...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
|
|
236
269
|
...(row.title !== null ? { title: String(row.title) } : {}),
|
|
237
|
-
...(
|
|
270
|
+
...(statusNoteWithBlockers ? { statusNote: statusNoteWithBlockers } : {}),
|
|
238
271
|
projectId: String(row.project_id),
|
|
239
272
|
factoryState: String(row.factory_state ?? "delegated"),
|
|
273
|
+
blockedByCount,
|
|
274
|
+
blockedByKeys,
|
|
275
|
+
readyForExecution,
|
|
240
276
|
...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
|
|
241
277
|
...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
|
|
278
|
+
...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
|
|
242
279
|
...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
|
|
243
280
|
...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
|
|
244
281
|
...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
|
package/dist/webhook-handler.js
CHANGED
|
@@ -94,8 +94,9 @@ export class WebhookHandler {
|
|
|
94
94
|
const hydrated = await this.hydrateIssueContext(project.id, normalized);
|
|
95
95
|
const issue = hydrated.issue ?? routedIssue;
|
|
96
96
|
// Record desired stage and upsert issue
|
|
97
|
-
const result = this.recordDesiredStage(project, hydrated);
|
|
97
|
+
const result = await this.recordDesiredStage(project, hydrated);
|
|
98
98
|
const trackedIssue = result.issue;
|
|
99
|
+
const newlyReadyDependents = this.reconcileDependentReadiness(project.id, issue.id);
|
|
99
100
|
// Handle agent session events
|
|
100
101
|
await this.handleAgentSession(hydrated, project, trackedIssue, result.desiredStage, result.delegated);
|
|
101
102
|
// Handle comments during active run
|
|
@@ -114,6 +115,20 @@ export class WebhookHandler {
|
|
|
114
115
|
});
|
|
115
116
|
this.enqueueIssue(project.id, issue.id);
|
|
116
117
|
}
|
|
118
|
+
for (const dependentIssueId of newlyReadyDependents) {
|
|
119
|
+
const dependent = this.db.getTrackedIssue(project.id, dependentIssueId);
|
|
120
|
+
this.feed?.publish({
|
|
121
|
+
level: "info",
|
|
122
|
+
kind: "stage",
|
|
123
|
+
issueKey: dependent?.issueKey,
|
|
124
|
+
projectId: project.id,
|
|
125
|
+
stage: "implementation",
|
|
126
|
+
status: "queued",
|
|
127
|
+
summary: "Queued implementation after blockers resolved",
|
|
128
|
+
detail: `All blockers are now done for ${dependent?.issueKey ?? dependentIssueId}.`,
|
|
129
|
+
});
|
|
130
|
+
this.enqueueIssue(project.id, dependentIssueId);
|
|
131
|
+
}
|
|
117
132
|
}
|
|
118
133
|
catch (error) {
|
|
119
134
|
this.db.markWebhookProcessed(webhookEventId, "failed");
|
|
@@ -130,7 +145,7 @@ export class WebhookHandler {
|
|
|
130
145
|
throw err;
|
|
131
146
|
}
|
|
132
147
|
}
|
|
133
|
-
recordDesiredStage(project, normalized) {
|
|
148
|
+
async recordDesiredStage(project, normalized) {
|
|
134
149
|
const normalizedIssue = normalized.issue;
|
|
135
150
|
if (!normalizedIssue) {
|
|
136
151
|
return { issue: undefined, desiredStage: undefined, delegated: false };
|
|
@@ -139,19 +154,18 @@ export class WebhookHandler {
|
|
|
139
154
|
const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
|
|
140
155
|
const delegated = this.isDelegatedToPatchRelay(project, normalized);
|
|
141
156
|
const triggerAllowed = triggerEventAllowed(project, normalized.triggerEvent);
|
|
157
|
+
const shouldTrack = Boolean(existingIssue || delegated);
|
|
158
|
+
if (!shouldTrack) {
|
|
159
|
+
return { issue: undefined, desiredStage: undefined, delegated };
|
|
160
|
+
}
|
|
161
|
+
const hydratedIssue = await this.syncIssueDependencies(project.id, normalizedIssue);
|
|
162
|
+
const unresolvedBlockers = this.db.countUnresolvedBlockers(project.id, normalizedIssue.id);
|
|
142
163
|
const pendingRunContextJson = mergePendingImplementationContext(existingIssue?.pendingRunContextJson, normalized);
|
|
143
|
-
// In the factory model, only a true delegation queues implementation work.
|
|
144
164
|
let pendingRunType;
|
|
145
|
-
|
|
146
|
-
if (isDelegationSignal && triggerAllowed && !activeRun && !existingIssue?.pendingRunType) {
|
|
165
|
+
if (delegated && triggerAllowed && unresolvedBlockers === 0 && !activeRun && !existingIssue?.pendingRunType) {
|
|
147
166
|
pendingRunType = "implementation";
|
|
148
167
|
}
|
|
149
|
-
|
|
150
|
-
// An issue becomes PatchRelay-relevant only once it is already tracked
|
|
151
|
-
// or a true delegation queues work.
|
|
152
|
-
if (!existingIssue && !pendingRunType) {
|
|
153
|
-
return { issue: undefined, desiredStage: undefined, delegated };
|
|
154
|
-
}
|
|
168
|
+
const clearPendingImplementation = unresolvedBlockers > 0 && existingIssue?.pendingRunType === "implementation" && !activeRun;
|
|
155
169
|
// Resolve agent session
|
|
156
170
|
const agentSessionId = normalized.agentSession?.id ??
|
|
157
171
|
(!activeRun && (pendingRunType || (normalized.triggerEvent === "delegateChanged" && !delegated)) ? null : undefined);
|
|
@@ -159,14 +173,16 @@ export class WebhookHandler {
|
|
|
159
173
|
const issue = this.db.upsertIssue({
|
|
160
174
|
projectId: project.id,
|
|
161
175
|
linearIssueId: normalizedIssue.id,
|
|
162
|
-
...(
|
|
163
|
-
...(
|
|
164
|
-
...(
|
|
165
|
-
...(
|
|
166
|
-
...(
|
|
167
|
-
...(
|
|
168
|
-
...(
|
|
176
|
+
...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
|
|
177
|
+
...(hydratedIssue.title ? { title: hydratedIssue.title } : {}),
|
|
178
|
+
...(hydratedIssue.description ? { description: hydratedIssue.description } : {}),
|
|
179
|
+
...(hydratedIssue.url ? { url: hydratedIssue.url } : {}),
|
|
180
|
+
...(hydratedIssue.priority != null ? { priority: hydratedIssue.priority } : {}),
|
|
181
|
+
...(hydratedIssue.estimate != null ? { estimate: hydratedIssue.estimate } : {}),
|
|
182
|
+
...(hydratedIssue.stateName ? { currentLinearState: hydratedIssue.stateName } : {}),
|
|
183
|
+
...(hydratedIssue.stateType ? { currentLinearStateType: hydratedIssue.stateType } : {}),
|
|
169
184
|
...(pendingRunType ? { pendingRunType, factoryState: "delegated" } : {}),
|
|
185
|
+
...(clearPendingImplementation ? { pendingRunType: null } : {}),
|
|
170
186
|
...((pendingRunType || existingIssue?.pendingRunType === "implementation") && pendingRunContextJson
|
|
171
187
|
? { pendingRunContextJson }
|
|
172
188
|
: {}),
|
|
@@ -186,6 +202,64 @@ export class WebhookHandler {
|
|
|
186
202
|
return false;
|
|
187
203
|
return normalized.issue.delegateId === installation.actorId;
|
|
188
204
|
}
|
|
205
|
+
async syncIssueDependencies(projectId, issue) {
|
|
206
|
+
let source = issue;
|
|
207
|
+
if (!source.relationsKnown) {
|
|
208
|
+
const linear = await this.linearProvider.forProject(projectId);
|
|
209
|
+
if (linear) {
|
|
210
|
+
try {
|
|
211
|
+
source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Preserve existing dependency rows when webhook relation data is incomplete.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (source.relationsKnown) {
|
|
219
|
+
this.db.replaceIssueDependencies({
|
|
220
|
+
projectId,
|
|
221
|
+
linearIssueId: source.id,
|
|
222
|
+
blockers: source.blockedBy.map((blocker) => ({
|
|
223
|
+
blockerLinearIssueId: blocker.id,
|
|
224
|
+
...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
|
|
225
|
+
...(blocker.title ? { blockerTitle: blocker.title } : {}),
|
|
226
|
+
...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
|
|
227
|
+
...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
|
|
228
|
+
})),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return source;
|
|
232
|
+
}
|
|
233
|
+
reconcileDependentReadiness(projectId, blockerLinearIssueId) {
|
|
234
|
+
const newlyReady = [];
|
|
235
|
+
for (const dependent of this.db.listDependents(projectId, blockerLinearIssueId)) {
|
|
236
|
+
const issue = this.db.getIssue(projectId, dependent.linearIssueId);
|
|
237
|
+
if (!issue) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const unresolved = this.db.countUnresolvedBlockers(projectId, dependent.linearIssueId);
|
|
241
|
+
if (unresolved > 0) {
|
|
242
|
+
if (issue.pendingRunType === "implementation" && issue.activeRunId === undefined) {
|
|
243
|
+
this.db.upsertIssue({
|
|
244
|
+
projectId,
|
|
245
|
+
linearIssueId: dependent.linearIssueId,
|
|
246
|
+
pendingRunType: null,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined || issue.pendingRunType !== undefined) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
this.db.upsertIssue({
|
|
255
|
+
projectId,
|
|
256
|
+
linearIssueId: dependent.linearIssueId,
|
|
257
|
+
pendingRunType: "implementation",
|
|
258
|
+
});
|
|
259
|
+
newlyReady.push(dependent.linearIssueId);
|
|
260
|
+
}
|
|
261
|
+
return newlyReady;
|
|
262
|
+
}
|
|
189
263
|
// ─── Agent session handling (inlined) ─────────────────────────────
|
|
190
264
|
async handleAgentSession(normalized, project, trackedIssue, desiredStage, delegated) {
|
|
191
265
|
if (!normalized.agentSession?.id || !normalized.issue)
|
|
@@ -213,9 +287,12 @@ export class WebhookHandler {
|
|
|
213
287
|
await this.publishAgentActivity(linear, normalized.agentSession.id, buildAlreadyRunningThought(activeRun.runType));
|
|
214
288
|
return;
|
|
215
289
|
}
|
|
290
|
+
const blockerSummary = trackedIssue?.blockedByCount
|
|
291
|
+
? `PatchRelay is delegated and waiting on blockers to reach Done: ${trackedIssue.blockedByKeys.join(", ")}.`
|
|
292
|
+
: "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.";
|
|
216
293
|
await this.publishAgentActivity(linear, normalized.agentSession.id, {
|
|
217
294
|
type: "elicitation",
|
|
218
|
-
body:
|
|
295
|
+
body: blockerSummary,
|
|
219
296
|
});
|
|
220
297
|
return;
|
|
221
298
|
}
|
|
@@ -423,9 +500,10 @@ export class WebhookHandler {
|
|
|
423
500
|
async hydrateIssueContext(projectId, normalized) {
|
|
424
501
|
if (!normalized.issue)
|
|
425
502
|
return normalized;
|
|
426
|
-
if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted")
|
|
503
|
+
if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted" && normalized.entityType !== "Issue") {
|
|
427
504
|
return normalized;
|
|
428
|
-
|
|
505
|
+
}
|
|
506
|
+
if (normalized.entityType !== "Issue" && hasCompleteIssueContext(normalized.issue))
|
|
429
507
|
return normalized;
|
|
430
508
|
const linear = await this.linearProvider.forProject(projectId);
|
|
431
509
|
if (!linear)
|
|
@@ -485,8 +563,12 @@ function mergeIssueMetadata(issue, liveIssue) {
|
|
|
485
563
|
...(issue.teamKey ? {} : liveIssue.teamKey ? { teamKey: liveIssue.teamKey } : {}),
|
|
486
564
|
...(issue.stateId ? {} : liveIssue.stateId ? { stateId: liveIssue.stateId } : {}),
|
|
487
565
|
...(issue.stateName ? {} : liveIssue.stateName ? { stateName: liveIssue.stateName } : {}),
|
|
566
|
+
...(issue.stateType ? {} : liveIssue.stateType ? { stateType: liveIssue.stateType } : {}),
|
|
488
567
|
...(issue.delegateId ? {} : liveIssue.delegateId ? { delegateId: liveIssue.delegateId } : {}),
|
|
489
568
|
...(issue.delegateName ? {} : liveIssue.delegateName ? { delegateName: liveIssue.delegateName } : {}),
|
|
569
|
+
relationsKnown: issue.relationsKnown || liveIssue.blockedBy !== undefined || liveIssue.blocks !== undefined,
|
|
490
570
|
labelNames: issue.labelNames.length > 0 ? issue.labelNames : (liveIssue.labels ?? []).map((l) => l.name),
|
|
571
|
+
blockedBy: issue.relationsKnown ? issue.blockedBy : (liveIssue.blockedBy ?? issue.blockedBy),
|
|
572
|
+
blocks: issue.relationsKnown ? issue.blocks : (liveIssue.blocks ?? issue.blocks),
|
|
491
573
|
};
|
|
492
574
|
}
|
package/dist/webhooks.js
CHANGED
|
@@ -80,12 +80,12 @@ function deriveTriggerEvent(payload) {
|
|
|
80
80
|
if (updatedFields.has("stateId") || updatedFields.has("state")) {
|
|
81
81
|
return "statusChanged";
|
|
82
82
|
}
|
|
83
|
-
if (updatedFields.has("assigneeId") || updatedFields.has("assignee")) {
|
|
84
|
-
return "assignmentChanged";
|
|
85
|
-
}
|
|
86
83
|
if (updatedFields.has("delegateId") || updatedFields.has("delegate")) {
|
|
87
84
|
return "delegateChanged";
|
|
88
85
|
}
|
|
86
|
+
if (updatedFields.has("assigneeId") || updatedFields.has("assignee")) {
|
|
87
|
+
return "assignmentChanged";
|
|
88
|
+
}
|
|
89
89
|
return "issueUpdated";
|
|
90
90
|
}
|
|
91
91
|
if (payload.type === "Comment") {
|
|
@@ -227,7 +227,10 @@ function extractIssueMetadata(payload) {
|
|
|
227
227
|
...(delegateName ? { delegateName } : {}),
|
|
228
228
|
...(priority != null ? { priority } : {}),
|
|
229
229
|
...(estimate != null ? { estimate } : {}),
|
|
230
|
+
relationsKnown: false,
|
|
230
231
|
labelNames: extractLabelNames(issueRecord),
|
|
232
|
+
blockedBy: [],
|
|
233
|
+
blocks: [],
|
|
231
234
|
};
|
|
232
235
|
}
|
|
233
236
|
function extractActorFromRecord(record) {
|