patchrelay 0.35.12 → 0.35.13

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.
@@ -0,0 +1,226 @@
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
+ segments.push({ text: ` (${match[2]})`, dimColor: true });
118
+ }
119
+ else if (match[3]) {
120
+ segments.push({ text: match[3], color: "yellow", bold: true });
121
+ }
122
+ else if (match[4]) {
123
+ segments.push({ text: match[4], ...(style ?? {}), bold: true });
124
+ }
125
+ lastIndex = index + match[0].length;
126
+ }
127
+ if (lastIndex < text.length) {
128
+ segments.push({ text: text.slice(lastIndex), ...(style ?? {}) });
129
+ }
130
+ return segments.length > 0 ? segments : [{ text, ...(style ?? {}) }];
131
+ }
132
+ function wrapSegments(tokens, width, firstPrefix, continuationPrefix, keyPrefix) {
133
+ const initialPrefix = cloneSegments(firstPrefix);
134
+ const nextPrefix = cloneSegments(continuationPrefix);
135
+ const lines = [];
136
+ let currentSegments = initialPrefix;
137
+ let currentWidth = segmentsWidth(initialPrefix);
138
+ let lineHasContent = false;
139
+ let lineIndex = 0;
140
+ const pushLine = () => {
141
+ lines.push({
142
+ key: `${keyPrefix}-${lineIndex}`,
143
+ segments: trimTrailingSpaces(currentSegments),
144
+ });
145
+ lineIndex += 1;
146
+ currentSegments = cloneSegments(nextPrefix);
147
+ currentWidth = segmentsWidth(currentSegments);
148
+ lineHasContent = false;
149
+ };
150
+ for (const token of tokens) {
151
+ let remaining = token.text.replace(/\t/g, " ");
152
+ while (remaining.length > 0) {
153
+ const whitespace = remaining.match(/^\s+/)?.[0];
154
+ if (whitespace) {
155
+ const spaceText = lineHasContent ? whitespace : "";
156
+ remaining = remaining.slice(whitespace.length);
157
+ if (spaceText.length === 0) {
158
+ continue;
159
+ }
160
+ if (currentWidth + spaceText.length > width) {
161
+ pushLine();
162
+ continue;
163
+ }
164
+ currentSegments.push({ ...token, text: spaceText });
165
+ currentWidth += spaceText.length;
166
+ continue;
167
+ }
168
+ const word = remaining.match(/^\S+/)?.[0] ?? remaining;
169
+ remaining = remaining.slice(word.length);
170
+ let rest = word;
171
+ while (rest.length > 0) {
172
+ const available = Math.max(1, width - currentWidth);
173
+ if (lineHasContent && rest.length > available) {
174
+ pushLine();
175
+ continue;
176
+ }
177
+ const sliceLength = Math.min(rest.length, available);
178
+ const chunk = rest.slice(0, sliceLength);
179
+ currentSegments.push({ ...token, text: chunk });
180
+ currentWidth += chunk.length;
181
+ lineHasContent = true;
182
+ rest = rest.slice(sliceLength);
183
+ if (rest.length > 0) {
184
+ pushLine();
185
+ }
186
+ }
187
+ }
188
+ }
189
+ if (lines.length === 0 || currentSegments.length > 0 || lineHasContent) {
190
+ lines.push({
191
+ key: `${keyPrefix}-${lineIndex}`,
192
+ segments: trimTrailingSpaces(currentSegments),
193
+ });
194
+ }
195
+ return lines;
196
+ }
197
+ function tokenizeSegments(segments) {
198
+ return segments.flatMap((segment) => {
199
+ const parts = segment.text.length === 0 ? [""] : segment.text.match(/\s+|\S+/g) ?? [segment.text];
200
+ return parts.map((part) => ({ ...segment, text: part }));
201
+ });
202
+ }
203
+ function cloneSegments(segments) {
204
+ return (segments ?? []).map((segment) => ({ ...segment }));
205
+ }
206
+ function segmentsWidth(segments) {
207
+ return (segments ?? []).reduce((sum, segment) => sum + segment.text.length, 0);
208
+ }
209
+ function trimTrailingSpaces(segments) {
210
+ const trimmed = cloneSegments(segments);
211
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1]?.text.trim().length === 0) {
212
+ trimmed.pop();
213
+ }
214
+ return trimmed;
215
+ }
216
+ function appendSegments(base, extra) {
217
+ return [...cloneSegments(base), ...cloneSegments(extra)];
218
+ }
219
+ function segmentsKey(segments) {
220
+ return (segments ?? []).map((segment) => `${segment.text}|${segment.color ?? ""}|${segment.dimColor ? "d" : ""}|${segment.bold ? "b" : ""}`).join(";");
221
+ }
222
+ function styleKey(style) {
223
+ if (!style)
224
+ return "";
225
+ return `${style.color ?? ""}|${style.dimColor ? "d" : ""}|${style.bold ? "b" : ""}`;
226
+ }
@@ -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: Math.min(state.selectedIndex, Math.max(0, action.issues.length - 1)),
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: Math.max(0, Math.min(action.index, state.issues.length - 1)),
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 { ...state, follow: !state.follow };
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: event.reviewBody?.slice(0, 200) ?? event.reviewerName,
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";
@@ -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 "Escalated for human help";
328
+ return "Needs operator intervention";
329
329
  case "done":
330
330
  return issue.prNumber !== undefined ? `Completed with PR #${issue.prNumber}` : "Completed";
331
331
  default: