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.
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +4 -0
- package/dist/cli/commands/issues.js +20 -1
- package/dist/cli/data.js +54 -7
- package/dist/cli/formatters/text.js +10 -0
- package/dist/cli/help.js +4 -0
- package/dist/cli/index.js +3 -0
- package/dist/config.js +26 -0
- package/dist/db/issue-store.js +10 -2
- package/dist/db/migrations.js +5 -0
- package/dist/factory-state.js +1 -0
- package/dist/github-linear-session-sync.js +57 -0
- package/dist/github-pr-comment-handler.js +74 -0
- package/dist/github-webhook-failure-context.js +70 -0
- package/dist/github-webhook-handler.js +52 -975
- package/dist/github-webhook-issue-resolution.js +46 -0
- package/dist/github-webhook-late-publication-guard.js +94 -0
- package/dist/github-webhook-policy.js +105 -0
- package/dist/github-webhook-reactive-run.js +302 -0
- package/dist/github-webhook-state-projector.js +245 -0
- package/dist/github-webhook-terminal-handler.js +111 -0
- package/dist/github-webhooks.js +39 -4
- package/dist/http.js +17 -0
- package/dist/idle-reconciliation.js +4 -2
- package/dist/issue-overview-query.js +8 -57
- package/dist/issue-session-events.js +1 -0
- package/dist/legacy-issue-overview.js +58 -0
- package/dist/linear-activity-key.js +11 -0
- package/dist/linear-agent-session-client.js +14 -1
- package/dist/linear-progress-reporter.js +7 -181
- package/dist/linear-status-comment-sync.js +3 -19
- package/dist/manual-issue-actions.js +37 -0
- package/dist/presentation-text.js +11 -1
- package/dist/prompting/patchrelay.js +8 -6
- package/dist/reactive-pr-state.js +65 -0
- package/dist/reactive-run-policy.js +35 -118
- package/dist/remote-pr-state.js +11 -0
- package/dist/run-budgets.js +12 -0
- package/dist/run-notification-handler.js +4 -0
- package/dist/run-orchestrator.js +28 -8
- package/dist/run-wake-planner.js +11 -10
- package/dist/service-issue-actions.js +80 -27
- package/dist/service.js +3 -0
- package/dist/webhooks/desired-stage-recorder.js +34 -10
- 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
|
+
}
|
package/dist/github-webhooks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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);
|
|
@@ -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
|
-
...(
|
|
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);
|