patchrelay 0.29.2 → 0.30.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/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/TimelineRow.js +2 -1
- package/dist/cli/watch/timeline-presentation.js +73 -5
- package/dist/cli/watch/use-watch-stream.js +24 -0
- package/dist/cli/watch/watch-state.js +7 -1
- package/dist/db/migrations.js +13 -0
- package/dist/db/shared.js +1 -0
- package/dist/db.js +101 -1
- package/dist/http.js +3 -0
- package/dist/linear-client.js +79 -110
- package/dist/service.js +33 -2
- package/dist/webhook-handler.js +97 -21
- package/dist/webhooks.js +5 -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
|
}
|
|
@@ -74,7 +74,8 @@ function verboseItemLabel(type) {
|
|
|
74
74
|
}
|
|
75
75
|
function FeedRow({ entry }) {
|
|
76
76
|
const label = entry.feed.status ?? entry.feed.feedKind;
|
|
77
|
-
|
|
77
|
+
const repeatSuffix = entry.repeatCount && entry.repeatCount > 1 ? ` ×${entry.repeatCount}` : "";
|
|
78
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { color: "cyan", bold: true, children: label.padEnd(12) })] }), _jsx(Box, { paddingLeft: 6, children: _jsxs(Text, { wrap: "wrap", children: [entry.feed.summary, repeatSuffix] }) })] }));
|
|
78
79
|
}
|
|
79
80
|
function RunRow({ entry, mode, }) {
|
|
80
81
|
const run = entry.run;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export function buildTimelineRows(entries, mode) {
|
|
2
|
-
|
|
2
|
+
const rows = mode === "compact" ? buildCompactTimelineRows(entries) : buildVerboseTimelineRows(entries);
|
|
3
|
+
return collapseRepeatedFeedRows(rows);
|
|
3
4
|
}
|
|
4
5
|
function buildVerboseTimelineRows(entries) {
|
|
5
6
|
const rows = [];
|
|
@@ -43,6 +44,9 @@ function buildVerboseTimelineRows(entries) {
|
|
|
43
44
|
}
|
|
44
45
|
switch (entry.kind) {
|
|
45
46
|
case "feed":
|
|
47
|
+
if (shouldHideFeed(entry.feed)) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
46
50
|
rows.push({
|
|
47
51
|
id: entry.id,
|
|
48
52
|
kind: "feed",
|
|
@@ -79,9 +83,9 @@ function buildVerboseTimelineRows(entries) {
|
|
|
79
83
|
finalized: run.items.every((item) => item.status !== "inProgress") && run.run.status !== "running",
|
|
80
84
|
run: { ...run.run, ...(run.endedAt ? { endedAt: run.endedAt } : {}) },
|
|
81
85
|
details: [],
|
|
82
|
-
items: entries
|
|
86
|
+
items: summarizeVerboseItems(entries
|
|
83
87
|
.filter((entry) => entry.kind === "item" && entry.runId === runId)
|
|
84
|
-
.map((entry) => ({ at: entry.at, item: entry.item })),
|
|
88
|
+
.map((entry) => ({ at: entry.at, item: entry.item }))),
|
|
85
89
|
});
|
|
86
90
|
}
|
|
87
91
|
rows.sort((left, right) => {
|
|
@@ -135,7 +139,7 @@ function buildCompactTimelineRows(entries) {
|
|
|
135
139
|
runs.get(entry.runId).items.push(entry.item);
|
|
136
140
|
continue;
|
|
137
141
|
}
|
|
138
|
-
if (entry.kind === "feed" &&
|
|
142
|
+
if (entry.kind === "feed" && shouldHideFeed(entry.feed)) {
|
|
139
143
|
continue;
|
|
140
144
|
}
|
|
141
145
|
if (entry.kind === "feed") {
|
|
@@ -191,15 +195,25 @@ function buildCompactTimelineRows(entries) {
|
|
|
191
195
|
});
|
|
192
196
|
return rows;
|
|
193
197
|
}
|
|
194
|
-
function
|
|
198
|
+
function shouldHideFeed(feed) {
|
|
195
199
|
if (feed.feedKind === "stage" && feed.status === "starting") {
|
|
196
200
|
return true;
|
|
197
201
|
}
|
|
202
|
+
if (feed.feedKind === "stage" && feed.status === "reconciled" && isNoOpReconciliation(feed.summary)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
198
205
|
if (feed.feedKind === "turn" && (feed.status === "completed" || feed.status === "failed")) {
|
|
199
206
|
return true;
|
|
200
207
|
}
|
|
208
|
+
if (feed.feedKind === "queue" && feed.status === "queue_label_requested") {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
201
211
|
return false;
|
|
202
212
|
}
|
|
213
|
+
function isNoOpReconciliation(summary) {
|
|
214
|
+
const match = summary.match(/^Reconciliation:\s+([a-z_]+)\s+→\s+([a-z_]+)$/i);
|
|
215
|
+
return Boolean(match?.[1] && match[1] === match[2]);
|
|
216
|
+
}
|
|
203
217
|
function resolveCompactRunStatus(run, items) {
|
|
204
218
|
if (run.endedAt || run.status === "completed" || run.status === "failed" || run.status === "released") {
|
|
205
219
|
return run.status;
|
|
@@ -321,6 +335,60 @@ function rowKindOrder(kind) {
|
|
|
321
335
|
return 3;
|
|
322
336
|
}
|
|
323
337
|
}
|
|
338
|
+
function summarizeVerboseItems(items) {
|
|
339
|
+
const directTypes = new Set(["userMessage", "commandExecution", "fileChange", "plan"]);
|
|
340
|
+
const kept = items.filter((entry) => directTypes.has(entry.item.type));
|
|
341
|
+
const latestAgentMessage = findLatestVerboseItem(items, (entry) => entry.item.type === "agentMessage" && Boolean(entry.item.text?.trim()));
|
|
342
|
+
if (latestAgentMessage) {
|
|
343
|
+
kept.push(latestAgentMessage);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
const latestReasoning = findLatestVerboseItem(items, (entry) => entry.item.type === "reasoning" && Boolean(entry.item.text?.trim()));
|
|
347
|
+
if (latestReasoning) {
|
|
348
|
+
kept.push(latestReasoning);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const deduped = new Map();
|
|
352
|
+
for (const entry of kept) {
|
|
353
|
+
deduped.set(entry.item.id, entry);
|
|
354
|
+
}
|
|
355
|
+
return Array.from(deduped.values()).sort((left, right) => {
|
|
356
|
+
const cmp = left.at.localeCompare(right.at);
|
|
357
|
+
if (cmp !== 0)
|
|
358
|
+
return cmp;
|
|
359
|
+
return left.item.id.localeCompare(right.item.id);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
function findLatestVerboseItem(items, predicate) {
|
|
363
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
364
|
+
const item = items[i];
|
|
365
|
+
if (predicate(item)) {
|
|
366
|
+
return item;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
function collapseRepeatedFeedRows(rows) {
|
|
372
|
+
const collapsed = [];
|
|
373
|
+
for (const row of rows) {
|
|
374
|
+
if (row.kind !== "feed") {
|
|
375
|
+
collapsed.push(row);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const previous = collapsed.at(-1);
|
|
379
|
+
if (previous?.kind === "feed"
|
|
380
|
+
&& previous.feed.feedKind === row.feed.feedKind
|
|
381
|
+
&& previous.feed.status === row.feed.status
|
|
382
|
+
&& previous.feed.summary === row.feed.summary
|
|
383
|
+
&& previous.feed.detail === row.feed.detail) {
|
|
384
|
+
previous.at = row.at;
|
|
385
|
+
previous.repeatCount = (previous.repeatCount ?? 1) + 1;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
collapsed.push({ ...row, ...(row.repeatCount ? { repeatCount: row.repeatCount } : {}) });
|
|
389
|
+
}
|
|
390
|
+
return collapsed;
|
|
391
|
+
}
|
|
324
392
|
function cleanCommand(raw) {
|
|
325
393
|
const bashMatch = raw.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+['"](.+?)['"]$/s);
|
|
326
394
|
if (bashMatch?.[1])
|
|
@@ -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
|
@@ -145,6 +145,17 @@ CREATE TABLE IF NOT EXISTS operator_feed_events (
|
|
|
145
145
|
status TEXT
|
|
146
146
|
);
|
|
147
147
|
|
|
148
|
+
CREATE TABLE IF NOT EXISTS issue_dependencies (
|
|
149
|
+
project_id TEXT NOT NULL,
|
|
150
|
+
linear_issue_id TEXT NOT NULL,
|
|
151
|
+
blocker_linear_issue_id TEXT NOT NULL,
|
|
152
|
+
blocker_issue_key TEXT,
|
|
153
|
+
blocker_title TEXT,
|
|
154
|
+
blocker_current_linear_state TEXT,
|
|
155
|
+
updated_at TEXT NOT NULL,
|
|
156
|
+
PRIMARY KEY (project_id, linear_issue_id, blocker_linear_issue_id)
|
|
157
|
+
);
|
|
158
|
+
|
|
148
159
|
CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, linear_issue_id);
|
|
149
160
|
CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
|
|
150
161
|
CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
|
|
@@ -158,6 +169,8 @@ CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_eve
|
|
|
158
169
|
CREATE INDEX IF NOT EXISTS idx_repository_links_installation ON repository_links(installation_id, github_repo);
|
|
159
170
|
CREATE INDEX IF NOT EXISTS idx_linear_catalog_teams_installation ON linear_catalog_teams(installation_id, team_key, team_name);
|
|
160
171
|
CREATE INDEX IF NOT EXISTS idx_linear_catalog_projects_installation ON linear_catalog_projects(installation_id, project_name);
|
|
172
|
+
CREATE INDEX IF NOT EXISTS idx_issue_dependencies_issue ON issue_dependencies(project_id, linear_issue_id);
|
|
173
|
+
CREATE INDEX IF NOT EXISTS idx_issue_dependencies_blocker ON issue_dependencies(project_id, blocker_linear_issue_id);
|
|
161
174
|
`;
|
|
162
175
|
export function runPatchRelayMigrations(connection) {
|
|
163
176
|
connection.exec(schema);
|
package/dist/db/shared.js
CHANGED
package/dist/db.js
CHANGED
|
@@ -279,9 +279,100 @@ export class PatchRelayDatabase {
|
|
|
279
279
|
const row = this.connection.prepare("SELECT * FROM issues WHERE pr_number = ?").get(prNumber);
|
|
280
280
|
return row ? mapIssueRow(row) : undefined;
|
|
281
281
|
}
|
|
282
|
+
replaceIssueDependencies(params) {
|
|
283
|
+
const now = isoNow();
|
|
284
|
+
this.connection
|
|
285
|
+
.prepare("DELETE FROM issue_dependencies WHERE project_id = ? AND linear_issue_id = ?")
|
|
286
|
+
.run(params.projectId, params.linearIssueId);
|
|
287
|
+
if (params.blockers.length === 0) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const insert = this.connection.prepare(`
|
|
291
|
+
INSERT INTO issue_dependencies (
|
|
292
|
+
project_id,
|
|
293
|
+
linear_issue_id,
|
|
294
|
+
blocker_linear_issue_id,
|
|
295
|
+
blocker_issue_key,
|
|
296
|
+
blocker_title,
|
|
297
|
+
blocker_current_linear_state,
|
|
298
|
+
updated_at
|
|
299
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
300
|
+
`);
|
|
301
|
+
for (const blocker of params.blockers) {
|
|
302
|
+
insert.run(params.projectId, params.linearIssueId, blocker.blockerLinearIssueId, blocker.blockerIssueKey ?? null, blocker.blockerTitle ?? null, blocker.blockerCurrentLinearState ?? null, now);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
listIssueDependencies(projectId, linearIssueId) {
|
|
306
|
+
const rows = this.connection.prepare(`
|
|
307
|
+
SELECT
|
|
308
|
+
d.project_id,
|
|
309
|
+
d.linear_issue_id,
|
|
310
|
+
d.blocker_linear_issue_id,
|
|
311
|
+
COALESCE(blockers.issue_key, d.blocker_issue_key) AS blocker_issue_key,
|
|
312
|
+
COALESCE(blockers.title, d.blocker_title) AS blocker_title,
|
|
313
|
+
COALESCE(blockers.current_linear_state, d.blocker_current_linear_state) AS blocker_current_linear_state,
|
|
314
|
+
d.updated_at
|
|
315
|
+
FROM issue_dependencies d
|
|
316
|
+
LEFT JOIN issues blockers
|
|
317
|
+
ON blockers.project_id = d.project_id
|
|
318
|
+
AND blockers.linear_issue_id = d.blocker_linear_issue_id
|
|
319
|
+
WHERE d.project_id = ? AND d.linear_issue_id = ?
|
|
320
|
+
ORDER BY COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id) ASC
|
|
321
|
+
`).all(projectId, linearIssueId);
|
|
322
|
+
return rows.map((row) => ({
|
|
323
|
+
projectId: String(row.project_id),
|
|
324
|
+
linearIssueId: String(row.linear_issue_id),
|
|
325
|
+
blockerLinearIssueId: String(row.blocker_linear_issue_id),
|
|
326
|
+
...(row.blocker_issue_key !== null && row.blocker_issue_key !== undefined ? { blockerIssueKey: String(row.blocker_issue_key) } : {}),
|
|
327
|
+
...(row.blocker_title !== null && row.blocker_title !== undefined ? { blockerTitle: String(row.blocker_title) } : {}),
|
|
328
|
+
...(row.blocker_current_linear_state !== null && row.blocker_current_linear_state !== undefined
|
|
329
|
+
? { blockerCurrentLinearState: String(row.blocker_current_linear_state) }
|
|
330
|
+
: {}),
|
|
331
|
+
updatedAt: String(row.updated_at),
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
listDependents(projectId, blockerLinearIssueId) {
|
|
335
|
+
const rows = this.connection.prepare(`
|
|
336
|
+
SELECT project_id, linear_issue_id
|
|
337
|
+
FROM issue_dependencies
|
|
338
|
+
WHERE project_id = ? AND blocker_linear_issue_id = ?
|
|
339
|
+
ORDER BY linear_issue_id ASC
|
|
340
|
+
`).all(projectId, blockerLinearIssueId);
|
|
341
|
+
return rows.map((row) => ({
|
|
342
|
+
projectId: String(row.project_id),
|
|
343
|
+
linearIssueId: String(row.linear_issue_id),
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
countUnresolvedBlockers(projectId, linearIssueId) {
|
|
347
|
+
const row = this.connection.prepare(`
|
|
348
|
+
SELECT COUNT(*) AS count
|
|
349
|
+
FROM issue_dependencies d
|
|
350
|
+
LEFT JOIN issues blockers
|
|
351
|
+
ON blockers.project_id = d.project_id
|
|
352
|
+
AND blockers.linear_issue_id = d.blocker_linear_issue_id
|
|
353
|
+
WHERE d.project_id = ? AND d.linear_issue_id = ?
|
|
354
|
+
AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
|
|
355
|
+
`).get(projectId, linearIssueId);
|
|
356
|
+
return Number(row?.count ?? 0);
|
|
357
|
+
}
|
|
282
358
|
listIssuesReadyForExecution() {
|
|
283
359
|
const rows = this.connection
|
|
284
|
-
.prepare(
|
|
360
|
+
.prepare(`
|
|
361
|
+
SELECT i.project_id, i.linear_issue_id
|
|
362
|
+
FROM issues i
|
|
363
|
+
WHERE i.pending_run_type IS NOT NULL
|
|
364
|
+
AND i.active_run_id IS NULL
|
|
365
|
+
AND NOT EXISTS (
|
|
366
|
+
SELECT 1
|
|
367
|
+
FROM issue_dependencies d
|
|
368
|
+
LEFT JOIN issues blockers
|
|
369
|
+
ON blockers.project_id = d.project_id
|
|
370
|
+
AND blockers.linear_issue_id = d.blocker_linear_issue_id
|
|
371
|
+
WHERE d.project_id = i.project_id
|
|
372
|
+
AND d.linear_issue_id = i.linear_issue_id
|
|
373
|
+
AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
|
|
374
|
+
)
|
|
375
|
+
`)
|
|
285
376
|
.all();
|
|
286
377
|
return rows.map((row) => ({
|
|
287
378
|
projectId: String(row.project_id),
|
|
@@ -399,6 +490,7 @@ export class PatchRelayDatabase {
|
|
|
399
490
|
}
|
|
400
491
|
// ─── View builders ──────────────────────────────────────────────
|
|
401
492
|
issueToTrackedIssue(issue) {
|
|
493
|
+
const blockedBy = this.listIssueDependencies(issue.projectId, issue.linearIssueId);
|
|
402
494
|
return {
|
|
403
495
|
id: issue.id,
|
|
404
496
|
projectId: issue.projectId,
|
|
@@ -408,6 +500,11 @@ export class PatchRelayDatabase {
|
|
|
408
500
|
...(issue.url ? { issueUrl: issue.url } : {}),
|
|
409
501
|
...(issue.currentLinearState ? { currentLinearState: issue.currentLinearState } : {}),
|
|
410
502
|
factoryState: issue.factoryState,
|
|
503
|
+
blockedByCount: blockedBy.filter((entry) => !isDoneState(entry.blockerCurrentLinearState)).length,
|
|
504
|
+
blockedByKeys: blockedBy
|
|
505
|
+
.filter((entry) => !isDoneState(entry.blockerCurrentLinearState))
|
|
506
|
+
.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId),
|
|
507
|
+
readyForExecution: issue.pendingRunType !== undefined && issue.activeRunId === undefined,
|
|
411
508
|
...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
|
|
412
509
|
...(issue.agentSessionId ? { activeAgentSessionId: issue.agentSessionId } : {}),
|
|
413
510
|
updatedAt: issue.updatedAt,
|
|
@@ -505,3 +602,6 @@ function mapRunRow(row) {
|
|
|
505
602
|
...(row.ended_at !== null ? { endedAt: String(row.ended_at) } : {}),
|
|
506
603
|
};
|
|
507
604
|
}
|
|
605
|
+
function isDoneState(stateName) {
|
|
606
|
+
return stateName?.trim().toLowerCase() === "done";
|
|
607
|
+
}
|
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,69 @@
|
|
|
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
|
+
}
|
|
19
|
+
labels {
|
|
20
|
+
nodes {
|
|
21
|
+
id
|
|
22
|
+
name
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
blockedBy {
|
|
26
|
+
nodes {
|
|
27
|
+
id
|
|
28
|
+
identifier
|
|
29
|
+
title
|
|
30
|
+
state {
|
|
31
|
+
id
|
|
32
|
+
name
|
|
33
|
+
type
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
blocks {
|
|
38
|
+
nodes {
|
|
39
|
+
id
|
|
40
|
+
identifier
|
|
41
|
+
title
|
|
42
|
+
state {
|
|
43
|
+
id
|
|
44
|
+
name
|
|
45
|
+
type
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
team {
|
|
50
|
+
id
|
|
51
|
+
key
|
|
52
|
+
states {
|
|
53
|
+
nodes {
|
|
54
|
+
id
|
|
55
|
+
name
|
|
56
|
+
type
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
labels {
|
|
60
|
+
nodes {
|
|
61
|
+
id
|
|
62
|
+
name
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
3
67
|
export class LinearGraphqlClient {
|
|
4
68
|
options;
|
|
5
69
|
logger;
|
|
@@ -11,44 +75,7 @@ export class LinearGraphqlClient {
|
|
|
11
75
|
const response = await this.request(`
|
|
12
76
|
query PatchRelayIssue($id: String!) {
|
|
13
77
|
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
|
-
}
|
|
78
|
+
${LINEAR_ISSUE_SELECTION}
|
|
52
79
|
}
|
|
53
80
|
}
|
|
54
81
|
`, { id: issueId });
|
|
@@ -68,44 +95,7 @@ export class LinearGraphqlClient {
|
|
|
68
95
|
issueUpdate(id: $id, input: { stateId: $stateId }) {
|
|
69
96
|
success
|
|
70
97
|
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
|
-
}
|
|
98
|
+
${LINEAR_ISSUE_SELECTION}
|
|
109
99
|
}
|
|
110
100
|
}
|
|
111
101
|
}
|
|
@@ -208,40 +198,7 @@ export class LinearGraphqlClient {
|
|
|
208
198
|
issueUpdate(id: $id, input: { addedLabelIds: $addedLabelIds, removedLabelIds: $removedLabelIds }) {
|
|
209
199
|
success
|
|
210
200
|
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
|
-
}
|
|
201
|
+
${LINEAR_ISSUE_SELECTION}
|
|
245
202
|
}
|
|
246
203
|
}
|
|
247
204
|
}
|
|
@@ -372,6 +329,8 @@ export class LinearGraphqlClient {
|
|
|
372
329
|
labelIds: labels.map((label) => label.id),
|
|
373
330
|
labels,
|
|
374
331
|
teamLabels,
|
|
332
|
+
blockedBy: (issue.blockedBy?.nodes ?? []).map(mapIssueRelation),
|
|
333
|
+
blocks: (issue.blocks?.nodes ?? []).map(mapIssueRelation),
|
|
375
334
|
};
|
|
376
335
|
}
|
|
377
336
|
resolveLabelIds(issue, names) {
|
|
@@ -389,6 +348,16 @@ export class LinearGraphqlClient {
|
|
|
389
348
|
return labelIds;
|
|
390
349
|
}
|
|
391
350
|
}
|
|
351
|
+
function mapIssueRelation(raw) {
|
|
352
|
+
return {
|
|
353
|
+
id: raw.id,
|
|
354
|
+
...(raw.identifier ? { identifier: raw.identifier } : {}),
|
|
355
|
+
...(raw.title ? { title: raw.title } : {}),
|
|
356
|
+
...(raw.state?.id ? { stateId: raw.state.id } : {}),
|
|
357
|
+
...(raw.state?.name ? { stateName: raw.state.name } : {}),
|
|
358
|
+
...(raw.state?.type ? { stateType: raw.state.type } : {}),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
392
361
|
export class DatabaseBackedLinearClientProvider {
|
|
393
362
|
config;
|
|
394
363
|
db;
|
package/dist/service.js
CHANGED
|
@@ -214,12 +214,33 @@ 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 LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
|
|
233
|
+
) AS blocked_by_count,
|
|
234
|
+
(
|
|
235
|
+
SELECT json_group_array(COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id))
|
|
236
|
+
FROM issue_dependencies d
|
|
237
|
+
LEFT JOIN issues blockers
|
|
238
|
+
ON blockers.project_id = d.project_id
|
|
239
|
+
AND blockers.linear_issue_id = d.blocker_linear_issue_id
|
|
240
|
+
WHERE d.project_id = i.project_id
|
|
241
|
+
AND d.linear_issue_id = i.linear_issue_id
|
|
242
|
+
AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
|
|
243
|
+
) AS blocked_by_keys_json
|
|
223
244
|
FROM issues i
|
|
224
245
|
LEFT JOIN runs active_run ON active_run.id = i.active_run_id
|
|
225
246
|
LEFT JOIN runs latest_run ON latest_run.id = (
|
|
@@ -231,14 +252,24 @@ export class PatchRelayService {
|
|
|
231
252
|
.all();
|
|
232
253
|
return rows.map((row) => {
|
|
233
254
|
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);
|
|
255
|
+
const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
|
|
256
|
+
const blockedByCount = Number(row.blocked_by_count ?? 0);
|
|
257
|
+
const readyForExecution = row.pending_run_type !== null && row.pending_run_type !== undefined && row.active_run_type === null && blockedByCount === 0;
|
|
258
|
+
const statusNoteWithBlockers = blockedByCount > 0
|
|
259
|
+
? `Blocked by ${blockedByKeys.join(", ")}`
|
|
260
|
+
: statusNote;
|
|
234
261
|
return {
|
|
235
262
|
...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
|
|
236
263
|
...(row.title !== null ? { title: String(row.title) } : {}),
|
|
237
|
-
...(
|
|
264
|
+
...(statusNoteWithBlockers ? { statusNote: statusNoteWithBlockers } : {}),
|
|
238
265
|
projectId: String(row.project_id),
|
|
239
266
|
factoryState: String(row.factory_state ?? "delegated"),
|
|
267
|
+
blockedByCount,
|
|
268
|
+
blockedByKeys,
|
|
269
|
+
readyForExecution,
|
|
240
270
|
...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
|
|
241
271
|
...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
|
|
272
|
+
...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
|
|
242
273
|
...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
|
|
243
274
|
...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
|
|
244
275
|
...(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,15 @@ 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 } : {}),
|
|
169
183
|
...(pendingRunType ? { pendingRunType, factoryState: "delegated" } : {}),
|
|
184
|
+
...(clearPendingImplementation ? { pendingRunType: null } : {}),
|
|
170
185
|
...((pendingRunType || existingIssue?.pendingRunType === "implementation") && pendingRunContextJson
|
|
171
186
|
? { pendingRunContextJson }
|
|
172
187
|
: {}),
|
|
@@ -186,6 +201,61 @@ export class WebhookHandler {
|
|
|
186
201
|
return false;
|
|
187
202
|
return normalized.issue.delegateId === installation.actorId;
|
|
188
203
|
}
|
|
204
|
+
async syncIssueDependencies(projectId, issue) {
|
|
205
|
+
let source = issue;
|
|
206
|
+
if (source.blockedBy.length === 0 && source.blocks.length === 0) {
|
|
207
|
+
const linear = await this.linearProvider.forProject(projectId);
|
|
208
|
+
if (linear) {
|
|
209
|
+
try {
|
|
210
|
+
source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Fall back to webhook payload data when live hydration is unavailable.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
this.db.replaceIssueDependencies({
|
|
218
|
+
projectId,
|
|
219
|
+
linearIssueId: source.id,
|
|
220
|
+
blockers: source.blockedBy.map((blocker) => ({
|
|
221
|
+
blockerLinearIssueId: blocker.id,
|
|
222
|
+
...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
|
|
223
|
+
...(blocker.title ? { blockerTitle: blocker.title } : {}),
|
|
224
|
+
...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
|
|
225
|
+
})),
|
|
226
|
+
});
|
|
227
|
+
return source;
|
|
228
|
+
}
|
|
229
|
+
reconcileDependentReadiness(projectId, blockerLinearIssueId) {
|
|
230
|
+
const newlyReady = [];
|
|
231
|
+
for (const dependent of this.db.listDependents(projectId, blockerLinearIssueId)) {
|
|
232
|
+
const issue = this.db.getIssue(projectId, dependent.linearIssueId);
|
|
233
|
+
if (!issue) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const unresolved = this.db.countUnresolvedBlockers(projectId, dependent.linearIssueId);
|
|
237
|
+
if (unresolved > 0) {
|
|
238
|
+
if (issue.pendingRunType === "implementation" && issue.activeRunId === undefined) {
|
|
239
|
+
this.db.upsertIssue({
|
|
240
|
+
projectId,
|
|
241
|
+
linearIssueId: dependent.linearIssueId,
|
|
242
|
+
pendingRunType: null,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined || issue.pendingRunType !== undefined) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
this.db.upsertIssue({
|
|
251
|
+
projectId,
|
|
252
|
+
linearIssueId: dependent.linearIssueId,
|
|
253
|
+
pendingRunType: "implementation",
|
|
254
|
+
});
|
|
255
|
+
newlyReady.push(dependent.linearIssueId);
|
|
256
|
+
}
|
|
257
|
+
return newlyReady;
|
|
258
|
+
}
|
|
189
259
|
// ─── Agent session handling (inlined) ─────────────────────────────
|
|
190
260
|
async handleAgentSession(normalized, project, trackedIssue, desiredStage, delegated) {
|
|
191
261
|
if (!normalized.agentSession?.id || !normalized.issue)
|
|
@@ -213,9 +283,12 @@ export class WebhookHandler {
|
|
|
213
283
|
await this.publishAgentActivity(linear, normalized.agentSession.id, buildAlreadyRunningThought(activeRun.runType));
|
|
214
284
|
return;
|
|
215
285
|
}
|
|
286
|
+
const blockerSummary = trackedIssue?.blockedByCount
|
|
287
|
+
? `PatchRelay is delegated and waiting on blockers to reach Done: ${trackedIssue.blockedByKeys.join(", ")}.`
|
|
288
|
+
: "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.";
|
|
216
289
|
await this.publishAgentActivity(linear, normalized.agentSession.id, {
|
|
217
290
|
type: "elicitation",
|
|
218
|
-
body:
|
|
291
|
+
body: blockerSummary,
|
|
219
292
|
});
|
|
220
293
|
return;
|
|
221
294
|
}
|
|
@@ -423,9 +496,10 @@ export class WebhookHandler {
|
|
|
423
496
|
async hydrateIssueContext(projectId, normalized) {
|
|
424
497
|
if (!normalized.issue)
|
|
425
498
|
return normalized;
|
|
426
|
-
if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted")
|
|
499
|
+
if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted" && normalized.entityType !== "Issue") {
|
|
427
500
|
return normalized;
|
|
428
|
-
|
|
501
|
+
}
|
|
502
|
+
if (normalized.entityType !== "Issue" && hasCompleteIssueContext(normalized.issue))
|
|
429
503
|
return normalized;
|
|
430
504
|
const linear = await this.linearProvider.forProject(projectId);
|
|
431
505
|
if (!linear)
|
|
@@ -488,5 +562,7 @@ function mergeIssueMetadata(issue, liveIssue) {
|
|
|
488
562
|
...(issue.delegateId ? {} : liveIssue.delegateId ? { delegateId: liveIssue.delegateId } : {}),
|
|
489
563
|
...(issue.delegateName ? {} : liveIssue.delegateName ? { delegateName: liveIssue.delegateName } : {}),
|
|
490
564
|
labelNames: issue.labelNames.length > 0 ? issue.labelNames : (liveIssue.labels ?? []).map((l) => l.name),
|
|
565
|
+
blockedBy: issue.blockedBy.length > 0 ? issue.blockedBy : (liveIssue.blockedBy ?? []),
|
|
566
|
+
blocks: issue.blocks.length > 0 ? issue.blocks : (liveIssue.blocks ?? []),
|
|
491
567
|
};
|
|
492
568
|
}
|
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") {
|
|
@@ -228,6 +228,8 @@ function extractIssueMetadata(payload) {
|
|
|
228
228
|
...(priority != null ? { priority } : {}),
|
|
229
229
|
...(estimate != null ? { estimate } : {}),
|
|
230
230
|
labelNames: extractLabelNames(issueRecord),
|
|
231
|
+
blockedBy: [],
|
|
232
|
+
blocks: [],
|
|
231
233
|
};
|
|
232
234
|
}
|
|
233
235
|
function extractActorFromRecord(record) {
|