patchrelay 0.35.12 → 0.35.14
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/args.js +19 -0
- package/dist/cli/commands/issues.js +17 -1
- package/dist/cli/data.js +50 -0
- package/dist/cli/formatters/text.js +45 -0
- package/dist/cli/help.js +12 -0
- package/dist/cli/index.js +3 -10
- package/dist/cli/watch/App.js +22 -2
- package/dist/cli/watch/HelpBar.js +1 -1
- package/dist/cli/watch/IssueDetailView.js +63 -161
- package/dist/cli/watch/IssueRow.js +25 -28
- package/dist/cli/watch/StatusBar.js +2 -1
- package/dist/cli/watch/detail-rows.js +590 -0
- package/dist/cli/watch/pr-status.js +74 -0
- package/dist/cli/watch/render-rich-text.js +225 -0
- package/dist/cli/watch/watch-state.js +111 -6
- package/dist/github-webhook-handler.js +176 -2
- package/dist/github-webhooks.js +2 -0
- package/dist/linear-session-sync.js +1 -1
- package/dist/run-orchestrator.js +75 -7
- package/dist/status-note.js +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
const richTextCache = new Map();
|
|
2
|
+
export function lineToPlainText(line) {
|
|
3
|
+
return line.segments.map((segment) => segment.text).join("");
|
|
4
|
+
}
|
|
5
|
+
export function renderTextLines(text, options) {
|
|
6
|
+
const width = Math.max(8, options.width);
|
|
7
|
+
const sourceLines = text.length === 0 ? [""] : text.split("\n");
|
|
8
|
+
const lines = [];
|
|
9
|
+
for (let index = 0; index < sourceLines.length; index += 1) {
|
|
10
|
+
const sourceLine = sourceLines[index] ?? "";
|
|
11
|
+
const wrapped = wrapSegments(tokenizeSegments([{ text: sourceLine, ...(options.style ?? {}) }]), width, index === 0 ? options.firstPrefix : options.continuationPrefix ?? options.firstPrefix, options.continuationPrefix ?? options.firstPrefix, `${options.key}-${index}`);
|
|
12
|
+
lines.push(...wrapped);
|
|
13
|
+
}
|
|
14
|
+
return lines.length > 0 ? lines : [{ key: `${options.key}-0`, segments: [] }];
|
|
15
|
+
}
|
|
16
|
+
export function renderRichTextLines(text, options) {
|
|
17
|
+
const width = Math.max(8, options.width);
|
|
18
|
+
const cacheKey = [
|
|
19
|
+
options.key,
|
|
20
|
+
width,
|
|
21
|
+
segmentsKey(options.firstPrefix),
|
|
22
|
+
segmentsKey(options.continuationPrefix),
|
|
23
|
+
styleKey(options.style),
|
|
24
|
+
options.codeColor ?? "",
|
|
25
|
+
text,
|
|
26
|
+
].join("\u0000");
|
|
27
|
+
const cached = richTextCache.get(cacheKey);
|
|
28
|
+
if (cached) {
|
|
29
|
+
return cached;
|
|
30
|
+
}
|
|
31
|
+
const lines = [];
|
|
32
|
+
const inputLines = text.replace(/\r\n/g, "\n").split("\n");
|
|
33
|
+
let paragraph = [];
|
|
34
|
+
let inCodeBlock = false;
|
|
35
|
+
let codeLines = [];
|
|
36
|
+
let blockIndex = 0;
|
|
37
|
+
const flushParagraph = () => {
|
|
38
|
+
if (paragraph.length === 0)
|
|
39
|
+
return;
|
|
40
|
+
const paragraphText = paragraph.join(" ").replace(/\s+/g, " ").trim();
|
|
41
|
+
if (paragraphText.length > 0) {
|
|
42
|
+
lines.push(...wrapSegments(tokenizeSegments(parseInlineMarkdown(paragraphText, options.style)), width, lines.length === 0 ? options.firstPrefix : options.continuationPrefix ?? options.firstPrefix, options.continuationPrefix ?? options.firstPrefix, `${options.key}-p-${blockIndex}`));
|
|
43
|
+
blockIndex += 1;
|
|
44
|
+
}
|
|
45
|
+
paragraph = [];
|
|
46
|
+
};
|
|
47
|
+
const flushCodeBlock = () => {
|
|
48
|
+
if (codeLines.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
for (const codeLine of codeLines) {
|
|
51
|
+
lines.push(...renderTextLines(codeLine, {
|
|
52
|
+
key: `${options.key}-code-${blockIndex}`,
|
|
53
|
+
width,
|
|
54
|
+
firstPrefix: lines.length === 0 ? options.firstPrefix : options.continuationPrefix ?? options.firstPrefix,
|
|
55
|
+
continuationPrefix: options.continuationPrefix ?? options.firstPrefix,
|
|
56
|
+
style: { color: options.codeColor ?? "green" },
|
|
57
|
+
}));
|
|
58
|
+
blockIndex += 1;
|
|
59
|
+
}
|
|
60
|
+
codeLines = [];
|
|
61
|
+
};
|
|
62
|
+
const pushBlankLine = () => {
|
|
63
|
+
lines.push({ key: `${options.key}-blank-${blockIndex}`, segments: [] });
|
|
64
|
+
blockIndex += 1;
|
|
65
|
+
};
|
|
66
|
+
for (const rawLine of inputLines) {
|
|
67
|
+
const line = rawLine ?? "";
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (trimmed.startsWith("```")) {
|
|
70
|
+
flushParagraph();
|
|
71
|
+
if (inCodeBlock) {
|
|
72
|
+
flushCodeBlock();
|
|
73
|
+
inCodeBlock = false;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
inCodeBlock = true;
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (inCodeBlock) {
|
|
81
|
+
codeLines.push(line);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (trimmed.length === 0) {
|
|
85
|
+
flushParagraph();
|
|
86
|
+
if (lines.length > 0) {
|
|
87
|
+
pushBlankLine();
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const bulletMatch = line.match(/^\s*[-*]\s+(.*)$/);
|
|
92
|
+
if (bulletMatch?.[1]) {
|
|
93
|
+
flushParagraph();
|
|
94
|
+
lines.push(...wrapSegments(tokenizeSegments(parseInlineMarkdown(bulletMatch[1], options.style)), width, appendSegments(options.firstPrefix, [{ text: "• ", ...(options.style ?? {}) }]), appendSegments(options.continuationPrefix ?? options.firstPrefix, [{ text: " ", ...(options.style ?? {}) }]), `${options.key}-b-${blockIndex}`));
|
|
95
|
+
blockIndex += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
paragraph.push(trimmed);
|
|
99
|
+
}
|
|
100
|
+
flushParagraph();
|
|
101
|
+
flushCodeBlock();
|
|
102
|
+
const result = lines.length > 0 ? lines : [{ key: `${options.key}-0`, segments: [] }];
|
|
103
|
+
richTextCache.set(cacheKey, result);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
function parseInlineMarkdown(text, style) {
|
|
107
|
+
const pattern = /\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`|\*\*([^*]+)\*\*/g;
|
|
108
|
+
const segments = [];
|
|
109
|
+
let lastIndex = 0;
|
|
110
|
+
for (const match of text.matchAll(pattern)) {
|
|
111
|
+
const index = match.index ?? 0;
|
|
112
|
+
if (index > lastIndex) {
|
|
113
|
+
segments.push({ text: text.slice(lastIndex, index), ...(style ?? {}) });
|
|
114
|
+
}
|
|
115
|
+
if (match[1] && match[2]) {
|
|
116
|
+
segments.push({ text: match[1], color: "cyan", bold: true });
|
|
117
|
+
}
|
|
118
|
+
else if (match[3]) {
|
|
119
|
+
segments.push({ text: match[3], color: "yellow", bold: true });
|
|
120
|
+
}
|
|
121
|
+
else if (match[4]) {
|
|
122
|
+
segments.push({ text: match[4], ...(style ?? {}), bold: true });
|
|
123
|
+
}
|
|
124
|
+
lastIndex = index + match[0].length;
|
|
125
|
+
}
|
|
126
|
+
if (lastIndex < text.length) {
|
|
127
|
+
segments.push({ text: text.slice(lastIndex), ...(style ?? {}) });
|
|
128
|
+
}
|
|
129
|
+
return segments.length > 0 ? segments : [{ text, ...(style ?? {}) }];
|
|
130
|
+
}
|
|
131
|
+
function wrapSegments(tokens, width, firstPrefix, continuationPrefix, keyPrefix) {
|
|
132
|
+
const initialPrefix = cloneSegments(firstPrefix);
|
|
133
|
+
const nextPrefix = cloneSegments(continuationPrefix);
|
|
134
|
+
const lines = [];
|
|
135
|
+
let currentSegments = initialPrefix;
|
|
136
|
+
let currentWidth = segmentsWidth(initialPrefix);
|
|
137
|
+
let lineHasContent = false;
|
|
138
|
+
let lineIndex = 0;
|
|
139
|
+
const pushLine = () => {
|
|
140
|
+
lines.push({
|
|
141
|
+
key: `${keyPrefix}-${lineIndex}`,
|
|
142
|
+
segments: trimTrailingSpaces(currentSegments),
|
|
143
|
+
});
|
|
144
|
+
lineIndex += 1;
|
|
145
|
+
currentSegments = cloneSegments(nextPrefix);
|
|
146
|
+
currentWidth = segmentsWidth(currentSegments);
|
|
147
|
+
lineHasContent = false;
|
|
148
|
+
};
|
|
149
|
+
for (const token of tokens) {
|
|
150
|
+
let remaining = token.text.replace(/\t/g, " ");
|
|
151
|
+
while (remaining.length > 0) {
|
|
152
|
+
const whitespace = remaining.match(/^\s+/)?.[0];
|
|
153
|
+
if (whitespace) {
|
|
154
|
+
const spaceText = lineHasContent ? whitespace : "";
|
|
155
|
+
remaining = remaining.slice(whitespace.length);
|
|
156
|
+
if (spaceText.length === 0) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (currentWidth + spaceText.length > width) {
|
|
160
|
+
pushLine();
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
currentSegments.push({ ...token, text: spaceText });
|
|
164
|
+
currentWidth += spaceText.length;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const word = remaining.match(/^\S+/)?.[0] ?? remaining;
|
|
168
|
+
remaining = remaining.slice(word.length);
|
|
169
|
+
let rest = word;
|
|
170
|
+
while (rest.length > 0) {
|
|
171
|
+
const available = Math.max(1, width - currentWidth);
|
|
172
|
+
if (lineHasContent && rest.length > available) {
|
|
173
|
+
pushLine();
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const sliceLength = Math.min(rest.length, available);
|
|
177
|
+
const chunk = rest.slice(0, sliceLength);
|
|
178
|
+
currentSegments.push({ ...token, text: chunk });
|
|
179
|
+
currentWidth += chunk.length;
|
|
180
|
+
lineHasContent = true;
|
|
181
|
+
rest = rest.slice(sliceLength);
|
|
182
|
+
if (rest.length > 0) {
|
|
183
|
+
pushLine();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (lines.length === 0 || currentSegments.length > 0 || lineHasContent) {
|
|
189
|
+
lines.push({
|
|
190
|
+
key: `${keyPrefix}-${lineIndex}`,
|
|
191
|
+
segments: trimTrailingSpaces(currentSegments),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
196
|
+
function tokenizeSegments(segments) {
|
|
197
|
+
return segments.flatMap((segment) => {
|
|
198
|
+
const parts = segment.text.length === 0 ? [""] : segment.text.match(/\s+|\S+/g) ?? [segment.text];
|
|
199
|
+
return parts.map((part) => ({ ...segment, text: part }));
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function cloneSegments(segments) {
|
|
203
|
+
return (segments ?? []).map((segment) => ({ ...segment }));
|
|
204
|
+
}
|
|
205
|
+
function segmentsWidth(segments) {
|
|
206
|
+
return (segments ?? []).reduce((sum, segment) => sum + segment.text.length, 0);
|
|
207
|
+
}
|
|
208
|
+
function trimTrailingSpaces(segments) {
|
|
209
|
+
const trimmed = cloneSegments(segments);
|
|
210
|
+
while (trimmed.length > 0 && trimmed[trimmed.length - 1]?.text.trim().length === 0) {
|
|
211
|
+
trimmed.pop();
|
|
212
|
+
}
|
|
213
|
+
return trimmed;
|
|
214
|
+
}
|
|
215
|
+
function appendSegments(base, extra) {
|
|
216
|
+
return [...cloneSegments(base), ...cloneSegments(extra)];
|
|
217
|
+
}
|
|
218
|
+
function segmentsKey(segments) {
|
|
219
|
+
return (segments ?? []).map((segment) => `${segment.text}|${segment.color ?? ""}|${segment.dimColor ? "d" : ""}|${segment.bold ? "b" : ""}`).join(";");
|
|
220
|
+
}
|
|
221
|
+
function styleKey(style) {
|
|
222
|
+
if (!style)
|
|
223
|
+
return "";
|
|
224
|
+
return `${style.color ?? ""}|${style.dimColor ? "d" : ""}|${style.bold ? "b" : ""}`;
|
|
225
|
+
}
|
|
@@ -8,6 +8,10 @@ function capArray(arr, max) {
|
|
|
8
8
|
}
|
|
9
9
|
const DETAIL_INITIAL = {
|
|
10
10
|
detailTab: "timeline",
|
|
11
|
+
detailScrollOffset: 0,
|
|
12
|
+
detailViewportRows: 0,
|
|
13
|
+
detailContentRows: 0,
|
|
14
|
+
detailUnreadBelow: 0,
|
|
11
15
|
timeline: [],
|
|
12
16
|
rawRuns: [],
|
|
13
17
|
rawFeedEvents: [],
|
|
@@ -79,6 +83,60 @@ function nextFilter(filter) {
|
|
|
79
83
|
case "all": return "non-done";
|
|
80
84
|
}
|
|
81
85
|
}
|
|
86
|
+
function clampIndex(index, length) {
|
|
87
|
+
return Math.max(0, Math.min(index, Math.max(0, length - 1)));
|
|
88
|
+
}
|
|
89
|
+
function selectedIssueKeyForFilter(state) {
|
|
90
|
+
const filtered = filterIssues(state.issues, state.filter);
|
|
91
|
+
return filtered[state.selectedIndex]?.issueKey ?? null;
|
|
92
|
+
}
|
|
93
|
+
function selectedIndexForSnapshot(state, nextIssues) {
|
|
94
|
+
const nextFiltered = filterIssues(nextIssues, state.filter);
|
|
95
|
+
if (nextFiltered.length === 0)
|
|
96
|
+
return 0;
|
|
97
|
+
const selectedIssueKey = selectedIssueKeyForFilter(state);
|
|
98
|
+
if (selectedIssueKey) {
|
|
99
|
+
const selectedIndex = nextFiltered.findIndex((issue) => issue.issueKey === selectedIssueKey);
|
|
100
|
+
if (selectedIndex >= 0)
|
|
101
|
+
return selectedIndex;
|
|
102
|
+
}
|
|
103
|
+
return clampIndex(state.selectedIndex, nextFiltered.length);
|
|
104
|
+
}
|
|
105
|
+
const DETAIL_BOTTOM_THRESHOLD = 2;
|
|
106
|
+
function maxDetailScrollOffset(contentRows, viewportRows) {
|
|
107
|
+
return Math.max(0, contentRows - viewportRows);
|
|
108
|
+
}
|
|
109
|
+
function isDetailNearBottom(scrollOffset, contentRows, viewportRows) {
|
|
110
|
+
const maxOffset = maxDetailScrollOffset(contentRows, viewportRows);
|
|
111
|
+
return scrollOffset >= Math.max(0, maxOffset - DETAIL_BOTTOM_THRESHOLD);
|
|
112
|
+
}
|
|
113
|
+
function detailStateForPosition(state, scrollOffset, follow) {
|
|
114
|
+
const maxOffset = maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows);
|
|
115
|
+
const nextOffset = clampIndex(scrollOffset, maxOffset + 1);
|
|
116
|
+
const nextFollow = follow || isDetailNearBottom(nextOffset, state.detailContentRows, state.detailViewportRows);
|
|
117
|
+
return {
|
|
118
|
+
detailScrollOffset: nextFollow ? maxOffset : nextOffset,
|
|
119
|
+
detailUnreadBelow: nextFollow ? 0 : Math.max(0, maxOffset - nextOffset),
|
|
120
|
+
follow: nextFollow,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function detailStateAfterLayout(state, viewportRows, contentRows) {
|
|
124
|
+
const nextState = {
|
|
125
|
+
...state,
|
|
126
|
+
detailViewportRows: Math.max(0, viewportRows),
|
|
127
|
+
detailContentRows: Math.max(0, contentRows),
|
|
128
|
+
};
|
|
129
|
+
const maxOffset = maxDetailScrollOffset(nextState.detailContentRows, nextState.detailViewportRows);
|
|
130
|
+
const shouldFollow = state.follow || isDetailNearBottom(state.detailScrollOffset, state.detailContentRows, state.detailViewportRows);
|
|
131
|
+
const nextOffset = shouldFollow ? maxOffset : Math.min(state.detailScrollOffset, maxOffset);
|
|
132
|
+
return {
|
|
133
|
+
detailViewportRows: nextState.detailViewportRows,
|
|
134
|
+
detailContentRows: nextState.detailContentRows,
|
|
135
|
+
detailScrollOffset: nextOffset,
|
|
136
|
+
detailUnreadBelow: shouldFollow ? 0 : Math.max(0, maxOffset - nextOffset),
|
|
137
|
+
follow: shouldFollow,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
82
140
|
// ─── Reducer ──────────────────────────────────────────────────────
|
|
83
141
|
export function watchReducer(state, action) {
|
|
84
142
|
switch (action.type) {
|
|
@@ -93,17 +151,17 @@ export function watchReducer(state, action) {
|
|
|
93
151
|
...state,
|
|
94
152
|
lastServerMessageAt: action.receivedAt,
|
|
95
153
|
issues: action.issues,
|
|
96
|
-
selectedIndex:
|
|
154
|
+
selectedIndex: selectedIndexForSnapshot(state, action.issues),
|
|
97
155
|
};
|
|
98
156
|
case "feed-event":
|
|
99
157
|
return applyFeedEvent(state, action.event, action.receivedAt);
|
|
100
158
|
case "select":
|
|
101
159
|
return {
|
|
102
160
|
...state,
|
|
103
|
-
selectedIndex:
|
|
161
|
+
selectedIndex: clampIndex(action.index, filterIssues(state.issues, state.filter).length),
|
|
104
162
|
};
|
|
105
163
|
case "enter-detail":
|
|
106
|
-
return { ...state, view: "detail", activeDetailKey: action.issueKey, ...DETAIL_INITIAL };
|
|
164
|
+
return { ...state, view: "detail", activeDetailKey: action.issueKey, follow: true, ...DETAIL_INITIAL };
|
|
107
165
|
case "exit-detail":
|
|
108
166
|
return { ...state, view: "list", activeDetailKey: null, ...DETAIL_INITIAL };
|
|
109
167
|
case "detail-navigate": {
|
|
@@ -117,8 +175,46 @@ export function watchReducer(state, action) {
|
|
|
117
175
|
const nextIssue = list[nextIdx];
|
|
118
176
|
if (!nextIssue?.issueKey || nextIssue.issueKey === state.activeDetailKey)
|
|
119
177
|
return state;
|
|
120
|
-
return { ...state, activeDetailKey: nextIssue.issueKey, selectedIndex: nextIdx, ...DETAIL_INITIAL };
|
|
178
|
+
return { ...state, activeDetailKey: nextIssue.issueKey, selectedIndex: nextIdx, follow: true, ...DETAIL_INITIAL };
|
|
179
|
+
}
|
|
180
|
+
case "detail-scroll":
|
|
181
|
+
return {
|
|
182
|
+
...state,
|
|
183
|
+
...detailStateForPosition(state, state.detailScrollOffset + action.delta, false),
|
|
184
|
+
};
|
|
185
|
+
case "detail-page": {
|
|
186
|
+
const pageSize = Math.max(1, state.detailViewportRows - 2);
|
|
187
|
+
const delta = action.direction === "down" ? pageSize : -pageSize;
|
|
188
|
+
return {
|
|
189
|
+
...state,
|
|
190
|
+
...detailStateForPosition(state, state.detailScrollOffset + delta, false),
|
|
191
|
+
};
|
|
121
192
|
}
|
|
193
|
+
case "detail-jump":
|
|
194
|
+
return action.target === "end"
|
|
195
|
+
? {
|
|
196
|
+
...state,
|
|
197
|
+
...detailStateForPosition(state, maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows), true),
|
|
198
|
+
}
|
|
199
|
+
: {
|
|
200
|
+
...state,
|
|
201
|
+
...detailStateForPosition(state, 0, false),
|
|
202
|
+
};
|
|
203
|
+
case "detail-layout-updated":
|
|
204
|
+
{
|
|
205
|
+
const nextDetailState = detailStateAfterLayout(state, action.viewportRows, action.contentRows);
|
|
206
|
+
if (nextDetailState.detailViewportRows === state.detailViewportRows
|
|
207
|
+
&& nextDetailState.detailContentRows === state.detailContentRows
|
|
208
|
+
&& nextDetailState.detailScrollOffset === state.detailScrollOffset
|
|
209
|
+
&& nextDetailState.detailUnreadBelow === state.detailUnreadBelow
|
|
210
|
+
&& nextDetailState.follow === state.follow) {
|
|
211
|
+
return state;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
...state,
|
|
215
|
+
...nextDetailState,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
122
218
|
case "timeline-rehydrate": {
|
|
123
219
|
const timeline = buildTimelineFromRehydration(action.runs, action.feedEvents, action.liveThread, action.activeRunId);
|
|
124
220
|
const activeRun = action.runs.find((r) => r.id === action.activeRunId);
|
|
@@ -137,7 +233,16 @@ export function watchReducer(state, action) {
|
|
|
137
233
|
case "cycle-filter":
|
|
138
234
|
return { ...state, filter: nextFilter(state.filter), selectedIndex: 0 };
|
|
139
235
|
case "toggle-follow":
|
|
140
|
-
return
|
|
236
|
+
return state.follow
|
|
237
|
+
? {
|
|
238
|
+
...state,
|
|
239
|
+
follow: false,
|
|
240
|
+
detailUnreadBelow: Math.max(0, maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows) - state.detailScrollOffset),
|
|
241
|
+
}
|
|
242
|
+
: {
|
|
243
|
+
...state,
|
|
244
|
+
...detailStateForPosition(state, maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows), true),
|
|
245
|
+
};
|
|
141
246
|
case "enter-feed":
|
|
142
247
|
return { ...state, view: "feed", activeDetailKey: null, ...DETAIL_INITIAL };
|
|
143
248
|
case "exit-feed":
|
|
@@ -147,7 +252,7 @@ export function watchReducer(state, action) {
|
|
|
147
252
|
case "feed-new-event":
|
|
148
253
|
return { ...state, feedEvents: capArray([...state.feedEvents, action.event], MAX_FEED_EVENTS) };
|
|
149
254
|
case "switch-detail-tab":
|
|
150
|
-
return { ...state, detailTab: action.tab };
|
|
255
|
+
return { ...state, follow: true, ...DETAIL_INITIAL, detailTab: action.tab };
|
|
151
256
|
default:
|
|
152
257
|
return state;
|
|
153
258
|
}
|
|
@@ -32,8 +32,9 @@ export class GitHubWebhookHandler {
|
|
|
32
32
|
feed;
|
|
33
33
|
failureContextResolver;
|
|
34
34
|
ciSnapshotResolver;
|
|
35
|
+
fetchImpl;
|
|
35
36
|
patchRelayAuthorLogins = new Set();
|
|
36
|
-
constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver()) {
|
|
37
|
+
constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver(), fetchImpl = fetch) {
|
|
37
38
|
this.config = config;
|
|
38
39
|
this.db = db;
|
|
39
40
|
this.linearProvider = linearProvider;
|
|
@@ -43,6 +44,7 @@ export class GitHubWebhookHandler {
|
|
|
43
44
|
this.feed = feed;
|
|
44
45
|
this.failureContextResolver = failureContextResolver;
|
|
45
46
|
this.ciSnapshotResolver = ciSnapshotResolver;
|
|
47
|
+
this.fetchImpl = fetchImpl;
|
|
46
48
|
for (const login of resolvePatchRelayAuthorLoginsFromEnv()) {
|
|
47
49
|
this.patchRelayAuthorLogins.add(login);
|
|
48
50
|
}
|
|
@@ -193,6 +195,7 @@ export class GitHubWebhookHandler {
|
|
|
193
195
|
lastAttemptedFailureHeadSha: null,
|
|
194
196
|
lastAttemptedFailureSignature: null,
|
|
195
197
|
});
|
|
198
|
+
await this.maybeRequestRereviewAfterPush(freshIssue, event, project);
|
|
196
199
|
}
|
|
197
200
|
this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
|
|
198
201
|
this.feed?.publish({
|
|
@@ -434,13 +437,26 @@ export class GitHubWebhookHandler {
|
|
|
434
437
|
}
|
|
435
438
|
if (event.triggerEvent === "review_changes_requested") {
|
|
436
439
|
const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
440
|
+
const reviewComments = await this.fetchReviewCommentsForEvent(event).catch((error) => {
|
|
441
|
+
this.logger.warn({
|
|
442
|
+
issueKey: issue.issueKey,
|
|
443
|
+
prNumber: event.prNumber,
|
|
444
|
+
reviewId: event.reviewId,
|
|
445
|
+
error: error instanceof Error ? error.message : String(error),
|
|
446
|
+
}, "Failed to fetch inline review comments for requested-changes event");
|
|
447
|
+
return undefined;
|
|
448
|
+
});
|
|
437
449
|
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
438
450
|
projectId: issue.projectId,
|
|
439
451
|
linearIssueId: issue.linearIssueId,
|
|
440
452
|
eventType: "review_changes_requested",
|
|
441
453
|
eventJson: JSON.stringify({
|
|
442
454
|
reviewBody: event.reviewBody,
|
|
455
|
+
reviewCommitId: event.reviewCommitId,
|
|
456
|
+
reviewId: event.reviewId,
|
|
457
|
+
reviewUrl: buildGitHubReviewUrl(event.repoFullName, event.prNumber, event.reviewId),
|
|
443
458
|
reviewerName: event.reviewerName,
|
|
459
|
+
...(reviewComments && reviewComments.length > 0 ? { reviewComments } : {}),
|
|
444
460
|
}),
|
|
445
461
|
dedupeKey: [
|
|
446
462
|
"review_changes_requested",
|
|
@@ -461,7 +477,9 @@ export class GitHubWebhookHandler {
|
|
|
461
477
|
stage: "changes_requested",
|
|
462
478
|
status: "review_fix_queued",
|
|
463
479
|
summary: `${queuedRunType ?? "review_fix"} queued after requested changes`,
|
|
464
|
-
detail:
|
|
480
|
+
detail: reviewComments && reviewComments.length > 0
|
|
481
|
+
? `${reviewComments.length} inline review comment${reviewComments.length === 1 ? "" : "s"} captured`
|
|
482
|
+
: event.reviewBody?.slice(0, 200) ?? event.reviewerName,
|
|
465
483
|
});
|
|
466
484
|
}
|
|
467
485
|
}
|
|
@@ -805,6 +823,64 @@ export class GitHubWebhookHandler {
|
|
|
805
823
|
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear session from GitHub webhook");
|
|
806
824
|
}
|
|
807
825
|
}
|
|
826
|
+
async fetchReviewCommentsForEvent(event) {
|
|
827
|
+
if (event.triggerEvent !== "review_changes_requested") {
|
|
828
|
+
return undefined;
|
|
829
|
+
}
|
|
830
|
+
if (!event.repoFullName || event.prNumber === undefined || event.reviewId === undefined) {
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
833
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
834
|
+
if (!token) {
|
|
835
|
+
this.logger.debug({ prNumber: event.prNumber, reviewId: event.reviewId }, "Skipping inline review comment fetch because no GitHub API token is available");
|
|
836
|
+
return undefined;
|
|
837
|
+
}
|
|
838
|
+
const [owner, repo] = event.repoFullName.split("/", 2);
|
|
839
|
+
if (!owner || !repo) {
|
|
840
|
+
return undefined;
|
|
841
|
+
}
|
|
842
|
+
const response = await this.fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${event.prNumber}/reviews/${event.reviewId}/comments?per_page=100`, {
|
|
843
|
+
headers: {
|
|
844
|
+
Authorization: `Bearer ${token}`,
|
|
845
|
+
Accept: "application/vnd.github+json",
|
|
846
|
+
"User-Agent": "patchrelay",
|
|
847
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
if (!response.ok) {
|
|
851
|
+
throw new Error(`GitHub review comment fetch failed (${response.status})`);
|
|
852
|
+
}
|
|
853
|
+
const payload = await response.json();
|
|
854
|
+
if (!Array.isArray(payload)) {
|
|
855
|
+
return undefined;
|
|
856
|
+
}
|
|
857
|
+
const comments = [];
|
|
858
|
+
for (const entry of payload) {
|
|
859
|
+
if (!entry || typeof entry !== "object")
|
|
860
|
+
continue;
|
|
861
|
+
const record = entry;
|
|
862
|
+
const body = typeof record.body === "string" ? record.body.trim() : "";
|
|
863
|
+
const id = typeof record.id === "number" ? record.id : undefined;
|
|
864
|
+
if (!body || id === undefined)
|
|
865
|
+
continue;
|
|
866
|
+
comments.push({
|
|
867
|
+
id,
|
|
868
|
+
body,
|
|
869
|
+
...(typeof record.path === "string" ? { path: record.path } : {}),
|
|
870
|
+
...(typeof record.line === "number" ? { line: record.line } : {}),
|
|
871
|
+
...(typeof record.side === "string" ? { side: record.side } : {}),
|
|
872
|
+
...(typeof record.start_line === "number" ? { startLine: record.start_line } : {}),
|
|
873
|
+
...(typeof record.start_side === "string" ? { startSide: record.start_side } : {}),
|
|
874
|
+
...(typeof record.commit_id === "string" ? { commitId: record.commit_id } : {}),
|
|
875
|
+
...(typeof record.html_url === "string" ? { url: record.html_url } : {}),
|
|
876
|
+
...(typeof record.diff_hunk === "string" ? { diffHunk: record.diff_hunk } : {}),
|
|
877
|
+
...(typeof record.user?.login === "string"
|
|
878
|
+
? { authorLogin: String(record.user.login) }
|
|
879
|
+
: {}),
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
return comments;
|
|
883
|
+
}
|
|
808
884
|
async handlePrComment(payload) {
|
|
809
885
|
if (payload.action !== "created")
|
|
810
886
|
return;
|
|
@@ -865,6 +941,98 @@ export class GitHubWebhookHandler {
|
|
|
865
941
|
});
|
|
866
942
|
this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
867
943
|
}
|
|
944
|
+
async maybeRequestRereviewAfterPush(issue, event, project) {
|
|
945
|
+
if (event.triggerEvent !== "pr_synchronize")
|
|
946
|
+
return;
|
|
947
|
+
if (issue.activeRunId !== undefined)
|
|
948
|
+
return;
|
|
949
|
+
if (issue.prState !== "open" || issue.prReviewState !== "changes_requested" || issue.prNumber === undefined)
|
|
950
|
+
return;
|
|
951
|
+
if (!this.isPatchRelayOwnedPr(issue))
|
|
952
|
+
return;
|
|
953
|
+
const reviewerName = this.findLatestRequestedChangesReviewer(issue.projectId, issue.linearIssueId);
|
|
954
|
+
if (!reviewerName) {
|
|
955
|
+
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Skipping auto re-review request because no prior reviewer was recorded");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const repoFullName = project?.github?.repoFullName ?? event.repoFullName;
|
|
959
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
960
|
+
if (!token) {
|
|
961
|
+
this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Skipping auto re-review request because no GitHub token is available");
|
|
962
|
+
this.feed?.publish({
|
|
963
|
+
level: "warn",
|
|
964
|
+
kind: "github",
|
|
965
|
+
issueKey: issue.issueKey,
|
|
966
|
+
projectId: issue.projectId,
|
|
967
|
+
stage: issue.factoryState,
|
|
968
|
+
status: "rereview_request_skipped",
|
|
969
|
+
summary: `Skipped auto re-review request for PR #${issue.prNumber}`,
|
|
970
|
+
detail: "No GitHub token available for requested_reviewers API call",
|
|
971
|
+
});
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const response = await this.fetchImpl(`https://api.github.com/repos/${repoFullName}/pulls/${issue.prNumber}/requested_reviewers`, {
|
|
975
|
+
method: "POST",
|
|
976
|
+
headers: {
|
|
977
|
+
authorization: `Bearer ${token}`,
|
|
978
|
+
accept: "application/vnd.github+json",
|
|
979
|
+
"content-type": "application/json",
|
|
980
|
+
"user-agent": "patchrelay",
|
|
981
|
+
},
|
|
982
|
+
body: JSON.stringify({ reviewers: [reviewerName] }),
|
|
983
|
+
});
|
|
984
|
+
if (!response.ok) {
|
|
985
|
+
const detail = await this.readGitHubErrorResponse(response);
|
|
986
|
+
this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber, reviewerName, status: response.status, detail }, "Failed to auto request re-review after push");
|
|
987
|
+
this.feed?.publish({
|
|
988
|
+
level: "warn",
|
|
989
|
+
kind: "github",
|
|
990
|
+
issueKey: issue.issueKey,
|
|
991
|
+
projectId: issue.projectId,
|
|
992
|
+
stage: issue.factoryState,
|
|
993
|
+
status: "rereview_request_failed",
|
|
994
|
+
summary: `Failed to auto request re-review from ${reviewerName}`,
|
|
995
|
+
detail,
|
|
996
|
+
});
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, reviewerName }, "Auto requested re-review after push");
|
|
1000
|
+
this.feed?.publish({
|
|
1001
|
+
level: "info",
|
|
1002
|
+
kind: "github",
|
|
1003
|
+
issueKey: issue.issueKey,
|
|
1004
|
+
projectId: issue.projectId,
|
|
1005
|
+
stage: issue.factoryState,
|
|
1006
|
+
status: "rereview_requested",
|
|
1007
|
+
summary: `Requested re-review from ${reviewerName} on PR #${issue.prNumber}`,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
findLatestRequestedChangesReviewer(projectId, linearIssueId) {
|
|
1011
|
+
const event = this.db
|
|
1012
|
+
.listIssueSessionEvents(projectId, linearIssueId)
|
|
1013
|
+
.findLast((candidate) => candidate.eventType === "review_changes_requested");
|
|
1014
|
+
if (!event?.eventJson)
|
|
1015
|
+
return undefined;
|
|
1016
|
+
const payload = safeJsonParse(event.eventJson);
|
|
1017
|
+
return typeof payload?.reviewerName === "string" && payload.reviewerName.trim()
|
|
1018
|
+
? payload.reviewerName.trim()
|
|
1019
|
+
: undefined;
|
|
1020
|
+
}
|
|
1021
|
+
async readGitHubErrorResponse(response) {
|
|
1022
|
+
try {
|
|
1023
|
+
const payload = await response.json();
|
|
1024
|
+
if (typeof payload?.message === "string" && payload.message.trim()) {
|
|
1025
|
+
return payload.message.trim();
|
|
1026
|
+
}
|
|
1027
|
+
if (payload?.errors !== undefined) {
|
|
1028
|
+
return JSON.stringify(payload.errors);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
catch {
|
|
1032
|
+
// Fall through to status text.
|
|
1033
|
+
}
|
|
1034
|
+
return response.statusText || `GitHub API responded with ${response.status}`;
|
|
1035
|
+
}
|
|
868
1036
|
peekPendingSessionWakeRunType(projectId, issueId) {
|
|
869
1037
|
return this.db.peekIssueSessionWake(projectId, issueId)?.runType;
|
|
870
1038
|
}
|
|
@@ -901,6 +1069,12 @@ function resolvePatchRelayAuthorLoginsFromEnv() {
|
|
|
901
1069
|
.map((value) => normalizeAuthorLogin(value))
|
|
902
1070
|
.filter((value) => Boolean(value));
|
|
903
1071
|
}
|
|
1072
|
+
function buildGitHubReviewUrl(repoFullName, prNumber, reviewId) {
|
|
1073
|
+
if (!repoFullName || prNumber === undefined || reviewId === undefined) {
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
return `https://github.com/${repoFullName}/pull/${prNumber}#pullrequestreview-${reviewId}`;
|
|
1077
|
+
}
|
|
904
1078
|
function resolveCheckClass(checkName, project) {
|
|
905
1079
|
if (!checkName || !project)
|
|
906
1080
|
return "code";
|
package/dist/github-webhooks.js
CHANGED
|
@@ -104,6 +104,8 @@ function normalizePullRequestReviewEvent(payload, repoFullName) {
|
|
|
104
104
|
prAuthorLogin: pr.user?.login ?? undefined,
|
|
105
105
|
reviewState,
|
|
106
106
|
reviewBody: review.body ?? undefined,
|
|
107
|
+
reviewId: typeof review.id === "number" ? review.id : undefined,
|
|
108
|
+
reviewCommitId: typeof review.commit_id === "string" ? review.commit_id : undefined,
|
|
107
109
|
reviewerName: review.user?.login ?? undefined,
|
|
108
110
|
};
|
|
109
111
|
}
|
|
@@ -325,7 +325,7 @@ function statusHeadline(issue, activeRunType) {
|
|
|
325
325
|
case "failed":
|
|
326
326
|
return "Needs operator intervention";
|
|
327
327
|
case "escalated":
|
|
328
|
-
return "
|
|
328
|
+
return "Needs operator intervention";
|
|
329
329
|
case "done":
|
|
330
330
|
return issue.prNumber !== undefined ? `Completed with PR #${issue.prNumber}` : "Completed";
|
|
331
331
|
default:
|