patchrelay 0.38.0 → 0.38.2

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.
Files changed (45) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/args.js +4 -0
  3. package/dist/cli/commands/issues.js +20 -1
  4. package/dist/cli/data.js +54 -7
  5. package/dist/cli/formatters/text.js +10 -0
  6. package/dist/cli/help.js +4 -0
  7. package/dist/cli/index.js +3 -0
  8. package/dist/config.js +26 -0
  9. package/dist/db/issue-store.js +10 -2
  10. package/dist/db/migrations.js +5 -0
  11. package/dist/factory-state.js +1 -0
  12. package/dist/github-linear-session-sync.js +57 -0
  13. package/dist/github-pr-comment-handler.js +74 -0
  14. package/dist/github-webhook-failure-context.js +70 -0
  15. package/dist/github-webhook-handler.js +52 -975
  16. package/dist/github-webhook-issue-resolution.js +46 -0
  17. package/dist/github-webhook-late-publication-guard.js +94 -0
  18. package/dist/github-webhook-policy.js +105 -0
  19. package/dist/github-webhook-reactive-run.js +302 -0
  20. package/dist/github-webhook-state-projector.js +245 -0
  21. package/dist/github-webhook-terminal-handler.js +111 -0
  22. package/dist/github-webhooks.js +39 -4
  23. package/dist/http.js +17 -0
  24. package/dist/idle-reconciliation.js +4 -2
  25. package/dist/issue-overview-query.js +8 -57
  26. package/dist/issue-session-events.js +1 -0
  27. package/dist/legacy-issue-overview.js +58 -0
  28. package/dist/linear-activity-key.js +11 -0
  29. package/dist/linear-agent-session-client.js +14 -1
  30. package/dist/linear-progress-reporter.js +7 -181
  31. package/dist/linear-status-comment-sync.js +3 -19
  32. package/dist/manual-issue-actions.js +37 -0
  33. package/dist/presentation-text.js +11 -1
  34. package/dist/prompting/patchrelay.js +8 -6
  35. package/dist/reactive-pr-state.js +65 -0
  36. package/dist/reactive-run-policy.js +35 -118
  37. package/dist/remote-pr-state.js +11 -0
  38. package/dist/run-budgets.js +12 -0
  39. package/dist/run-notification-handler.js +4 -0
  40. package/dist/run-orchestrator.js +28 -8
  41. package/dist/run-wake-planner.js +11 -10
  42. package/dist/service-issue-actions.js +80 -27
  43. package/dist/service.js +3 -0
  44. package/dist/webhooks/desired-stage-recorder.js +34 -10
  45. package/package.json +1 -1
@@ -0,0 +1,245 @@
1
+ import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver } from "./github-failure-context.js";
2
+ import { buildClosedPrCleanupFields } from "./pr-state.js";
3
+ import { canClearFailureProvenance, deriveImmediatePrCheckStatus, getGateCheckNames, getPrimaryGateCheckName, isGateCheckEvent, isMetadataOnlyCheckEvent, isQueueEvictionFailure, isStaleGateEvent, isSettledBranchFailure, resolveGitHubFactoryStateForEvent, } from "./github-webhook-policy.js";
4
+ import { buildGitHubQueueFailureContext, resolveGitHubBranchFailureContext, } from "./github-webhook-failure-context.js";
5
+ import { emitGitHubLinearActivity, syncGitHubLinearSession } from "./github-linear-session-sync.js";
6
+ import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
7
+ export async function projectGitHubWebhookState(deps, issue, event, project, linkedBy) {
8
+ const failureContextResolver = deps.failureContextResolver ?? createGitHubFailureContextResolver();
9
+ const ciSnapshotResolver = deps.ciSnapshotResolver ?? createGitHubCiSnapshotResolver();
10
+ const immediateCheckStatus = deriveImmediatePrCheckStatus(issue, event, project);
11
+ deps.db.issues.upsertIssue({
12
+ projectId: issue.projectId,
13
+ linearIssueId: issue.linearIssueId,
14
+ ...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
15
+ ...(event.prUrl !== undefined ? { prUrl: event.prUrl } : {}),
16
+ ...(event.prState !== undefined ? { prState: event.prState } : {}),
17
+ ...(event.headSha !== undefined ? { prHeadSha: event.headSha } : {}),
18
+ ...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
19
+ ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
20
+ ...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
21
+ ...(linkedBy === "issue_key" ? { branchName: event.branchName } : {}),
22
+ ...(event.reviewState === "changes_requested"
23
+ ? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
24
+ : event.reviewState === "approved"
25
+ ? { lastBlockingReviewHeadSha: null }
26
+ : {}),
27
+ ...(event.triggerEvent === "pr_closed"
28
+ ? buildClosedPrCleanupFields()
29
+ : {}),
30
+ });
31
+ await updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotResolver);
32
+ await updateGitHubFailureProvenance(deps, issue, event, project, failureContextResolver);
33
+ const queueEvictionCheck = isQueueEvictionFailure(issue, event, project);
34
+ if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
35
+ const afterMetadata = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
36
+ const newState = resolveGitHubFactoryStateForEvent(afterMetadata, event, project);
37
+ if (newState && newState !== afterMetadata.factoryState) {
38
+ deps.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
39
+ projectId: issue.projectId,
40
+ linearIssueId: issue.linearIssueId,
41
+ factoryState: newState,
42
+ });
43
+ deps.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
44
+ const transitionedIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
45
+ void emitGitHubLinearActivity({
46
+ linearProvider: deps.linearProvider,
47
+ logger: deps.logger,
48
+ feed: deps.feed,
49
+ issue: transitionedIssue,
50
+ newState,
51
+ event,
52
+ });
53
+ void syncGitHubLinearSession({
54
+ config: deps.config,
55
+ linearProvider: deps.linearProvider,
56
+ logger: deps.logger,
57
+ issue: transitionedIssue,
58
+ });
59
+ }
60
+ }
61
+ const freshIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
62
+ if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
63
+ deps.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
64
+ projectId: issue.projectId,
65
+ linearIssueId: issue.linearIssueId,
66
+ ciRepairAttempts: 0,
67
+ queueRepairAttempts: 0,
68
+ lastGitHubFailureSource: null,
69
+ lastGitHubFailureHeadSha: null,
70
+ lastGitHubFailureSignature: null,
71
+ lastGitHubFailureCheckName: null,
72
+ lastGitHubFailureCheckUrl: null,
73
+ lastGitHubFailureContextJson: null,
74
+ lastGitHubFailureAt: null,
75
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
76
+ lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
77
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
78
+ lastGitHubCiSnapshotJson: null,
79
+ lastGitHubCiSnapshotSettledAt: null,
80
+ lastQueueIncidentJson: null,
81
+ lastAttemptedFailureHeadSha: null,
82
+ lastAttemptedFailureSignature: null,
83
+ });
84
+ }
85
+ deps.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
86
+ deps.feed?.publish({
87
+ level: event.triggerEvent.includes("failed") ? "warn" : "info",
88
+ kind: "github",
89
+ issueKey: freshIssue.issueKey,
90
+ projectId: freshIssue.projectId,
91
+ stage: freshIssue.factoryState,
92
+ status: event.triggerEvent,
93
+ summary: `GitHub: ${event.triggerEvent}${event.prNumber ? ` on PR #${event.prNumber}` : ""}`,
94
+ detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
95
+ });
96
+ return freshIssue;
97
+ }
98
+ async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotResolver) {
99
+ if (event.triggerEvent === "pr_merged") {
100
+ deps.db.issues.upsertIssue({
101
+ projectId: issue.projectId,
102
+ linearIssueId: issue.linearIssueId,
103
+ lastGitHubCiSnapshotHeadSha: null,
104
+ lastGitHubCiSnapshotGateCheckName: null,
105
+ lastGitHubCiSnapshotGateCheckStatus: null,
106
+ lastGitHubCiSnapshotJson: null,
107
+ lastGitHubCiSnapshotSettledAt: null,
108
+ });
109
+ return;
110
+ }
111
+ if (event.triggerEvent === "pr_synchronize") {
112
+ deps.db.issues.upsertIssue({
113
+ projectId: issue.projectId,
114
+ linearIssueId: issue.linearIssueId,
115
+ prCheckStatus: "pending",
116
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
117
+ lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
118
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
119
+ lastGitHubCiSnapshotJson: null,
120
+ lastGitHubCiSnapshotSettledAt: null,
121
+ });
122
+ return;
123
+ }
124
+ if (issue.prState !== "open")
125
+ return;
126
+ if (event.eventSource !== "check_run" && event.eventSource !== "check_suite")
127
+ return;
128
+ if (isQueueEvictionFailure(issue, event, project))
129
+ return;
130
+ if (!isGateCheckEvent(event, project))
131
+ return;
132
+ if (isStaleGateEvent(issue, event))
133
+ return;
134
+ if (event.triggerEvent === "check_pending") {
135
+ deps.db.issues.upsertIssue({
136
+ projectId: issue.projectId,
137
+ linearIssueId: issue.linearIssueId,
138
+ prCheckStatus: "pending",
139
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
140
+ lastGitHubCiSnapshotGateCheckName: event.checkName ?? getPrimaryGateCheckName(project),
141
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
142
+ lastGitHubCiSnapshotJson: null,
143
+ lastGitHubCiSnapshotSettledAt: null,
144
+ });
145
+ return;
146
+ }
147
+ const snapshot = await ciSnapshotResolver.resolve({
148
+ repoFullName: project?.github?.repoFullName ?? event.repoFullName,
149
+ event,
150
+ gateCheckNames: getGateCheckNames(project),
151
+ });
152
+ if (!snapshot) {
153
+ deps.db.issues.upsertIssue({
154
+ projectId: issue.projectId,
155
+ linearIssueId: issue.linearIssueId,
156
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
157
+ lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
158
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
159
+ lastGitHubCiSnapshotJson: null,
160
+ lastGitHubCiSnapshotSettledAt: null,
161
+ });
162
+ deps.logger.warn({ issueKey: issue.issueKey, repoFullName: project?.github?.repoFullName ?? event.repoFullName, headSha: event.headSha }, "Could not resolve settled CI snapshot; waiting before CI repair");
163
+ deps.feed?.publish({
164
+ level: "warn",
165
+ kind: "github",
166
+ issueKey: issue.issueKey,
167
+ projectId: issue.projectId,
168
+ stage: issue.factoryState,
169
+ status: "ci_snapshot_unavailable",
170
+ summary: `Could not resolve settled ${getPrimaryGateCheckName(project)} snapshot; waiting before CI repair`,
171
+ });
172
+ return;
173
+ }
174
+ deps.db.issues.upsertIssue({
175
+ projectId: issue.projectId,
176
+ linearIssueId: issue.linearIssueId,
177
+ prCheckStatus: snapshot.gateCheckStatus,
178
+ lastGitHubCiSnapshotHeadSha: snapshot.headSha,
179
+ lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? getPrimaryGateCheckName(project),
180
+ lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
181
+ lastGitHubCiSnapshotJson: JSON.stringify(snapshot),
182
+ lastGitHubCiSnapshotSettledAt: snapshot.settledAt ?? null,
183
+ });
184
+ }
185
+ async function updateGitHubFailureProvenance(deps, issue, event, project, failureContextResolver) {
186
+ const isQueueEvictionCheck = isQueueEvictionFailure(issue, event, project);
187
+ if (event.triggerEvent === "check_failed" && issue.prState === "open") {
188
+ const source = isQueueEvictionCheck
189
+ ? "queue_eviction"
190
+ : "branch_ci";
191
+ if (source === "branch_ci" && !isSettledBranchFailure(deps.db, issue, event, project)) {
192
+ return;
193
+ }
194
+ const failureContext = source === "queue_eviction"
195
+ ? buildGitHubQueueFailureContext(event, project, buildQueueRepairContextFromEvent(event))
196
+ : await resolveGitHubBranchFailureContext({
197
+ db: deps.db,
198
+ issue,
199
+ event,
200
+ project,
201
+ failureContextResolver,
202
+ });
203
+ deps.db.issues.upsertIssue({
204
+ projectId: issue.projectId,
205
+ linearIssueId: issue.linearIssueId,
206
+ lastGitHubFailureSource: source,
207
+ lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? event.headSha ?? null,
208
+ lastGitHubFailureSignature: failureContext.failureSignature ?? null,
209
+ lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
210
+ lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
211
+ lastGitHubFailureContextJson: JSON.stringify(failureContext),
212
+ lastGitHubFailureAt: new Date().toISOString(),
213
+ ...(source === "queue_eviction"
214
+ ? {
215
+ lastQueueSignalAt: new Date().toISOString(),
216
+ lastQueueIncidentJson: JSON.stringify(buildQueueRepairContextFromEvent(event)),
217
+ }
218
+ : {
219
+ lastQueueIncidentJson: null,
220
+ }),
221
+ });
222
+ return;
223
+ }
224
+ if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionFailure(issue, event, project) || isGateCheckEvent(event, project)))
225
+ || event.triggerEvent === "pr_synchronize"
226
+ || event.triggerEvent === "pr_merged") {
227
+ if (event.triggerEvent === "check_passed" && !canClearFailureProvenance(issue, event, project)) {
228
+ return;
229
+ }
230
+ deps.db.issues.upsertIssue({
231
+ projectId: issue.projectId,
232
+ linearIssueId: issue.linearIssueId,
233
+ lastGitHubFailureSource: null,
234
+ lastGitHubFailureHeadSha: null,
235
+ lastGitHubFailureSignature: null,
236
+ lastGitHubFailureCheckName: null,
237
+ lastGitHubFailureCheckUrl: null,
238
+ lastGitHubFailureContextJson: null,
239
+ lastGitHubFailureAt: null,
240
+ lastQueueIncidentJson: null,
241
+ lastAttemptedFailureHeadSha: null,
242
+ lastAttemptedFailureSignature: null,
243
+ });
244
+ }
245
+ }
@@ -0,0 +1,111 @@
1
+ import { resolveClosedPrDisposition, resolveClosedPrFactoryState } from "./pr-state.js";
2
+ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
3
+ import { syncGitHubLinearSession } from "./github-linear-session-sync.js";
4
+ export async function handleGitHubTerminalPrEvent(params) {
5
+ const { db, linearProvider, enqueueIssue, logger, codex, issue, event, config } = params;
6
+ const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
7
+ db.issueSessions.appendIssueSessionEvent({
8
+ projectId: issue.projectId,
9
+ linearIssueId: issue.linearIssueId,
10
+ eventType,
11
+ dedupeKey: [eventType, issue.prNumber ?? event.prNumber ?? "unknown-pr", issue.prHeadSha ?? event.headSha ?? "unknown-sha"].join("::"),
12
+ });
13
+ db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
14
+ const run = issue.activeRunId ? db.runs.getRunById(issue.activeRunId) : undefined;
15
+ if (run?.threadId && run.turnId) {
16
+ try {
17
+ await codex.steerTurn({
18
+ threadId: run.threadId,
19
+ turnId: run.turnId,
20
+ input: event.triggerEvent === "pr_merged"
21
+ ? "STOP: The pull request has already merged. Stop working immediately and exit without making further changes."
22
+ : "STOP: The pull request was closed. Stop working immediately and exit without making further changes.",
23
+ });
24
+ }
25
+ catch (error) {
26
+ logger.warn({ issueKey: issue.issueKey, runId: run.id, error: error instanceof Error ? error.message : String(error) }, "Failed to steer active run after terminal PR event");
27
+ }
28
+ }
29
+ const commitTerminalUpdate = () => {
30
+ if (run) {
31
+ db.runs.finishRun(run.id, {
32
+ status: "released",
33
+ failureReason: event.triggerEvent === "pr_merged"
34
+ ? "Pull request merged during active run"
35
+ : "Pull request closed during active run",
36
+ });
37
+ }
38
+ const terminalFactoryState = event.triggerEvent === "pr_merged"
39
+ ? "done"
40
+ : resolveClosedPrFactoryState(issue);
41
+ db.issues.upsertIssue({
42
+ projectId: issue.projectId,
43
+ linearIssueId: issue.linearIssueId,
44
+ activeRunId: null,
45
+ factoryState: terminalFactoryState,
46
+ });
47
+ };
48
+ const activeLease = db.issueSessions.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
49
+ if (activeLease) {
50
+ db.issueSessions.withIssueSessionLease(issue.projectId, issue.linearIssueId, activeLease.leaseId, commitTerminalUpdate);
51
+ }
52
+ else {
53
+ db.transaction(commitTerminalUpdate);
54
+ }
55
+ db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
56
+ const updatedIssue = db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
57
+ if (event.triggerEvent === "pr_closed" && resolveClosedPrDisposition(issue) === "redelegate") {
58
+ db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
59
+ projectId: issue.projectId,
60
+ linearIssueId: issue.linearIssueId,
61
+ eventType: "delegated",
62
+ dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
63
+ });
64
+ if (db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
65
+ enqueueIssue(issue.projectId, issue.linearIssueId);
66
+ }
67
+ }
68
+ if (event.triggerEvent === "pr_merged") {
69
+ await completeLinearIssueAfterMerge(params, updatedIssue);
70
+ }
71
+ void syncGitHubLinearSession({
72
+ config,
73
+ linearProvider,
74
+ logger,
75
+ issue: updatedIssue,
76
+ });
77
+ }
78
+ async function completeLinearIssueAfterMerge(params, issue) {
79
+ const linear = await params.linearProvider.forProject(issue.projectId).catch(() => undefined);
80
+ if (!linear)
81
+ return;
82
+ try {
83
+ const liveIssue = await linear.getIssue(issue.linearIssueId);
84
+ const targetState = resolvePreferredCompletedLinearState(liveIssue);
85
+ if (!targetState) {
86
+ params.logger.warn({ issueKey: issue.issueKey }, "Could not find a completed Linear workflow state after merge");
87
+ return;
88
+ }
89
+ const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
90
+ if (normalizedCurrent === targetState.trim().toLowerCase()) {
91
+ params.db.issues.upsertIssue({
92
+ projectId: issue.projectId,
93
+ linearIssueId: issue.linearIssueId,
94
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
95
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
96
+ });
97
+ return;
98
+ }
99
+ const updated = await linear.setIssueState(issue.linearIssueId, targetState);
100
+ params.db.issues.upsertIssue({
101
+ projectId: issue.projectId,
102
+ linearIssueId: issue.linearIssueId,
103
+ ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
104
+ ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
105
+ });
106
+ }
107
+ catch (error) {
108
+ const msg = error instanceof Error ? error.message : String(error);
109
+ params.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to move merged issue to a completed Linear state");
110
+ }
111
+ }
@@ -114,9 +114,25 @@ function normalizePullRequestReviewEvent(payload, repoFullName) {
114
114
  };
115
115
  }
116
116
  function normalizeCheckSuiteEvent(payload, repoFullName) {
117
- if (payload.action !== "completed")
118
- return undefined;
119
117
  const suite = payload.check_suite;
118
+ if (payload.action !== "completed") {
119
+ if (payload.action !== "requested" && payload.action !== "rerequested" && payload.action !== "in_progress") {
120
+ return undefined;
121
+ }
122
+ const pr = suite.pull_requests?.[0];
123
+ const branchName = pr?.head.ref ?? suite.head_branch ?? "";
124
+ if (!branchName)
125
+ return undefined;
126
+ return {
127
+ triggerEvent: "check_pending",
128
+ repoFullName,
129
+ branchName,
130
+ headSha: suite.head_sha,
131
+ prNumber: pr?.number,
132
+ checkStatus: "pending",
133
+ eventSource: "check_suite",
134
+ };
135
+ }
120
136
  const conclusion = suite.conclusion?.toLowerCase();
121
137
  const pr = suite.pull_requests?.[0];
122
138
  const branchName = pr?.head.ref ?? suite.head_branch ?? "";
@@ -134,9 +150,28 @@ function normalizeCheckSuiteEvent(payload, repoFullName) {
134
150
  };
135
151
  }
136
152
  function normalizeCheckRunEvent(payload, repoFullName) {
137
- if (payload.action !== "completed")
138
- return undefined;
139
153
  const run = payload.check_run;
154
+ if (payload.action !== "completed") {
155
+ if (payload.action !== "requested" && payload.action !== "rerequested" && payload.action !== "in_progress") {
156
+ return undefined;
157
+ }
158
+ const pr = run.check_suite?.pull_requests?.[0];
159
+ const branchName = pr?.head.ref ?? run.check_suite?.head_branch ?? "";
160
+ if (!branchName)
161
+ return undefined;
162
+ return {
163
+ triggerEvent: "check_pending",
164
+ repoFullName,
165
+ branchName,
166
+ headSha: run.head_sha,
167
+ prNumber: pr?.number,
168
+ checkStatus: "pending",
169
+ checkName: run.name,
170
+ checkUrl: run.html_url,
171
+ checkDetailsUrl: run.details_url,
172
+ eventSource: "check_run",
173
+ };
174
+ }
140
175
  const conclusion = run.conclusion?.toLowerCase();
141
176
  const pr = run.check_suite?.pull_requests?.[0];
142
177
  const branchName = pr?.head.ref ?? run.check_suite?.head_branch ?? "";
package/dist/http.js CHANGED
@@ -314,6 +314,23 @@ export async function buildHttpServer(config, service, logger) {
314
314
  }
315
315
  return reply.send({ ok: true, ...result });
316
316
  });
317
+ app.post("/api/issues/:issueKey/close", async (request, reply) => {
318
+ const issueKey = request.params.issueKey;
319
+ const body = request.body;
320
+ const result = await service.closeIssue(issueKey, {
321
+ failed: body?.failed === true,
322
+ ...(typeof body?.reason === "string" && body.reason.trim()
323
+ ? { reason: body.reason.trim() }
324
+ : {}),
325
+ });
326
+ if (!result) {
327
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
328
+ }
329
+ if ("error" in result) {
330
+ return reply.code(409).send({ ok: false, reason: result.error });
331
+ }
332
+ return reply.send({ ok: true, ...result });
333
+ });
317
334
  app.get("/api/installations", async (_request, reply) => {
318
335
  return reply.send({ ok: true, installations: service.listLinearInstallations() });
319
336
  });
@@ -4,8 +4,8 @@ import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
4
4
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
5
5
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
6
6
  import { buildClosedPrCleanupFields, resolveClosedPrDisposition } from "./pr-state.js";
7
+ import { getReviewFixBudget } from "./run-budgets.js";
7
8
  import { execCommand } from "./utils.js";
8
- const DEFAULT_REVIEW_FIX_BUDGET = 12;
9
9
  function isFailingCheckStatus(status) {
10
10
  return status === "failed" || status === "failure";
11
11
  }
@@ -510,13 +510,15 @@ export class IdleIssueReconciler {
510
510
  if (issue.delegatedToPatchRelay
511
511
  && (issue.factoryState === "escalated" || issue.factoryState === "failed")
512
512
  && (reactiveIntent?.runType === "review_fix" || reactiveIntent?.runType === "branch_upkeep")) {
513
- if (issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
513
+ const reviewFixBudget = getReviewFixBudget(project);
514
+ if (issue.reviewFixAttempts >= reviewFixBudget) {
514
515
  this.logger.debug({
515
516
  issueKey: issue.issueKey,
516
517
  prNumber: issue.prNumber,
517
518
  from: issue.factoryState,
518
519
  runType: reactiveIntent.runType,
519
520
  reviewFixAttempts: issue.reviewFixAttempts,
521
+ reviewFixBudget,
520
522
  }, "Reconciliation: leaving terminal requested-changes issue escalated because the repair budget is exhausted");
521
523
  return;
522
524
  }
@@ -1,5 +1,6 @@
1
1
  import { parseGitHubFailureContext } from "./github-failure-context.js";
2
2
  import { isIssueSessionReadyForExecution } from "./issue-session.js";
3
+ import { getLegacyIssueOverview } from "./legacy-issue-overview.js";
3
4
  import { deriveIssueStatusNote } from "./status-note.js";
4
5
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
5
6
  export function parseStageReport(reportJson, runStatus) {
@@ -25,7 +26,13 @@ export class IssueOverviewQuery {
25
26
  async getIssueOverview(issueKey) {
26
27
  const session = this.db.issueSessions.getIssueSessionByKey(issueKey);
27
28
  if (!session) {
28
- return await this.getLegacyIssueOverview(issueKey);
29
+ return await getLegacyIssueOverview({
30
+ db: this.db,
31
+ issueKey,
32
+ runStatusProvider: this.runStatusProvider,
33
+ buildRuns: (projectId, linearIssueId) => this.buildRuns(projectId, linearIssueId),
34
+ readLiveThread: (run) => this.readLiveThread(run),
35
+ });
29
36
  }
30
37
  return await this.getSessionIssueOverview(issueKey, session);
31
38
  }
@@ -66,62 +73,6 @@ export class IssueOverviewQuery {
66
73
  })(),
67
74
  }));
68
75
  }
69
- async getLegacyIssueOverview(issueKey) {
70
- const legacy = this.db.getIssueOverview(issueKey);
71
- if (!legacy)
72
- return undefined;
73
- const issueRecord = this.db.issues.getIssueByKey(issueKey);
74
- const activeStatus = await this.runStatusProvider.getActiveRunStatus(issueKey);
75
- const activeRun = activeStatus?.run ?? legacy.activeRun;
76
- const latestRun = this.db.runs.getLatestRunForIssue(legacy.issue.projectId, legacy.issue.linearIssueId);
77
- const latestEvent = this.db.issueSessions.listIssueSessionEvents(legacy.issue.projectId, legacy.issue.linearIssueId, { limit: 1 }).at(-1);
78
- const runs = this.buildRuns(legacy.issue.projectId, legacy.issue.linearIssueId);
79
- const runCount = runs.length;
80
- const liveThread = await this.readLiveThread(activeRun);
81
- const statusNote = issueRecord
82
- ? deriveIssueStatusNote({
83
- issue: issueRecord,
84
- latestRun,
85
- latestEvent,
86
- failureSummary: legacy.issue.latestFailureSummary,
87
- blockedByKeys: legacy.issue.blockedByKeys,
88
- waitingReason: legacy.issue.waitingReason,
89
- })
90
- : legacy.issue.statusNote;
91
- return {
92
- issue: {
93
- ...legacy.issue,
94
- ...(statusNote ? { statusNote } : {}),
95
- },
96
- ...(activeRun ? { activeRun } : {}),
97
- ...(latestRun ? { latestRun } : {}),
98
- ...(liveThread ? { liveThread } : {}),
99
- ...(runs.length > 0 ? { runs } : {}),
100
- ...(issueRecord
101
- ? {
102
- issueContext: {
103
- ...(issueRecord.description ? { description: issueRecord.description } : {}),
104
- ...(issueRecord.currentLinearState ? { currentLinearState: issueRecord.currentLinearState } : {}),
105
- ...(issueRecord.url ? { issueUrl: issueRecord.url } : {}),
106
- ...(issueRecord.worktreePath ? { worktreePath: issueRecord.worktreePath } : {}),
107
- ...(issueRecord.branchName ? { branchName: issueRecord.branchName } : {}),
108
- ...(issueRecord.prUrl ? { prUrl: issueRecord.prUrl } : {}),
109
- ...(issueRecord.priority != null ? { priority: issueRecord.priority } : {}),
110
- ...(issueRecord.estimate != null ? { estimate: issueRecord.estimate } : {}),
111
- ciRepairAttempts: issueRecord.ciRepairAttempts,
112
- queueRepairAttempts: issueRecord.queueRepairAttempts,
113
- reviewFixAttempts: issueRecord.reviewFixAttempts,
114
- ...(legacy.issue.latestFailureSource ? { latestFailureSource: legacy.issue.latestFailureSource } : {}),
115
- ...(legacy.issue.latestFailureHeadSha ? { latestFailureHeadSha: legacy.issue.latestFailureHeadSha } : {}),
116
- ...(legacy.issue.latestFailureCheckName ? { latestFailureCheckName: legacy.issue.latestFailureCheckName } : {}),
117
- ...(legacy.issue.latestFailureStepName ? { latestFailureStepName: legacy.issue.latestFailureStepName } : {}),
118
- ...(legacy.issue.latestFailureSummary ? { latestFailureSummary: legacy.issue.latestFailureSummary } : {}),
119
- runCount,
120
- },
121
- }
122
- : {}),
123
- };
124
- }
125
76
  async getSessionIssueOverview(issueKey, session) {
126
77
  const issueRecord = this.db.issues.getIssueByKey(issueKey);
127
78
  const blockedBy = this.db.issues.listIssueDependencies(session.projectId, session.linearIssueId);
@@ -1,6 +1,7 @@
1
1
  import { sanitizeOperatorFacingText } from "./presentation-text.js";
2
2
  const TERMINAL_SESSION_EVENTS = new Set([
3
3
  "stop_requested",
4
+ "operator_closed",
4
5
  "undelegated",
5
6
  "issue_removed",
6
7
  "pr_closed",
@@ -0,0 +1,58 @@
1
+ import { deriveIssueStatusNote } from "./status-note.js";
2
+ export async function getLegacyIssueOverview(params) {
3
+ const { db, issueKey, runStatusProvider, buildRuns, readLiveThread } = params;
4
+ const legacy = db.getIssueOverview(issueKey);
5
+ if (!legacy)
6
+ return undefined;
7
+ const issueRecord = db.issues.getIssueByKey(issueKey);
8
+ const activeStatus = await runStatusProvider.getActiveRunStatus(issueKey);
9
+ const activeRun = activeStatus?.run ?? legacy.activeRun;
10
+ const latestRun = db.runs.getLatestRunForIssue(legacy.issue.projectId, legacy.issue.linearIssueId);
11
+ const latestEvent = db.issueSessions.listIssueSessionEvents(legacy.issue.projectId, legacy.issue.linearIssueId, { limit: 1 }).at(-1);
12
+ const runs = buildRuns(legacy.issue.projectId, legacy.issue.linearIssueId);
13
+ const runCount = runs.length;
14
+ const liveThread = await readLiveThread(activeRun);
15
+ const statusNote = issueRecord
16
+ ? deriveIssueStatusNote({
17
+ issue: issueRecord,
18
+ latestRun,
19
+ latestEvent,
20
+ failureSummary: legacy.issue.latestFailureSummary,
21
+ blockedByKeys: legacy.issue.blockedByKeys,
22
+ waitingReason: legacy.issue.waitingReason,
23
+ })
24
+ : legacy.issue.statusNote;
25
+ return {
26
+ issue: {
27
+ ...legacy.issue,
28
+ ...(statusNote ? { statusNote } : {}),
29
+ },
30
+ ...(activeRun ? { activeRun } : {}),
31
+ ...(latestRun ? { latestRun } : {}),
32
+ ...(liveThread ? { liveThread } : {}),
33
+ ...(runs.length > 0 ? { runs } : {}),
34
+ ...(issueRecord
35
+ ? {
36
+ issueContext: {
37
+ ...(issueRecord.description ? { description: issueRecord.description } : {}),
38
+ ...(issueRecord.currentLinearState ? { currentLinearState: issueRecord.currentLinearState } : {}),
39
+ ...(issueRecord.url ? { issueUrl: issueRecord.url } : {}),
40
+ ...(issueRecord.worktreePath ? { worktreePath: issueRecord.worktreePath } : {}),
41
+ ...(issueRecord.branchName ? { branchName: issueRecord.branchName } : {}),
42
+ ...(issueRecord.prUrl ? { prUrl: issueRecord.prUrl } : {}),
43
+ ...(issueRecord.priority != null ? { priority: issueRecord.priority } : {}),
44
+ ...(issueRecord.estimate != null ? { estimate: issueRecord.estimate } : {}),
45
+ ciRepairAttempts: issueRecord.ciRepairAttempts,
46
+ queueRepairAttempts: issueRecord.queueRepairAttempts,
47
+ reviewFixAttempts: issueRecord.reviewFixAttempts,
48
+ ...(legacy.issue.latestFailureSource ? { latestFailureSource: legacy.issue.latestFailureSource } : {}),
49
+ ...(legacy.issue.latestFailureHeadSha ? { latestFailureHeadSha: legacy.issue.latestFailureHeadSha } : {}),
50
+ ...(legacy.issue.latestFailureCheckName ? { latestFailureCheckName: legacy.issue.latestFailureCheckName } : {}),
51
+ ...(legacy.issue.latestFailureStepName ? { latestFailureStepName: legacy.issue.latestFailureStepName } : {}),
52
+ ...(legacy.issue.latestFailureSummary ? { latestFailureSummary: legacy.issue.latestFailureSummary } : {}),
53
+ runCount,
54
+ },
55
+ }
56
+ : {}),
57
+ };
58
+ }
@@ -0,0 +1,11 @@
1
+ import { sanitizeOperatorFacingText } from "./presentation-text.js";
2
+ export function computeLinearActivityKey(content) {
3
+ if (content.type === "action") {
4
+ const action = sanitizeOperatorFacingText(content.action) ?? content.action;
5
+ const parameter = sanitizeOperatorFacingText(content.parameter) ?? content.parameter;
6
+ const result = sanitizeOperatorFacingText(content.result);
7
+ return `action:${action}:${parameter}:${result ?? ""}`;
8
+ }
9
+ const body = sanitizeOperatorFacingText(content.body) ?? content.body;
10
+ return `${content.type}:${body}`;
11
+ }
@@ -1,5 +1,6 @@
1
1
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
+ import { computeLinearActivityKey } from "./linear-activity-key.js";
3
4
  export class LinearAgentSessionClient {
4
5
  config;
5
6
  db;
@@ -36,11 +37,23 @@ export class LinearAgentSessionClient {
36
37
  if (!linear)
37
38
  return;
38
39
  const allowEphemeral = content.type === "thought" || content.type === "action";
40
+ const ephemeral = options?.ephemeral && allowEphemeral;
41
+ const activityKey = ephemeral ? undefined : computeLinearActivityKey(content);
42
+ if (activityKey && syncedIssue.lastLinearActivityKey === activityKey) {
43
+ return;
44
+ }
39
45
  await linear.createAgentActivity({
40
46
  agentSessionId: syncedIssue.agentSessionId,
41
47
  content,
42
- ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
48
+ ...(ephemeral ? { ephemeral: true } : {}),
43
49
  });
50
+ if (activityKey) {
51
+ this.db.issues.upsertIssue({
52
+ projectId: syncedIssue.projectId,
53
+ linearIssueId: syncedIssue.linearIssueId,
54
+ lastLinearActivityKey: activityKey,
55
+ });
56
+ }
44
57
  }
45
58
  catch (error) {
46
59
  const msg = error instanceof Error ? error.message : String(error);