patchrelay 0.36.11 → 0.36.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.
@@ -1,239 +1,27 @@
1
- import { parseGitHubFailureContext } from "./github-failure-context.js";
2
- import { isIssueSessionReadyForExecution } from "./issue-session.js";
3
1
  import { extractStageSummary, summarizeCurrentThread } from "./run-reporting.js";
4
- import { deriveIssueStatusNote } from "./status-note.js";
5
- import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
6
- function parseStageReport(reportJson, runStatus) {
7
- if (!reportJson)
8
- return undefined;
9
- try {
10
- const parsed = JSON.parse(reportJson);
11
- return { ...parsed, status: runStatus };
12
- }
13
- catch {
14
- return undefined;
15
- }
16
- }
2
+ import { IssueOverviewQuery, parseStageReport, } from "./issue-overview-query.js";
17
3
  export class IssueQueryService {
18
4
  db;
19
- codex;
20
5
  runStatusProvider;
6
+ overviewQuery;
21
7
  constructor(db, codex, runStatusProvider) {
22
8
  this.db = db;
23
- this.codex = codex;
24
9
  this.runStatusProvider = runStatusProvider;
25
- }
26
- async readLiveThread(run) {
27
- if (!run?.threadId)
28
- return undefined;
29
- return await this.codex.readThread(run.threadId, true).catch(() => undefined);
30
- }
31
- buildRuns(projectId, linearIssueId) {
32
- return this.db.runs.listRunsForIssue(projectId, linearIssueId).map((run) => ({
33
- id: run.id,
34
- runType: run.runType,
35
- status: run.status,
36
- startedAt: run.startedAt,
37
- ...(run.endedAt ? { endedAt: run.endedAt } : {}),
38
- ...(run.threadId ? { threadId: run.threadId } : {}),
39
- ...(() => {
40
- const report = parseStageReport(run.reportJson, run.status);
41
- return report ? { report } : {};
42
- })(),
43
- ...(() => {
44
- const events = this.db.runs.listThreadEvents(run.id).flatMap((event) => {
45
- try {
46
- const parsed = JSON.parse(event.eventJson);
47
- return [{
48
- id: event.id,
49
- method: event.method,
50
- createdAt: event.createdAt,
51
- ...(parsed && typeof parsed === "object" && !Array.isArray(parsed)
52
- ? { parsedEvent: parsed }
53
- : {}),
54
- }];
55
- }
56
- catch {
57
- return [{
58
- id: event.id,
59
- method: event.method,
60
- createdAt: event.createdAt,
61
- }];
62
- }
63
- });
64
- return events.length > 0 ? { events } : {};
65
- })(),
66
- }));
10
+ this.overviewQuery = new IssueOverviewQuery(db, codex, runStatusProvider);
67
11
  }
68
12
  async getIssueOverview(issueKey) {
69
- const session = this.db.issueSessions.getIssueSessionByKey(issueKey);
70
- if (!session) {
71
- const legacy = this.db.getIssueOverview(issueKey);
72
- if (!legacy)
73
- return undefined;
74
- const issueRecord = this.db.issues.getIssueByKey(issueKey);
75
- const activeStatus = await this.runStatusProvider.getActiveRunStatus(issueKey);
76
- const activeRun = activeStatus?.run ?? legacy.activeRun;
77
- const latestRun = this.db.runs.getLatestRunForIssue(legacy.issue.projectId, legacy.issue.linearIssueId);
78
- const latestEvent = this.db.issueSessions.listIssueSessionEvents(legacy.issue.projectId, legacy.issue.linearIssueId, { limit: 1 }).at(-1);
79
- const runs = this.buildRuns(legacy.issue.projectId, legacy.issue.linearIssueId);
80
- const runCount = runs.length;
81
- const liveThread = await this.readLiveThread(activeRun);
82
- const statusNote = issueRecord
83
- ? deriveIssueStatusNote({
84
- issue: issueRecord,
85
- latestRun,
86
- latestEvent,
87
- failureSummary: legacy.issue.latestFailureSummary,
88
- blockedByKeys: legacy.issue.blockedByKeys,
89
- waitingReason: legacy.issue.waitingReason,
90
- })
91
- : legacy.issue.statusNote;
92
- return {
93
- issue: {
94
- ...legacy.issue,
95
- ...(statusNote ? { statusNote } : {}),
96
- },
97
- ...(activeRun ? { activeRun } : {}),
98
- ...(latestRun ? { latestRun } : {}),
99
- ...(liveThread ? { liveThread } : {}),
100
- ...(runs.length > 0 ? { runs } : {}),
101
- ...(issueRecord
102
- ? {
103
- issueContext: {
104
- ...(issueRecord.description ? { description: issueRecord.description } : {}),
105
- ...(issueRecord.currentLinearState ? { currentLinearState: issueRecord.currentLinearState } : {}),
106
- ...(issueRecord.url ? { issueUrl: issueRecord.url } : {}),
107
- ...(issueRecord.worktreePath ? { worktreePath: issueRecord.worktreePath } : {}),
108
- ...(issueRecord.branchName ? { branchName: issueRecord.branchName } : {}),
109
- ...(issueRecord.prUrl ? { prUrl: issueRecord.prUrl } : {}),
110
- ...(issueRecord.priority != null ? { priority: issueRecord.priority } : {}),
111
- ...(issueRecord.estimate != null ? { estimate: issueRecord.estimate } : {}),
112
- ciRepairAttempts: issueRecord.ciRepairAttempts,
113
- queueRepairAttempts: issueRecord.queueRepairAttempts,
114
- reviewFixAttempts: issueRecord.reviewFixAttempts,
115
- ...(legacy.issue.latestFailureSource ? { latestFailureSource: legacy.issue.latestFailureSource } : {}),
116
- ...(legacy.issue.latestFailureHeadSha ? { latestFailureHeadSha: legacy.issue.latestFailureHeadSha } : {}),
117
- ...(legacy.issue.latestFailureCheckName ? { latestFailureCheckName: legacy.issue.latestFailureCheckName } : {}),
118
- ...(legacy.issue.latestFailureStepName ? { latestFailureStepName: legacy.issue.latestFailureStepName } : {}),
119
- ...(legacy.issue.latestFailureSummary ? { latestFailureSummary: legacy.issue.latestFailureSummary } : {}),
120
- runCount,
121
- },
122
- }
123
- : {}),
124
- };
125
- }
126
- const issueRecord = this.db.issues.getIssueByKey(issueKey);
127
- const blockedBy = this.db.issues.listIssueDependencies(session.projectId, session.linearIssueId);
128
- const unresolvedBlockedBy = blockedBy.filter((entry) => (entry.blockerCurrentLinearStateType !== "completed"
129
- && entry.blockerCurrentLinearState?.trim().toLowerCase() !== "done"));
130
- const blockedByKeys = unresolvedBlockedBy.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId);
131
- const activeStatus = await this.runStatusProvider.getActiveRunStatus(issueKey);
132
- const activeRun = activeStatus?.run
133
- ?? (session.activeRunId !== undefined ? this.db.runs.getRunById(session.activeRunId) : undefined);
134
- const latestRun = this.db.runs.getLatestRunForIssue(session.projectId, session.linearIssueId);
135
- const latestEvent = this.db.issueSessions.listIssueSessionEvents(session.projectId, session.linearIssueId, { limit: 1 }).at(-1);
136
- const runs = this.buildRuns(session.projectId, session.linearIssueId);
137
- const runCount = runs.length;
138
- const liveThread = await this.readLiveThread(activeRun);
139
- const failureContext = parseGitHubFailureContext(issueRecord?.lastGitHubFailureContextJson);
140
- const waitingReason = session.waitingReason ?? derivePatchRelayWaitingReason({
141
- ...(activeRun ? { activeRunType: activeRun.runType } : {}),
142
- blockedByKeys,
143
- factoryState: issueRecord?.factoryState ?? "delegated",
144
- pendingRunType: issueRecord?.pendingRunType,
145
- prNumber: session.prNumber,
146
- prHeadSha: issueRecord?.prHeadSha ?? session.prHeadSha,
147
- prReviewState: issueRecord?.prReviewState,
148
- prCheckStatus: issueRecord?.prCheckStatus,
149
- lastBlockingReviewHeadSha: issueRecord?.lastBlockingReviewHeadSha,
150
- latestFailureCheckName: issueRecord?.lastGitHubFailureCheckName,
151
- });
152
- const issue = {
153
- id: issueRecord?.id ?? session.id,
154
- projectId: session.projectId,
155
- linearIssueId: session.linearIssueId,
156
- ...(session.issueKey ? { issueKey: session.issueKey } : {}),
157
- ...(issueRecord?.title ? { title: issueRecord.title } : {}),
158
- ...(issueRecord?.url ? { issueUrl: issueRecord.url } : {}),
159
- ...(issueRecord?.currentLinearState ? { currentLinearState: issueRecord.currentLinearState } : {}),
160
- sessionState: session.sessionState,
161
- factoryState: issueRecord?.factoryState ?? "delegated",
162
- blockedByCount: unresolvedBlockedBy.length,
163
- blockedByKeys,
164
- readyForExecution: isIssueSessionReadyForExecution({
165
- sessionState: session.sessionState,
166
- factoryState: issueRecord?.factoryState ?? "delegated",
167
- ...(activeRun ? { activeRunId: activeRun.id } : {}),
168
- blockedByCount: unresolvedBlockedBy.length,
169
- hasPendingWake: this.db.issueSessions.peekIssueSessionWake(session.projectId, session.linearIssueId) !== undefined,
170
- hasLegacyPendingRun: issueRecord?.pendingRunType !== undefined,
171
- ...(session.prNumber !== undefined ? { prNumber: session.prNumber } : {}),
172
- ...(issueRecord?.prState ? { prState: issueRecord.prState } : {}),
173
- ...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
174
- ...(issueRecord?.prCheckStatus ? { prCheckStatus: issueRecord.prCheckStatus } : {}),
175
- ...(issueRecord?.lastGitHubFailureSource ? { latestFailureSource: issueRecord.lastGitHubFailureSource } : {}),
176
- }),
177
- ...(issueRecord?.lastGitHubFailureSource ? { latestFailureSource: issueRecord.lastGitHubFailureSource } : {}),
178
- ...(issueRecord?.lastGitHubFailureHeadSha ? { latestFailureHeadSha: issueRecord.lastGitHubFailureHeadSha } : {}),
179
- ...(issueRecord?.lastGitHubFailureCheckName ? { latestFailureCheckName: issueRecord.lastGitHubFailureCheckName } : {}),
180
- ...(() => {
181
- const statusNote = issueRecord
182
- ? deriveIssueStatusNote({
183
- issue: issueRecord,
184
- sessionSummary: session.summaryText,
185
- latestRun,
186
- latestEvent,
187
- failureSummary: failureContext?.summary,
188
- blockedByKeys,
189
- waitingReason,
190
- })
191
- : undefined;
192
- return statusNote ? { statusNote } : {};
193
- })(),
194
- ...(waitingReason ? { waitingReason } : {}),
195
- ...(activeRun ? { activeRunId: activeRun.id } : {}),
196
- ...(issueRecord?.agentSessionId ? { activeAgentSessionId: issueRecord.agentSessionId } : {}),
197
- updatedAt: session.updatedAt,
198
- };
199
- return {
200
- issue,
201
- session,
202
- ...(activeRun ? { activeRun } : {}),
203
- ...(latestRun ? { latestRun } : {}),
204
- ...(liveThread ? { liveThread } : {}),
205
- ...(runs.length > 0 ? { runs } : {}),
206
- issueContext: {
207
- ...(issueRecord?.description ? { description: issueRecord.description } : {}),
208
- ...(issueRecord?.currentLinearState ? { currentLinearState: issueRecord.currentLinearState } : {}),
209
- ...(issueRecord?.url ? { issueUrl: issueRecord.url } : {}),
210
- ...(session.worktreePath ? { worktreePath: session.worktreePath } : {}),
211
- ...(session.branchName ? { branchName: session.branchName } : {}),
212
- ...(issueRecord?.prUrl ? { prUrl: issueRecord.prUrl } : {}),
213
- ...(issueRecord?.priority != null ? { priority: issueRecord.priority } : {}),
214
- ...(issueRecord?.estimate != null ? { estimate: issueRecord.estimate } : {}),
215
- ciRepairAttempts: issueRecord?.ciRepairAttempts ?? session.ciRepairAttempts,
216
- queueRepairAttempts: issueRecord?.queueRepairAttempts ?? session.queueRepairAttempts,
217
- reviewFixAttempts: issueRecord?.reviewFixAttempts ?? session.reviewFixAttempts,
218
- ...(issue.latestFailureSource ? { latestFailureSource: issue.latestFailureSource } : {}),
219
- ...(issue.latestFailureHeadSha ? { latestFailureHeadSha: issue.latestFailureHeadSha } : {}),
220
- ...(issue.latestFailureCheckName ? { latestFailureCheckName: issue.latestFailureCheckName } : {}),
221
- ...(issue.latestFailureStepName ? { latestFailureStepName: issue.latestFailureStepName } : {}),
222
- ...(issue.latestFailureSummary ? { latestFailureSummary: issue.latestFailureSummary } : {}),
223
- runCount,
224
- },
225
- };
13
+ return await this.overviewQuery.getIssueOverview(issueKey);
226
14
  }
227
15
  async getActiveRunStatus(issueKey) {
228
16
  return await this.runStatusProvider.getActiveRunStatus(issueKey);
229
17
  }
230
18
  async getPublicAgentSessionStatus(issueKey) {
231
- const overview = await this.getIssueOverview(issueKey);
19
+ const overview = await this.overviewQuery.getIssueOverview(issueKey);
232
20
  if (!overview)
233
21
  return undefined;
234
22
  const issueRecord = this.db.issues.getIssueByKey(issueKey);
235
23
  const latestRunReport = parseStageReport(overview.latestRun?.reportJson, overview.latestRun?.status ?? "unknown");
236
- const runs = (overview.runs ?? this.buildRuns(overview.issue.projectId, overview.issue.linearIssueId)).map((run) => ({
24
+ const runs = (overview.runs ?? this.overviewQuery.buildRuns(overview.issue.projectId, overview.issue.linearIssueId)).map((run) => ({
237
25
  run: {
238
26
  id: run.id,
239
27
  runType: run.runType,
@@ -62,20 +62,51 @@ export function buildRunStartedActivity(runType) {
62
62
  }
63
63
  }
64
64
  export function buildRunCompletedActivity(params) {
65
- const label = formatRunTypeLabel(params.runType);
66
- const nextState = describeNextState(params.postRunState, params.prNumber);
65
+ const prLabel = params.prNumber ? `PR #${params.prNumber}` : "the pull request";
67
66
  const summary = trimSummary(params.completionSummary);
68
- const lines = [`${label} completed.`];
69
- if (nextState) {
70
- lines.push("", nextState);
71
- }
72
- if (summary) {
73
- lines.push("", summary);
67
+ const detail = summary ? ` ${summary}` : "";
68
+ switch (params.runType) {
69
+ case "implementation":
70
+ if (params.postRunState === "pr_open") {
71
+ return {
72
+ type: "response",
73
+ body: `${prLabel} opened:${detail || " Published and ready for review."}`,
74
+ };
75
+ }
76
+ return undefined;
77
+ case "review_fix":
78
+ return {
79
+ type: "response",
80
+ body: `Updated ${prLabel} to address review feedback.${detail}`,
81
+ };
82
+ case "ci_repair":
83
+ return {
84
+ type: "response",
85
+ body: `Updated ${prLabel} after CI repair.${detail}`,
86
+ };
87
+ case "queue_repair":
88
+ return {
89
+ type: "response",
90
+ body: `Updated ${prLabel} after merge-queue repair.${detail}`,
91
+ };
92
+ case "branch_upkeep":
93
+ return undefined;
94
+ default: {
95
+ const label = formatRunTypeLabel(params.runType);
96
+ const nextState = describeNextState(params.postRunState, params.prNumber);
97
+ const lines = [`${label} completed.`];
98
+ if (nextState) {
99
+ lines.push("", nextState);
100
+ }
101
+ if (summary) {
102
+ lines.push("", summary);
103
+ }
104
+ return {
105
+ type: "response",
106
+ body: lines.join("\n"),
107
+ };
108
+ }
74
109
  }
75
- return {
76
- type: "response",
77
- body: lines.join("\n"),
78
- };
79
110
  }
80
111
  export function buildRunFailureActivity(runType, reason) {
81
112
  const label = formatRunTypeLabel(runType);
@@ -92,33 +123,16 @@ export function buildStopConfirmationActivity() {
92
123
  }
93
124
  export function buildGitHubStateActivity(newState, event) {
94
125
  switch (newState) {
95
- case "pr_open": {
96
- const parts = [`PR #${event.prNumber ?? "?"} is open and ready for review.`];
97
- if (event.prUrl) {
98
- parts.push("", event.prUrl);
99
- }
100
- return { type: "response", body: parts.join("\n") };
101
- }
126
+ case "pr_open":
127
+ return undefined;
102
128
  case "awaiting_queue":
103
- return { type: "response", body: "Review approved. PatchRelay is moving the PR toward merge." };
129
+ return undefined;
104
130
  case "changes_requested":
105
- return {
106
- type: "action",
107
- action: "Addressing",
108
- parameter: event.reviewerName ? `review feedback from ${event.reviewerName}` : "review feedback",
109
- };
131
+ return undefined;
110
132
  case "repairing_ci":
111
- return {
112
- type: "action",
113
- action: "Repairing",
114
- parameter: event.checkName ? `CI failure: ${event.checkName}` : "failing CI checks",
115
- };
133
+ return undefined;
116
134
  case "repairing_queue":
117
- return {
118
- type: "action",
119
- action: "Repairing",
120
- parameter: "merge queue validation",
121
- };
135
+ return undefined;
122
136
  case "done":
123
137
  return { type: "response", body: `PR merged.${event.prNumber ? ` PR #${event.prNumber}` : ""}` };
124
138
  case "failed":
@@ -0,0 +1,288 @@
1
+ import { execCommand } from "./utils.js";
2
+ function isRequestedChangesRunType(runType) {
3
+ return runType === "review_fix" || runType === "branch_upkeep";
4
+ }
5
+ function normalizeRemotePrState(value) {
6
+ const normalized = value?.trim().toUpperCase();
7
+ if (normalized === "OPEN")
8
+ return "open";
9
+ if (normalized === "CLOSED")
10
+ return "closed";
11
+ if (normalized === "MERGED")
12
+ return "merged";
13
+ return undefined;
14
+ }
15
+ function normalizeRemoteReviewDecision(value) {
16
+ const normalized = value?.trim().toUpperCase();
17
+ if (normalized === "APPROVED")
18
+ return "approved";
19
+ if (normalized === "CHANGES_REQUESTED")
20
+ return "changes_requested";
21
+ if (normalized === "REVIEW_REQUIRED")
22
+ return "commented";
23
+ return undefined;
24
+ }
25
+ function isDirtyMergeStateStatus(value) {
26
+ return value?.trim().toUpperCase() === "DIRTY";
27
+ }
28
+ function buildReviewFixBranchUpkeepContext(prNumber, baseBranch, pr, context) {
29
+ const promptContext = [
30
+ `The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${String(pr.mergeStateStatus)} against latest ${baseBranch}.`,
31
+ `This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
32
+ "Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
33
+ ].join(" ");
34
+ return {
35
+ ...(context ?? {}),
36
+ branchUpkeepRequired: true,
37
+ reviewFixMode: "branch_upkeep",
38
+ wakeReason: "branch_upkeep",
39
+ promptContext,
40
+ ...(pr.mergeStateStatus ? { mergeStateStatus: pr.mergeStateStatus } : {}),
41
+ ...(pr.headRefOid ? { failingHeadSha: pr.headRefOid } : {}),
42
+ baseBranch,
43
+ };
44
+ }
45
+ export class ReactiveRunPolicy {
46
+ config;
47
+ db;
48
+ logger;
49
+ withHeldLease;
50
+ constructor(config, db, logger, withHeldLease) {
51
+ this.config = config;
52
+ this.db = db;
53
+ this.logger = logger;
54
+ this.withHeldLease = withHeldLease;
55
+ }
56
+ async verifyReactiveRunAdvancedBranch(run, issue) {
57
+ if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
58
+ return undefined;
59
+ }
60
+ if (!issue.prNumber || issue.prState !== "open" || !issue.lastGitHubFailureHeadSha) {
61
+ return undefined;
62
+ }
63
+ const project = this.config.projects.find((entry) => entry.id === run.projectId);
64
+ if (!project?.github?.repoFullName) {
65
+ return undefined;
66
+ }
67
+ try {
68
+ const pr = await this.loadRemotePrState(project.github.repoFullName, issue.prNumber);
69
+ if (!pr || pr.state?.toUpperCase() !== "OPEN")
70
+ return undefined;
71
+ if (!pr.headRefOid || pr.headRefOid !== issue.lastGitHubFailureHeadSha)
72
+ return undefined;
73
+ return `Repair finished but PR #${issue.prNumber} is still on failing head ${issue.lastGitHubFailureHeadSha.slice(0, 8)}`;
74
+ }
75
+ catch (error) {
76
+ this.logger.debug({
77
+ issueKey: issue.issueKey,
78
+ prNumber: issue.prNumber,
79
+ error: error instanceof Error ? error.message : String(error),
80
+ }, "Failed to verify PR head advancement after repair");
81
+ return undefined;
82
+ }
83
+ }
84
+ async verifyReviewFixAdvancedHead(run, issue) {
85
+ if (!isRequestedChangesRunType(run.runType)) {
86
+ return undefined;
87
+ }
88
+ if (!issue.prNumber || issue.prState !== "open") {
89
+ return undefined;
90
+ }
91
+ if (!run.sourceHeadSha) {
92
+ return `Requested-changes run finished for PR #${issue.prNumber} without a recorded starting head SHA. PatchRelay cannot verify that a new head was published.`;
93
+ }
94
+ const project = this.config.projects.find((entry) => entry.id === run.projectId);
95
+ if (!project?.github?.repoFullName) {
96
+ return undefined;
97
+ }
98
+ try {
99
+ const pr = await this.loadRemotePrState(project.github.repoFullName, issue.prNumber);
100
+ if (!pr || pr.state?.toUpperCase() !== "OPEN")
101
+ return undefined;
102
+ if (!pr.headRefOid) {
103
+ return `Requested-changes run finished for PR #${issue.prNumber} but GitHub did not report a current head SHA.`;
104
+ }
105
+ if (pr.headRefOid === run.sourceHeadSha) {
106
+ return `Requested-changes run finished for PR #${issue.prNumber} without pushing a new head; PatchRelay must not hand the same SHA back to review.`;
107
+ }
108
+ return undefined;
109
+ }
110
+ catch (error) {
111
+ this.logger.debug({
112
+ issueKey: issue.issueKey,
113
+ prNumber: issue.prNumber,
114
+ error: error instanceof Error ? error.message : String(error),
115
+ }, "Failed to verify PR head advancement after requested-changes work");
116
+ return undefined;
117
+ }
118
+ }
119
+ async refreshIssueAfterReactivePublish(run, issue) {
120
+ if (run.runType !== "ci_repair" && run.runType !== "queue_repair" && !isRequestedChangesRunType(run.runType)) {
121
+ return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
122
+ }
123
+ if (!issue.prNumber) {
124
+ return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
125
+ }
126
+ const project = this.config.projects.find((entry) => entry.id === run.projectId);
127
+ const repoFullName = project?.github?.repoFullName;
128
+ if (!repoFullName) {
129
+ return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
130
+ }
131
+ try {
132
+ const pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
133
+ if (!pr) {
134
+ return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
135
+ }
136
+ const nextPrState = normalizeRemotePrState(pr.state);
137
+ const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
138
+ const gateCheckName = project?.gateChecks?.find((entry) => entry.trim())?.trim() ?? "verify";
139
+ const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== issue.lastGitHubFailureHeadSha);
140
+ const reviewFixHeadAdvanced = isRequestedChangesRunType(run.runType)
141
+ && Boolean(pr.headRefOid && run.sourceHeadSha && pr.headRefOid !== run.sourceHeadSha);
142
+ this.upsertIssueIfLeaseHeld(run.projectId, run.linearIssueId, {
143
+ projectId: run.projectId,
144
+ linearIssueId: run.linearIssueId,
145
+ ...(nextPrState ? { prState: nextPrState } : {}),
146
+ ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
147
+ ...(nextReviewState ? { prReviewState: nextReviewState } : {}),
148
+ ...((headAdvanced || reviewFixHeadAdvanced)
149
+ ? {
150
+ prCheckStatus: "pending",
151
+ lastGitHubFailureSource: null,
152
+ lastGitHubFailureHeadSha: null,
153
+ lastGitHubFailureSignature: null,
154
+ lastGitHubFailureCheckName: null,
155
+ lastGitHubFailureCheckUrl: null,
156
+ lastGitHubFailureContextJson: null,
157
+ lastGitHubFailureAt: null,
158
+ lastQueueIncidentJson: null,
159
+ lastAttemptedFailureHeadSha: null,
160
+ lastAttemptedFailureSignature: null,
161
+ lastGitHubCiSnapshotHeadSha: pr.headRefOid ?? null,
162
+ lastGitHubCiSnapshotGateCheckName: gateCheckName,
163
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
164
+ lastGitHubCiSnapshotJson: null,
165
+ lastGitHubCiSnapshotSettledAt: null,
166
+ }
167
+ : {}),
168
+ }, "reactive publish refresh");
169
+ }
170
+ catch (error) {
171
+ this.logger.debug({
172
+ issueKey: issue.issueKey,
173
+ prNumber: issue.prNumber,
174
+ error: error instanceof Error ? error.message : String(error),
175
+ }, "Failed to refresh PR state after reactive publish");
176
+ }
177
+ return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
178
+ }
179
+ async resolveRequestedChangesWakeContext(issue, runType, context) {
180
+ if (runType === "branch_upkeep" || context?.branchUpkeepRequired === true) {
181
+ return context;
182
+ }
183
+ if (!issue.prNumber || issue.prState !== "open" || issue.prReviewState !== "changes_requested") {
184
+ return context;
185
+ }
186
+ const project = this.config.projects.find((entry) => entry.id === issue.projectId);
187
+ const repoFullName = project?.github?.repoFullName;
188
+ if (!repoFullName) {
189
+ return context;
190
+ }
191
+ try {
192
+ const pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
193
+ if (!pr)
194
+ return context;
195
+ const nextPrState = normalizeRemotePrState(pr.state);
196
+ const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
197
+ this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
198
+ projectId: issue.projectId,
199
+ linearIssueId: issue.linearIssueId,
200
+ ...(nextPrState ? { prState: nextPrState } : {}),
201
+ ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
202
+ ...(nextReviewState ? { prReviewState: nextReviewState } : {}),
203
+ }, "review-fix wake refresh");
204
+ if (nextPrState !== "open")
205
+ return context;
206
+ if (nextReviewState && nextReviewState !== "changes_requested")
207
+ return context;
208
+ if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
209
+ return context;
210
+ return buildReviewFixBranchUpkeepContext(issue.prNumber, project?.github?.baseBranch ?? "main", pr, context);
211
+ }
212
+ catch (error) {
213
+ this.logger.debug({
214
+ issueKey: issue.issueKey,
215
+ prNumber: issue.prNumber,
216
+ error: error instanceof Error ? error.message : String(error),
217
+ }, "Failed to resolve requested-changes wake context");
218
+ return context;
219
+ }
220
+ }
221
+ async resolvePostRunFollowUp(run, issue) {
222
+ if (run.runType !== "review_fix") {
223
+ return undefined;
224
+ }
225
+ if (!issue.prNumber || issue.prState !== "open") {
226
+ return undefined;
227
+ }
228
+ if (issue.prReviewState !== "changes_requested") {
229
+ return undefined;
230
+ }
231
+ const project = this.config.projects.find((entry) => entry.id === run.projectId);
232
+ const repoFullName = project?.github?.repoFullName;
233
+ if (!repoFullName) {
234
+ return undefined;
235
+ }
236
+ try {
237
+ const pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
238
+ if (!pr)
239
+ return undefined;
240
+ const nextPrState = normalizeRemotePrState(pr.state);
241
+ const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
242
+ this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
243
+ projectId: issue.projectId,
244
+ linearIssueId: issue.linearIssueId,
245
+ ...(nextPrState ? { prState: nextPrState } : {}),
246
+ ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
247
+ ...(nextReviewState ? { prReviewState: nextReviewState } : {}),
248
+ }, "post-run follow-up refresh");
249
+ if (nextPrState !== "open")
250
+ return undefined;
251
+ if (nextReviewState && nextReviewState !== "changes_requested")
252
+ return undefined;
253
+ if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
254
+ return undefined;
255
+ return {
256
+ pendingRunType: "branch_upkeep",
257
+ factoryState: "changes_requested",
258
+ context: buildReviewFixBranchUpkeepContext(issue.prNumber, project?.github?.baseBranch ?? "main", pr),
259
+ summary: `PR #${issue.prNumber} is still dirty after review fix; queued branch upkeep`,
260
+ };
261
+ }
262
+ catch (error) {
263
+ this.logger.debug({
264
+ issueKey: issue.issueKey,
265
+ prNumber: issue.prNumber,
266
+ error: error instanceof Error ? error.message : String(error),
267
+ }, "Failed to resolve post-run PR upkeep");
268
+ return undefined;
269
+ }
270
+ }
271
+ async loadRemotePrState(repoFullName, prNumber) {
272
+ const { stdout, exitCode } = await execCommand("gh", [
273
+ "pr", "view", String(prNumber),
274
+ "--repo", repoFullName,
275
+ "--json", "headRefOid,state,reviewDecision,mergeStateStatus",
276
+ ], { timeoutMs: 10_000 });
277
+ if (exitCode !== 0)
278
+ return undefined;
279
+ return JSON.parse(stdout);
280
+ }
281
+ upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
282
+ const updated = this.withHeldLease(projectId, linearIssueId, (lease) => this.db.issueSessions.upsertIssueWithLease(lease, params));
283
+ if (updated === undefined) {
284
+ this.logger.warn({ projectId, linearIssueId, context }, "Skipping issue write after losing issue-session lease");
285
+ }
286
+ return updated;
287
+ }
288
+ }