patchrelay 0.38.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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);
@@ -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,65 @@
1
+ import { readRemotePrState } from "./remote-pr-state.js";
2
+ export function isRequestedChangesRunType(runType) {
3
+ return runType === "review_fix" || runType === "branch_upkeep";
4
+ }
5
+ export 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
+ export 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
+ export function isDirtyMergeStateStatus(value) {
26
+ return value?.trim().toUpperCase() === "DIRTY";
27
+ }
28
+ export 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 async function readReactivePrSnapshot(config, projectId, prNumber) {
46
+ const project = config.projects.find((entry) => entry.id === projectId);
47
+ const repoFullName = project?.github?.repoFullName;
48
+ if (!repoFullName) {
49
+ return undefined;
50
+ }
51
+ const pr = await readRemotePrState(repoFullName, prNumber);
52
+ if (!pr) {
53
+ return undefined;
54
+ }
55
+ return {
56
+ projectId,
57
+ repoFullName,
58
+ baseBranch: project?.github?.baseBranch ?? "main",
59
+ pr,
60
+ prState: normalizeRemotePrState(pr.state),
61
+ reviewState: normalizeRemoteReviewDecision(pr.reviewDecision),
62
+ headSha: pr.headRefOid,
63
+ gateCheckName: project?.gateChecks?.find((entry) => entry.trim())?.trim() ?? "verify",
64
+ };
65
+ }
@@ -1,47 +1,4 @@
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
- }
1
+ import { buildReviewFixBranchUpkeepContext, isDirtyMergeStateStatus, isRequestedChangesRunType, readReactivePrSnapshot, } from "./reactive-pr-state.js";
45
2
  export class ReactiveRunPolicy {
46
3
  config;
47
4
  db;
@@ -60,15 +17,11 @@ export class ReactiveRunPolicy {
60
17
  if (!issue.prNumber || issue.prState !== "open" || !issue.lastGitHubFailureHeadSha) {
61
18
  return undefined;
62
19
  }
63
- const project = this.config.projects.find((entry) => entry.id === run.projectId);
64
- if (!project?.github?.repoFullName) {
65
- return undefined;
66
- }
67
20
  try {
68
- const pr = await this.loadRemotePrState(project.github.repoFullName, issue.prNumber);
69
- if (!pr || pr.state?.toUpperCase() !== "OPEN")
21
+ const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
22
+ if (!snapshot || snapshot.prState !== "open")
70
23
  return undefined;
71
- if (!pr.headRefOid || pr.headRefOid !== issue.lastGitHubFailureHeadSha)
24
+ if (!snapshot.headSha || snapshot.headSha !== issue.lastGitHubFailureHeadSha)
72
25
  return undefined;
73
26
  return `Repair finished but PR #${issue.prNumber} is still on failing head ${issue.lastGitHubFailureHeadSha.slice(0, 8)}`;
74
27
  }
@@ -91,18 +44,14 @@ export class ReactiveRunPolicy {
91
44
  if (!run.sourceHeadSha) {
92
45
  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
46
  }
94
- const project = this.config.projects.find((entry) => entry.id === run.projectId);
95
- if (!project?.github?.repoFullName) {
96
- return undefined;
97
- }
98
47
  try {
99
- const pr = await this.loadRemotePrState(project.github.repoFullName, issue.prNumber);
100
- if (!pr || pr.state?.toUpperCase() !== "OPEN")
48
+ const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
49
+ if (!snapshot || snapshot.prState !== "open")
101
50
  return undefined;
102
- if (!pr.headRefOid) {
51
+ if (!snapshot.headSha) {
103
52
  return `Requested-changes run finished for PR #${issue.prNumber} but GitHub did not report a current head SHA.`;
104
53
  }
105
- if (pr.headRefOid === run.sourceHeadSha) {
54
+ if (snapshot.headSha === run.sourceHeadSha) {
106
55
  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
56
  }
108
57
  return undefined;
@@ -123,28 +72,20 @@ export class ReactiveRunPolicy {
123
72
  if (!issue.prNumber) {
124
73
  return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
125
74
  }
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
75
  try {
132
- const pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
133
- if (!pr) {
76
+ const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
77
+ if (!snapshot) {
134
78
  return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
135
79
  }
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);
80
+ const headAdvanced = Boolean(snapshot.headSha && snapshot.headSha !== issue.lastGitHubFailureHeadSha);
140
81
  const reviewFixHeadAdvanced = isRequestedChangesRunType(run.runType)
141
- && Boolean(pr.headRefOid && run.sourceHeadSha && pr.headRefOid !== run.sourceHeadSha);
82
+ && Boolean(snapshot.headSha && run.sourceHeadSha && snapshot.headSha !== run.sourceHeadSha);
142
83
  this.upsertIssueIfLeaseHeld(run.projectId, run.linearIssueId, {
143
84
  projectId: run.projectId,
144
85
  linearIssueId: run.linearIssueId,
145
- ...(nextPrState ? { prState: nextPrState } : {}),
146
- ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
147
- ...(nextReviewState ? { prReviewState: nextReviewState } : {}),
86
+ ...(snapshot.prState ? { prState: snapshot.prState } : {}),
87
+ ...(snapshot.headSha ? { prHeadSha: snapshot.headSha } : {}),
88
+ ...(snapshot.reviewState ? { prReviewState: snapshot.reviewState } : {}),
148
89
  ...((headAdvanced || reviewFixHeadAdvanced)
149
90
  ? {
150
91
  prCheckStatus: "pending",
@@ -158,8 +99,8 @@ export class ReactiveRunPolicy {
158
99
  lastQueueIncidentJson: null,
159
100
  lastAttemptedFailureHeadSha: null,
160
101
  lastAttemptedFailureSignature: null,
161
- lastGitHubCiSnapshotHeadSha: pr.headRefOid ?? null,
162
- lastGitHubCiSnapshotGateCheckName: gateCheckName,
102
+ lastGitHubCiSnapshotHeadSha: snapshot.headSha ?? null,
103
+ lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName,
163
104
  lastGitHubCiSnapshotGateCheckStatus: "pending",
164
105
  lastGitHubCiSnapshotJson: null,
165
106
  lastGitHubCiSnapshotSettledAt: null,
@@ -183,31 +124,24 @@ export class ReactiveRunPolicy {
183
124
  if (!issue.prNumber || issue.prState !== "open" || issue.prReviewState !== "changes_requested") {
184
125
  return context;
185
126
  }
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
127
  try {
192
- const pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
193
- if (!pr)
128
+ const snapshot = await readReactivePrSnapshot(this.config, issue.projectId, issue.prNumber);
129
+ if (!snapshot)
194
130
  return context;
195
- const nextPrState = normalizeRemotePrState(pr.state);
196
- const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
197
131
  this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
198
132
  projectId: issue.projectId,
199
133
  linearIssueId: issue.linearIssueId,
200
- ...(nextPrState ? { prState: nextPrState } : {}),
201
- ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
202
- ...(nextReviewState ? { prReviewState: nextReviewState } : {}),
134
+ ...(snapshot.prState ? { prState: snapshot.prState } : {}),
135
+ ...(snapshot.headSha ? { prHeadSha: snapshot.headSha } : {}),
136
+ ...(snapshot.reviewState ? { prReviewState: snapshot.reviewState } : {}),
203
137
  }, "review-fix wake refresh");
204
- if (nextPrState !== "open")
138
+ if (snapshot.prState !== "open")
205
139
  return context;
206
- if (nextReviewState && nextReviewState !== "changes_requested")
140
+ if (snapshot.reviewState && snapshot.reviewState !== "changes_requested")
207
141
  return context;
208
- if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
142
+ if (!isDirtyMergeStateStatus(snapshot.pr.mergeStateStatus))
209
143
  return context;
210
- return buildReviewFixBranchUpkeepContext(issue.prNumber, project?.github?.baseBranch ?? "main", pr, context);
144
+ return buildReviewFixBranchUpkeepContext(issue.prNumber, snapshot.baseBranch, snapshot.pr, context);
211
145
  }
212
146
  catch (error) {
213
147
  this.logger.debug({
@@ -228,34 +162,27 @@ export class ReactiveRunPolicy {
228
162
  if (issue.prReviewState !== "changes_requested") {
229
163
  return undefined;
230
164
  }
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
165
  try {
237
- const pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
238
- if (!pr)
166
+ const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
167
+ if (!snapshot)
239
168
  return undefined;
240
- const nextPrState = normalizeRemotePrState(pr.state);
241
- const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
242
169
  this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
243
170
  projectId: issue.projectId,
244
171
  linearIssueId: issue.linearIssueId,
245
- ...(nextPrState ? { prState: nextPrState } : {}),
246
- ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
247
- ...(nextReviewState ? { prReviewState: nextReviewState } : {}),
172
+ ...(snapshot.prState ? { prState: snapshot.prState } : {}),
173
+ ...(snapshot.headSha ? { prHeadSha: snapshot.headSha } : {}),
174
+ ...(snapshot.reviewState ? { prReviewState: snapshot.reviewState } : {}),
248
175
  }, "post-run follow-up refresh");
249
- if (nextPrState !== "open")
176
+ if (snapshot.prState !== "open")
250
177
  return undefined;
251
- if (nextReviewState && nextReviewState !== "changes_requested")
178
+ if (snapshot.reviewState && snapshot.reviewState !== "changes_requested")
252
179
  return undefined;
253
- if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
180
+ if (!isDirtyMergeStateStatus(snapshot.pr.mergeStateStatus))
254
181
  return undefined;
255
182
  return {
256
183
  pendingRunType: "branch_upkeep",
257
184
  factoryState: "changes_requested",
258
- context: buildReviewFixBranchUpkeepContext(issue.prNumber, project?.github?.baseBranch ?? "main", pr),
185
+ context: buildReviewFixBranchUpkeepContext(issue.prNumber, snapshot.baseBranch, snapshot.pr),
259
186
  summary: `PR #${issue.prNumber} is still dirty after review fix; queued branch upkeep`,
260
187
  };
261
188
  }
@@ -268,16 +195,6 @@ export class ReactiveRunPolicy {
268
195
  return undefined;
269
196
  }
270
197
  }
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
198
  upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
282
199
  const updated = this.withHeldLease(projectId, linearIssueId, (lease) => this.db.issueSessions.upsertIssueWithLease(lease, params));
283
200
  if (updated === undefined) {
@@ -0,0 +1,11 @@
1
+ import { execCommand } from "./utils.js";
2
+ export async function readRemotePrState(repoFullName, prNumber) {
3
+ const { stdout, exitCode } = await execCommand("gh", [
4
+ "pr", "view", String(prNumber),
5
+ "--repo", repoFullName,
6
+ "--json", "headRefOid,state,reviewDecision,mergeStateStatus",
7
+ ], { timeoutMs: 10_000 });
8
+ if (exitCode !== 0)
9
+ return undefined;
10
+ return JSON.parse(stdout);
11
+ }
@@ -54,6 +54,20 @@ export class RunOrchestrator {
54
54
  runNotificationHandler;
55
55
  runReconciler;
56
56
  mergedLinearCompletionReconciler;
57
+ threadPorts = {
58
+ readThreadWithRetry: (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries),
59
+ };
60
+ leasePorts = {
61
+ withHeldLease: (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn),
62
+ releaseLease: (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId),
63
+ heartbeatLease: (projectId, linearIssueId) => this.heartbeatIssueSessionLease(projectId, linearIssueId),
64
+ getHeldLease: (projectId, linearIssueId) => this.getHeldIssueSessionLease(projectId, linearIssueId),
65
+ };
66
+ recoveryPorts = {
67
+ failRunAndClear: (run, message, nextState) => this.failRunAndClear(run, message, nextState),
68
+ restoreIdleWorktree: (issue) => this.restoreIdleWorktree(issue),
69
+ recoverOrEscalate: (issue, runType, reason) => this.recoverOrEscalate(issue, runType, reason),
70
+ };
57
71
  activeSessionLeases;
58
72
  botIdentity;
59
73
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
@@ -66,16 +80,16 @@ export class RunOrchestrator {
66
80
  this.feed = feed;
67
81
  this.worktreeManager = new WorktreeManager(config);
68
82
  this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
69
- this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries));
83
+ this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry);
70
84
  this.activeSessionLeases = this.leaseService.activeSessionLeases;
71
- this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn));
85
+ this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, this.leasePorts.withHeldLease);
72
86
  this.completionCheck = new CompletionCheckService(codex, logger);
73
- this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (run, message, nextState) => this.failRunAndClear(run, message, nextState), this.runCompletionPolicy, this.completionCheck, feed);
87
+ this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.recoveryPorts.failRunAndClear, this.runCompletionPolicy, this.completionCheck, feed);
74
88
  this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
75
- this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries), (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.heartbeatIssueSessionLease(projectId, linearIssueId), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), feed);
76
- this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.getHeldIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
77
- this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (run, message, nextState) => this.failRunAndClear(run, message, nextState), (issue) => this.restoreIdleWorktree(issue), this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
78
- this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries), (issue, runType, reason) => this.recoverOrEscalate(issue, runType, reason), feed);
89
+ this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed);
90
+ this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.getHeldLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.leasePorts.releaseLease, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
91
+ this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.recoveryPorts.failRunAndClear, this.recoveryPorts.restoreIdleWorktree, this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
92
+ this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, feed);
79
93
  this.runWakePlanner = new RunWakePlanner(db);
80
94
  this.idleReconciler = new IdleIssueReconciler(db, config, {
81
95
  enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {