patchrelay 0.35.11 → 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.
- package/README.md +41 -9
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +19 -1
- package/dist/cli/commands/issues.js +18 -56
- package/dist/cli/commands/watch.js +5 -0
- package/dist/cli/data.js +160 -47
- package/dist/cli/formatters/text.js +51 -90
- package/dist/cli/help.js +15 -8
- package/dist/cli/index.js +3 -58
- package/dist/cli/operator-client.js +0 -82
- package/dist/cli/watch/App.js +21 -12
- package/dist/cli/watch/HelpBar.js +3 -3
- package/dist/cli/watch/IssueDetailView.js +63 -130
- package/dist/cli/watch/IssueRow.js +82 -27
- package/dist/cli/watch/StatusBar.js +8 -4
- package/dist/cli/watch/detail-rows.js +589 -0
- package/dist/cli/watch/render-rich-text.js +226 -0
- package/dist/cli/watch/state-visualization.js +48 -23
- package/dist/cli/watch/timeline-builder.js +2 -1
- package/dist/cli/watch/use-detail-stream.js +10 -104
- package/dist/cli/watch/use-watch-stream.js +11 -102
- package/dist/cli/watch/watch-state.js +129 -56
- package/dist/codex-thread-utils.js +3 -0
- package/dist/db/migrations.js +239 -2
- package/dist/db.js +628 -39
- package/dist/github-app-token.js +7 -0
- package/dist/github-failure-context.js +44 -1
- package/dist/github-rollup.js +47 -0
- package/dist/github-webhook-handler.js +423 -52
- package/dist/github-webhooks.js +7 -0
- package/dist/http.js +12 -264
- package/dist/idle-reconciliation.js +268 -76
- package/dist/issue-query-service.js +221 -129
- package/dist/issue-session-events.js +151 -0
- package/dist/issue-session.js +99 -0
- package/dist/linear-client.js +39 -25
- package/dist/linear-session-reporting.js +12 -0
- package/dist/linear-session-sync.js +253 -24
- package/dist/linear-workflow.js +33 -0
- package/dist/merge-queue-protocol.js +0 -51
- package/dist/preflight.js +1 -4
- package/dist/queue-health-monitor.js +11 -7
- package/dist/run-orchestrator.js +1364 -147
- package/dist/run-reporting.js +5 -3
- package/dist/service.js +279 -102
- package/dist/status-note.js +56 -0
- package/dist/waiting-reason.js +65 -0
- package/dist/webhook-handler.js +270 -79
- package/package.json +3 -2
- package/dist/cli/commands/feed.js +0 -60
- package/dist/cli/watch/FeedView.js +0 -28
- package/dist/cli/watch/use-feed-stream.js +0 -92
package/dist/linear-client.js
CHANGED
|
@@ -23,27 +23,33 @@ const LINEAR_ISSUE_SELECTION = `
|
|
|
23
23
|
name
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
inverseRelations {
|
|
27
27
|
nodes {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
title
|
|
31
|
-
state {
|
|
28
|
+
type
|
|
29
|
+
issue {
|
|
32
30
|
id
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
identifier
|
|
32
|
+
title
|
|
33
|
+
state {
|
|
34
|
+
id
|
|
35
|
+
name
|
|
36
|
+
type
|
|
37
|
+
}
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
|
-
|
|
41
|
+
relations {
|
|
39
42
|
nodes {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
title
|
|
43
|
-
state {
|
|
43
|
+
type
|
|
44
|
+
relatedIssue {
|
|
44
45
|
id
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
identifier
|
|
47
|
+
title
|
|
48
|
+
state {
|
|
49
|
+
id
|
|
50
|
+
name
|
|
51
|
+
type
|
|
52
|
+
}
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
}
|
|
@@ -92,19 +98,16 @@ export class LinearGraphqlClient {
|
|
|
92
98
|
throw new Error(`Linear state "${stateName}" was not found for issue ${issue.identifier ?? issueId}`);
|
|
93
99
|
}
|
|
94
100
|
const response = await this.request(`
|
|
95
|
-
mutation PatchRelaySetIssueState($id: String!, $
|
|
96
|
-
issueUpdate(id: $id, input:
|
|
101
|
+
mutation PatchRelaySetIssueState($id: String!, $input: IssueUpdateInput!) {
|
|
102
|
+
issueUpdate(id: $id, input: $input) {
|
|
97
103
|
success
|
|
98
|
-
issue {
|
|
99
|
-
${LINEAR_ISSUE_SELECTION}
|
|
100
|
-
}
|
|
101
104
|
}
|
|
102
105
|
}
|
|
103
|
-
`, { id: issueId, stateId: state.id });
|
|
104
|
-
if (!response.issueUpdate.success
|
|
106
|
+
`, { id: issueId, input: { stateId: state.id } });
|
|
107
|
+
if (!response.issueUpdate.success) {
|
|
105
108
|
throw new Error(`Linear rejected state update for issue ${issue.identifier ?? issueId}`);
|
|
106
109
|
}
|
|
107
|
-
return this.
|
|
110
|
+
return await this.getIssue(issueId);
|
|
108
111
|
}
|
|
109
112
|
async upsertIssueComment(params) {
|
|
110
113
|
if (params.commentId) {
|
|
@@ -292,7 +295,10 @@ export class LinearGraphqlClient {
|
|
|
292
295
|
}),
|
|
293
296
|
});
|
|
294
297
|
if (!response.ok) {
|
|
295
|
-
|
|
298
|
+
const body = (await response.text()).trim();
|
|
299
|
+
throw new Error(body
|
|
300
|
+
? `Linear API request failed with HTTP ${response.status}: ${body}`
|
|
301
|
+
: `Linear API request failed with HTTP ${response.status}`);
|
|
296
302
|
}
|
|
297
303
|
const payload = (await response.json());
|
|
298
304
|
if (payload.errors?.length) {
|
|
@@ -308,6 +314,8 @@ export class LinearGraphqlClient {
|
|
|
308
314
|
mapIssue(issue) {
|
|
309
315
|
const labels = (issue.labels?.nodes ?? []).map((label) => ({ id: label.id, name: label.name }));
|
|
310
316
|
const teamLabels = (issue.team?.labels?.nodes ?? []).map((label) => ({ id: label.id, name: label.name }));
|
|
317
|
+
const blocksRelations = (issue.relations?.nodes ?? []).filter((relation) => relation.type?.trim().toLowerCase() === "blocks");
|
|
318
|
+
const blockedByRelations = (issue.inverseRelations?.nodes ?? []).filter((relation) => relation.type?.trim().toLowerCase() === "blocks");
|
|
311
319
|
return {
|
|
312
320
|
id: issue.id,
|
|
313
321
|
...(issue.identifier ? { identifier: issue.identifier } : {}),
|
|
@@ -331,8 +339,14 @@ export class LinearGraphqlClient {
|
|
|
331
339
|
labelIds: labels.map((label) => label.id),
|
|
332
340
|
labels,
|
|
333
341
|
teamLabels,
|
|
334
|
-
blockedBy:
|
|
335
|
-
|
|
342
|
+
blockedBy: blockedByRelations
|
|
343
|
+
.map((relation) => relation.issue)
|
|
344
|
+
.filter((relation) => Boolean(relation))
|
|
345
|
+
.map(mapIssueRelation),
|
|
346
|
+
blocks: blocksRelations
|
|
347
|
+
.map((relation) => relation.relatedIssue)
|
|
348
|
+
.filter((relation) => Boolean(relation))
|
|
349
|
+
.map(mapIssueRelation),
|
|
336
350
|
};
|
|
337
351
|
}
|
|
338
352
|
resolveLabelIds(issue, names) {
|
|
@@ -147,6 +147,18 @@ export function buildMergePrepEscalationActivity(attempts) {
|
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
149
|
export function summarizeIssueStateForLinear(issue) {
|
|
150
|
+
switch (issue.sessionState) {
|
|
151
|
+
case "waiting_input":
|
|
152
|
+
return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is waiting for input.` : "Waiting for input.");
|
|
153
|
+
case "running":
|
|
154
|
+
return issue.prNumber ? `PR #${issue.prNumber} is actively running.` : "Actively running.";
|
|
155
|
+
case "idle":
|
|
156
|
+
return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is idle.` : "Idle.");
|
|
157
|
+
case "done":
|
|
158
|
+
return issue.prNumber ? `PR #${issue.prNumber} has merged.` : "Change merged.";
|
|
159
|
+
case "failed":
|
|
160
|
+
return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} needs help to recover.` : "Needs help to recover.");
|
|
161
|
+
}
|
|
150
162
|
switch (issue.factoryState) {
|
|
151
163
|
case "pr_open":
|
|
152
164
|
return issue.prNumber ? `PR #${issue.prNumber} is awaiting review.` : "Awaiting review.";
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
|
|
2
2
|
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
3
|
+
import { deriveIssueStatusNote } from "./status-note.js";
|
|
4
|
+
import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
|
|
5
|
+
import { resolvePreferredReviewLinearState, resolvePreferredStartedLinearState } from "./linear-workflow.js";
|
|
3
6
|
const PROGRESS_THROTTLE_MS = 5_000;
|
|
4
7
|
export class LinearSessionSync {
|
|
5
8
|
config;
|
|
@@ -15,57 +18,119 @@ export class LinearSessionSync {
|
|
|
15
18
|
this.logger = logger;
|
|
16
19
|
this.feed = feed;
|
|
17
20
|
}
|
|
21
|
+
ensureAgentSessionIssue(issue) {
|
|
22
|
+
if (issue.agentSessionId) {
|
|
23
|
+
return issue;
|
|
24
|
+
}
|
|
25
|
+
const recoveredAgentSessionId = this.db.findLatestAgentSessionIdForIssue(issue.linearIssueId);
|
|
26
|
+
if (!recoveredAgentSessionId)
|
|
27
|
+
return issue;
|
|
28
|
+
this.logger.info({ issueKey: issue.issueKey, agentSessionId: recoveredAgentSessionId }, "Recovered missing Linear agent session id from webhook history");
|
|
29
|
+
return this.db.upsertIssue({
|
|
30
|
+
projectId: issue.projectId,
|
|
31
|
+
linearIssueId: issue.linearIssueId,
|
|
32
|
+
agentSessionId: recoveredAgentSessionId,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
18
35
|
async emitActivity(issue, content, options) {
|
|
19
|
-
|
|
36
|
+
const syncedIssue = this.ensureAgentSessionIssue(issue);
|
|
37
|
+
if (!syncedIssue.agentSessionId)
|
|
20
38
|
return;
|
|
21
39
|
try {
|
|
22
|
-
const linear = await this.linearProvider.forProject(
|
|
40
|
+
const linear = await this.linearProvider.forProject(syncedIssue.projectId);
|
|
23
41
|
if (!linear)
|
|
24
42
|
return;
|
|
25
43
|
const allowEphemeral = content.type === "thought" || content.type === "action";
|
|
26
44
|
await linear.createAgentActivity({
|
|
27
|
-
agentSessionId:
|
|
45
|
+
agentSessionId: syncedIssue.agentSessionId,
|
|
28
46
|
content,
|
|
29
47
|
...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
|
|
30
48
|
});
|
|
31
49
|
}
|
|
32
50
|
catch (error) {
|
|
33
51
|
const msg = error instanceof Error ? error.message : String(error);
|
|
34
|
-
this.logger.warn({ issueKey:
|
|
52
|
+
this.logger.warn({ issueKey: syncedIssue.issueKey, type: content.type, error: msg }, "Failed to emit Linear activity");
|
|
35
53
|
this.feed?.publish({
|
|
36
54
|
level: "warn",
|
|
37
55
|
kind: "linear",
|
|
38
|
-
issueKey:
|
|
39
|
-
projectId:
|
|
56
|
+
issueKey: syncedIssue.issueKey,
|
|
57
|
+
projectId: syncedIssue.projectId,
|
|
40
58
|
status: "linear_error",
|
|
41
59
|
summary: `Linear activity failed: ${msg}`,
|
|
42
60
|
});
|
|
43
61
|
}
|
|
44
62
|
}
|
|
45
63
|
async syncSession(issue, options) {
|
|
46
|
-
|
|
47
|
-
return;
|
|
64
|
+
const syncedIssue = this.ensureAgentSessionIssue(issue);
|
|
48
65
|
try {
|
|
49
|
-
const linear = await this.linearProvider.forProject(
|
|
50
|
-
if (!linear
|
|
66
|
+
const linear = await this.linearProvider.forProject(syncedIssue.projectId);
|
|
67
|
+
if (!linear)
|
|
51
68
|
return;
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
const trackedIssue = this.db.getTrackedIssue(syncedIssue.projectId, syncedIssue.linearIssueId);
|
|
70
|
+
await this.syncActiveWorkflowState(syncedIssue, linear, trackedIssue, options);
|
|
71
|
+
if (syncedIssue.agentSessionId && linear.updateAgentSession) {
|
|
72
|
+
const externalUrls = buildAgentSessionExternalUrls(this.config, {
|
|
73
|
+
...(syncedIssue.issueKey ? { issueKey: syncedIssue.issueKey } : {}),
|
|
74
|
+
...(syncedIssue.prUrl ? { prUrl: syncedIssue.prUrl } : {}),
|
|
75
|
+
});
|
|
76
|
+
await linear.updateAgentSession({
|
|
77
|
+
agentSessionId: syncedIssue.agentSessionId,
|
|
78
|
+
plan: buildAgentSessionPlanForIssue(syncedIssue, options),
|
|
79
|
+
...(externalUrls ? { externalUrls } : {}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (shouldSyncVisibleIssueComment(trackedIssue ?? syncedIssue, Boolean(syncedIssue.agentSessionId))) {
|
|
83
|
+
await this.syncStatusComment(syncedIssue, linear, options);
|
|
84
|
+
}
|
|
61
85
|
}
|
|
62
86
|
catch (error) {
|
|
63
87
|
const msg = error instanceof Error ? error.message : String(error);
|
|
64
|
-
this.logger.warn({ issueKey:
|
|
88
|
+
this.logger.warn({ issueKey: syncedIssue.issueKey, error: msg }, "Failed to update Linear plan");
|
|
65
89
|
}
|
|
66
90
|
}
|
|
91
|
+
async syncActiveWorkflowState(issue, linear, trackedIssue, options) {
|
|
92
|
+
if (!shouldAutoAdvanceLinearState(issue)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const liveIssue = await linear.getIssue(issue.linearIssueId).catch(() => undefined);
|
|
96
|
+
if (!liveIssue)
|
|
97
|
+
return;
|
|
98
|
+
if (!shouldAutoAdvanceLinearState({
|
|
99
|
+
currentLinearState: liveIssue.stateName,
|
|
100
|
+
currentLinearStateType: liveIssue.stateType,
|
|
101
|
+
})) {
|
|
102
|
+
this.db.upsertIssue({
|
|
103
|
+
projectId: issue.projectId,
|
|
104
|
+
linearIssueId: issue.linearIssueId,
|
|
105
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
106
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const targetState = resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIssue);
|
|
111
|
+
if (!targetState)
|
|
112
|
+
return;
|
|
113
|
+
const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
|
|
114
|
+
if (normalizedCurrent === targetState.trim().toLowerCase()) {
|
|
115
|
+
this.db.upsertIssue({
|
|
116
|
+
projectId: issue.projectId,
|
|
117
|
+
linearIssueId: issue.linearIssueId,
|
|
118
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
119
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
|
|
124
|
+
this.db.upsertIssue({
|
|
125
|
+
projectId: issue.projectId,
|
|
126
|
+
linearIssueId: issue.linearIssueId,
|
|
127
|
+
...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
|
|
128
|
+
...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
67
131
|
async syncCodexPlan(issue, params) {
|
|
68
|
-
|
|
132
|
+
const syncedIssue = this.ensureAgentSessionIssue(issue);
|
|
133
|
+
if (!syncedIssue.agentSessionId)
|
|
69
134
|
return;
|
|
70
135
|
const plan = params.plan;
|
|
71
136
|
if (!Array.isArray(plan))
|
|
@@ -87,17 +152,17 @@ export class LinearSessionSync {
|
|
|
87
152
|
{ content: "Merge", status: "pending" },
|
|
88
153
|
];
|
|
89
154
|
try {
|
|
90
|
-
const linear = await this.linearProvider.forProject(
|
|
155
|
+
const linear = await this.linearProvider.forProject(syncedIssue.projectId);
|
|
91
156
|
if (!linear?.updateAgentSession)
|
|
92
157
|
return;
|
|
93
158
|
await linear.updateAgentSession({
|
|
94
|
-
agentSessionId:
|
|
159
|
+
agentSessionId: syncedIssue.agentSessionId,
|
|
95
160
|
plan: fullPlan,
|
|
96
161
|
});
|
|
97
162
|
}
|
|
98
163
|
catch (error) {
|
|
99
164
|
const msg = error instanceof Error ? error.message : String(error);
|
|
100
|
-
this.logger.warn({ issueKey:
|
|
165
|
+
this.logger.warn({ issueKey: syncedIssue.issueKey, error: msg }, "Failed to sync codex plan to Linear");
|
|
101
166
|
}
|
|
102
167
|
}
|
|
103
168
|
maybeEmitProgress(notification, run) {
|
|
@@ -117,6 +182,28 @@ export class LinearSessionSync {
|
|
|
117
182
|
clearProgress(runId) {
|
|
118
183
|
this.progressThrottle.delete(runId);
|
|
119
184
|
}
|
|
185
|
+
async syncStatusComment(issue, linear, options) {
|
|
186
|
+
try {
|
|
187
|
+
const trackedIssue = this.db.getTrackedIssue(issue.projectId, issue.linearIssueId);
|
|
188
|
+
const body = renderStatusComment(this.db, issue, trackedIssue, options);
|
|
189
|
+
const result = await linear.upsertIssueComment({
|
|
190
|
+
issueId: issue.linearIssueId,
|
|
191
|
+
...(issue.statusCommentId ? { commentId: issue.statusCommentId } : {}),
|
|
192
|
+
body,
|
|
193
|
+
});
|
|
194
|
+
if (result.id !== issue.statusCommentId) {
|
|
195
|
+
this.db.upsertIssue({
|
|
196
|
+
projectId: issue.projectId,
|
|
197
|
+
linearIssueId: issue.linearIssueId,
|
|
198
|
+
statusCommentId: result.id,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
204
|
+
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear status comment");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
120
207
|
}
|
|
121
208
|
function resolveProgressActivity(notification) {
|
|
122
209
|
if (notification.method === "item/started") {
|
|
@@ -141,3 +228,145 @@ function resolveProgressActivity(notification) {
|
|
|
141
228
|
}
|
|
142
229
|
return undefined;
|
|
143
230
|
}
|
|
231
|
+
function renderStatusComment(db, issue, trackedIssue, options) {
|
|
232
|
+
const activeRun = issue.activeRunId ? db.getRun(issue.activeRunId) : undefined;
|
|
233
|
+
const latestRun = db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
234
|
+
const latestEvent = db.listIssueSessionEvents(issue.projectId, issue.linearIssueId, { limit: 1 }).at(-1);
|
|
235
|
+
const activeRunType = issue.activeRunId !== undefined
|
|
236
|
+
? (options?.activeRunType ?? activeRun?.runType)
|
|
237
|
+
: undefined;
|
|
238
|
+
const waitingReason = trackedIssue?.waitingReason ?? derivePatchRelayWaitingReason({
|
|
239
|
+
...(activeRunType ? { activeRunType } : {}),
|
|
240
|
+
...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
|
|
241
|
+
factoryState: issue.factoryState,
|
|
242
|
+
pendingRunType: issue.pendingRunType,
|
|
243
|
+
...(issue.prNumber !== undefined ? { prNumber: issue.prNumber } : {}),
|
|
244
|
+
prReviewState: issue.prReviewState,
|
|
245
|
+
prCheckStatus: issue.prCheckStatus,
|
|
246
|
+
latestFailureCheckName: issue.lastGitHubFailureCheckName,
|
|
247
|
+
});
|
|
248
|
+
const lines = [
|
|
249
|
+
"## PatchRelay status",
|
|
250
|
+
"",
|
|
251
|
+
statusHeadline(trackedIssue ?? issue, activeRunType),
|
|
252
|
+
];
|
|
253
|
+
const statusNote = trackedIssue?.statusNote ?? deriveIssueStatusNote({ issue, latestRun, latestEvent, waitingReason });
|
|
254
|
+
if (waitingReason) {
|
|
255
|
+
lines.push("", `Waiting: ${waitingReason}`);
|
|
256
|
+
}
|
|
257
|
+
if (statusNote && statusNote !== waitingReason) {
|
|
258
|
+
const label = trackedIssue?.sessionState === "waiting_input" || issue.factoryState === "awaiting_input" ? "Input needed"
|
|
259
|
+
: trackedIssue?.sessionState === "failed" || issue.factoryState === "failed" || issue.factoryState === "escalated" ? "Action needed"
|
|
260
|
+
: "Note";
|
|
261
|
+
lines.push("", `${label}: ${statusNote}`);
|
|
262
|
+
}
|
|
263
|
+
if (issue.prNumber !== undefined || issue.prUrl) {
|
|
264
|
+
const prLabel = issue.prNumber !== undefined ? `#${issue.prNumber}` : "open";
|
|
265
|
+
lines.push("", `PR: ${issue.prUrl ? `[${prLabel}](${issue.prUrl})` : prLabel}`);
|
|
266
|
+
}
|
|
267
|
+
if (latestRun) {
|
|
268
|
+
lines.push("", `Latest run: ${formatLatestRun(latestRun)}`);
|
|
269
|
+
if (latestRun.failureReason) {
|
|
270
|
+
lines.push("", `Failure: ${latestRun.failureReason}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (issue.lastGitHubFailureCheckName && (issue.factoryState === "repairing_ci" || issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure")) {
|
|
274
|
+
lines.push("", `Latest failing check: ${issue.lastGitHubFailureCheckName}`);
|
|
275
|
+
}
|
|
276
|
+
lines.push("", "_PatchRelay updates this comment as it works. Review and merge remain downstream._");
|
|
277
|
+
return lines.join("\n");
|
|
278
|
+
}
|
|
279
|
+
function shouldSyncVisibleIssueComment(issue, hasAgentSession) {
|
|
280
|
+
if (!hasAgentSession) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (issue.sessionState === "waiting_input" || issue.sessionState === "failed"
|
|
284
|
+
|| issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
if ((issue.sessionState === "done" || issue.factoryState === "done") && issue.prNumber === undefined && !issue.prUrl) {
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
function statusHeadline(issue, activeRunType) {
|
|
293
|
+
if (activeRunType) {
|
|
294
|
+
return `Running ${humanize(activeRunType)}`;
|
|
295
|
+
}
|
|
296
|
+
switch (issue.sessionState) {
|
|
297
|
+
case "waiting_input":
|
|
298
|
+
return issue.waitingReason ?? "Waiting for more input";
|
|
299
|
+
case "running":
|
|
300
|
+
return issue.prNumber !== undefined ? `PR #${issue.prNumber} is actively running` : "Actively running";
|
|
301
|
+
case "done":
|
|
302
|
+
return issue.prNumber !== undefined ? `Completed with PR #${issue.prNumber}` : "Completed";
|
|
303
|
+
case "failed":
|
|
304
|
+
return "Needs operator intervention";
|
|
305
|
+
default:
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
switch (issue.factoryState) {
|
|
309
|
+
case "delegated":
|
|
310
|
+
return "Queued to start work";
|
|
311
|
+
case "implementing":
|
|
312
|
+
return "Implementing requested change";
|
|
313
|
+
case "pr_open":
|
|
314
|
+
return issue.prNumber !== undefined ? `PR #${issue.prNumber} opened` : "PR opened";
|
|
315
|
+
case "changes_requested":
|
|
316
|
+
return "Addressing requested review changes";
|
|
317
|
+
case "repairing_ci":
|
|
318
|
+
return "Repairing failing CI";
|
|
319
|
+
case "awaiting_queue":
|
|
320
|
+
return "Handed off downstream for merge";
|
|
321
|
+
case "repairing_queue":
|
|
322
|
+
return "Repairing merge handoff";
|
|
323
|
+
case "awaiting_input":
|
|
324
|
+
return "Waiting for more input";
|
|
325
|
+
case "failed":
|
|
326
|
+
return "Needs operator intervention";
|
|
327
|
+
case "escalated":
|
|
328
|
+
return "Needs operator intervention";
|
|
329
|
+
case "done":
|
|
330
|
+
return issue.prNumber !== undefined ? `Completed with PR #${issue.prNumber}` : "Completed";
|
|
331
|
+
default:
|
|
332
|
+
return humanize(issue.factoryState);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function formatLatestRun(run) {
|
|
336
|
+
const at = run.endedAt ?? run.startedAt;
|
|
337
|
+
return `${humanize(run.runType)} ${run.status} at ${at}`;
|
|
338
|
+
}
|
|
339
|
+
function humanize(value) {
|
|
340
|
+
return value.replaceAll("_", " ");
|
|
341
|
+
}
|
|
342
|
+
function shouldAutoAdvanceLinearState(issue) {
|
|
343
|
+
const normalizedType = issue.currentLinearStateType?.trim().toLowerCase();
|
|
344
|
+
if (normalizedType === "backlog" || normalizedType === "unstarted") {
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
const normalizedName = issue.currentLinearState?.trim().toLowerCase();
|
|
348
|
+
return normalizedName === "backlog" || normalizedName === "todo" || normalizedName === "to do" || normalizedName === "triage";
|
|
349
|
+
}
|
|
350
|
+
function resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIssue) {
|
|
351
|
+
const reviewBound = issue.prNumber !== undefined
|
|
352
|
+
|| Boolean(issue.prUrl)
|
|
353
|
+
|| issue.factoryState === "pr_open"
|
|
354
|
+
|| issue.factoryState === "awaiting_queue"
|
|
355
|
+
|| issue.factoryState === "changes_requested"
|
|
356
|
+
|| issue.factoryState === "repairing_ci"
|
|
357
|
+
|| issue.factoryState === "repairing_queue"
|
|
358
|
+
|| issue.prReviewState !== undefined
|
|
359
|
+
|| issue.prCheckStatus !== undefined;
|
|
360
|
+
if (reviewBound) {
|
|
361
|
+
return resolvePreferredReviewLinearState(liveIssue);
|
|
362
|
+
}
|
|
363
|
+
const activelyWorking = issue.activeRunId !== undefined
|
|
364
|
+
|| options?.activeRunType !== undefined
|
|
365
|
+
|| trackedIssue?.sessionState === "running"
|
|
366
|
+
|| issue.factoryState === "delegated"
|
|
367
|
+
|| issue.factoryState === "implementing";
|
|
368
|
+
if (activelyWorking) {
|
|
369
|
+
return resolvePreferredStartedLinearState(liveIssue);
|
|
370
|
+
}
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
package/dist/linear-workflow.js
CHANGED
|
@@ -2,6 +2,39 @@ function normalizeLinearState(value) {
|
|
|
2
2
|
const trimmed = value?.trim();
|
|
3
3
|
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
4
4
|
}
|
|
5
|
+
export function resolvePreferredStartedLinearState(issue) {
|
|
6
|
+
const startedStates = issue.workflowStates.filter((state) => normalizeLinearState(state.type) === "started");
|
|
7
|
+
const preferred = startedStates.find((state) => {
|
|
8
|
+
const normalized = normalizeLinearState(state.name);
|
|
9
|
+
return normalized === "in progress" || normalized === "in-progress" || normalized === "started" || normalized === "doing";
|
|
10
|
+
});
|
|
11
|
+
return preferred?.name ?? startedStates[0]?.name;
|
|
12
|
+
}
|
|
13
|
+
export function resolvePreferredReviewLinearState(issue) {
|
|
14
|
+
const reviewState = issue.workflowStates.find((state) => {
|
|
15
|
+
if (normalizeLinearState(state.type) !== "started")
|
|
16
|
+
return false;
|
|
17
|
+
const normalized = normalizeLinearState(state.name);
|
|
18
|
+
return normalized === "in review" || normalized === "review";
|
|
19
|
+
});
|
|
20
|
+
return reviewState?.name ?? resolvePreferredStartedLinearState(issue);
|
|
21
|
+
}
|
|
22
|
+
export function resolvePreferredCompletedLinearState(issue) {
|
|
23
|
+
const completed = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
|
|
24
|
+
if (completed?.name) {
|
|
25
|
+
return completed.name;
|
|
26
|
+
}
|
|
27
|
+
const currentStateName = issue.stateName?.trim();
|
|
28
|
+
const normalizedCurrentState = normalizeLinearState(currentStateName);
|
|
29
|
+
if (normalizedCurrentState === "done" || normalizedCurrentState === "completed" || normalizedCurrentState === "complete") {
|
|
30
|
+
return currentStateName;
|
|
31
|
+
}
|
|
32
|
+
const named = issue.workflowStates.find((state) => {
|
|
33
|
+
const normalized = normalizeLinearState(state.name);
|
|
34
|
+
return normalized === "done" || normalized === "completed" || normalized === "complete";
|
|
35
|
+
});
|
|
36
|
+
return named?.name;
|
|
37
|
+
}
|
|
5
38
|
export function resolveAuthoritativeLinearStopState(issue) {
|
|
6
39
|
const currentStateName = issue.stateName?.trim();
|
|
7
40
|
const normalizedCurrentState = normalizeLinearState(currentStateName);
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { execCommand } from "./utils.js";
|
|
2
1
|
export const DEFAULT_MERGE_QUEUE_LABEL = "queue";
|
|
3
2
|
export const DEFAULT_MERGE_QUEUE_CHECK_NAME = "merge-steward/queue";
|
|
4
3
|
export function resolveMergeQueueProtocol(project) {
|
|
@@ -9,53 +8,3 @@ export function resolveMergeQueueProtocol(project) {
|
|
|
9
8
|
evictionCheckName: project?.github?.mergeQueueCheckName ?? DEFAULT_MERGE_QUEUE_CHECK_NAME,
|
|
10
9
|
};
|
|
11
10
|
}
|
|
12
|
-
export async function requestMergeQueueAdmission(params) {
|
|
13
|
-
const { issue, protocol, logger, feed } = params;
|
|
14
|
-
if (!protocol.repoFullName || !issue.prNumber)
|
|
15
|
-
return false;
|
|
16
|
-
feed?.publish({
|
|
17
|
-
level: "info",
|
|
18
|
-
kind: "github",
|
|
19
|
-
issueKey: issue.issueKey,
|
|
20
|
-
projectId: issue.projectId,
|
|
21
|
-
stage: "awaiting_queue",
|
|
22
|
-
status: "queue_label_requested",
|
|
23
|
-
summary: `Queue hand-off requested via label "${protocol.admissionLabel}" on PR #${issue.prNumber}`,
|
|
24
|
-
});
|
|
25
|
-
try {
|
|
26
|
-
const [owner, repo] = protocol.repoFullName.split("/", 2);
|
|
27
|
-
if (!owner || !repo) {
|
|
28
|
-
throw new Error(`Invalid repoFullName: ${protocol.repoFullName}`);
|
|
29
|
-
}
|
|
30
|
-
await execCommand("gh", [
|
|
31
|
-
"api",
|
|
32
|
-
"--method", "POST",
|
|
33
|
-
`repos/${owner}/${repo}/issues/${issue.prNumber}/labels`,
|
|
34
|
-
"-f", `labels[]=${protocol.admissionLabel}`,
|
|
35
|
-
], { timeoutMs: 15_000 });
|
|
36
|
-
feed?.publish({
|
|
37
|
-
level: "info",
|
|
38
|
-
kind: "github",
|
|
39
|
-
issueKey: issue.issueKey,
|
|
40
|
-
projectId: issue.projectId,
|
|
41
|
-
stage: "awaiting_queue",
|
|
42
|
-
status: "queue_label_applied",
|
|
43
|
-
summary: `Queue label "${protocol.admissionLabel}" applied to PR #${issue.prNumber}`,
|
|
44
|
-
});
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
catch (error) {
|
|
48
|
-
logger.warn({ issueKey: issue.issueKey, err: error }, "Failed to add merge queue label");
|
|
49
|
-
feed?.publish({
|
|
50
|
-
level: "warn",
|
|
51
|
-
kind: "github",
|
|
52
|
-
issueKey: issue.issueKey,
|
|
53
|
-
projectId: issue.projectId,
|
|
54
|
-
stage: "awaiting_queue",
|
|
55
|
-
status: "queue_label_failed",
|
|
56
|
-
summary: `Queue hand-off failed while adding label "${protocol.admissionLabel}" to PR #${issue.prNumber}`,
|
|
57
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
58
|
-
});
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
}
|
package/dist/preflight.js
CHANGED
|
@@ -283,7 +283,7 @@ function checkGitHubProtocol(project, publicBaseUrl) {
|
|
|
283
283
|
];
|
|
284
284
|
}
|
|
285
285
|
const checks = [
|
|
286
|
-
pass(scope, `GitHub protocol configured for ${protocol.repoFullName} (
|
|
286
|
+
pass(scope, `GitHub protocol configured for ${protocol.repoFullName} (base "${protocol.baseBranch ?? "main"}", queue incident check "${protocol.evictionCheckName}")`),
|
|
287
287
|
];
|
|
288
288
|
if (!publicBaseUrl) {
|
|
289
289
|
checks.push(warn(scope, "PatchRelay public base URL is not configured; public operator/session links will be incomplete"));
|
|
@@ -291,9 +291,6 @@ function checkGitHubProtocol(project, publicBaseUrl) {
|
|
|
291
291
|
if (!protocol.baseBranch) {
|
|
292
292
|
checks.push(warn(scope, "GitHub base branch is not configured; defaults may diverge from the target repository"));
|
|
293
293
|
}
|
|
294
|
-
if (!protocol.admissionLabel.trim()) {
|
|
295
|
-
checks.push(fail(scope, "Merge queue admission label must not be empty"));
|
|
296
|
-
}
|
|
297
294
|
if (!protocol.evictionCheckName.trim()) {
|
|
298
295
|
checks.push(fail(scope, "Merge queue eviction check name must not be empty"));
|
|
299
296
|
}
|
|
@@ -44,7 +44,7 @@ export class QueueHealthMonitor {
|
|
|
44
44
|
const { stdout } = await execCommand("gh", [
|
|
45
45
|
"pr", "view", String(issue.prNumber),
|
|
46
46
|
"--repo", project.github.repoFullName,
|
|
47
|
-
"--json", "state,mergeable,mergeStateStatus,headRefOid
|
|
47
|
+
"--json", "state,mergeable,mergeStateStatus,headRefOid",
|
|
48
48
|
], { timeoutMs: 10_000 });
|
|
49
49
|
pr = JSON.parse(stdout);
|
|
50
50
|
}
|
|
@@ -74,9 +74,6 @@ export class QueueHealthMonitor {
|
|
|
74
74
|
}
|
|
75
75
|
if (pr.state !== "OPEN")
|
|
76
76
|
return;
|
|
77
|
-
const hasQueueLabel = pr.labels?.some((l) => l.name === protocol.admissionLabel) ?? false;
|
|
78
|
-
if (!hasQueueLabel)
|
|
79
|
-
return;
|
|
80
77
|
const isDirty = pr.mergeStateStatus === "DIRTY" || pr.mergeable === "CONFLICTING";
|
|
81
78
|
let hasEvictionCheckRun = false;
|
|
82
79
|
if (!isDirty) {
|
|
@@ -110,10 +107,17 @@ export class QueueHealthMonitor {
|
|
|
110
107
|
lastAttemptedFailureHeadSha: headRefOid,
|
|
111
108
|
lastAttemptedFailureSignature: signature,
|
|
112
109
|
});
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
111
|
+
projectId: issue.projectId,
|
|
112
|
+
linearIssueId: issue.linearIssueId,
|
|
113
|
+
eventType: "merge_steward_incident",
|
|
114
|
+
eventJson: JSON.stringify(pendingRunContext),
|
|
115
|
+
dedupeKey: `queue_health:queue_repair:${issue.linearIssueId}:${signature}`,
|
|
116
116
|
});
|
|
117
|
+
this.advancer.advanceIdleIssue(issue, "repairing_queue");
|
|
118
|
+
if (this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
119
|
+
this.advancer.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
120
|
+
}
|
|
117
121
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
|
|
118
122
|
this.feed?.publish({
|
|
119
123
|
level: "warn",
|