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.
- package/dist/build-info.json +3 -3
- 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 +41 -976
- package/dist/github-webhook-issue-resolution.js +46 -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 +231 -0
- package/dist/github-webhook-terminal-handler.js +111 -0
- package/dist/issue-overview-query.js +8 -57
- package/dist/legacy-issue-overview.js +58 -0
- 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-orchestrator.js +21 -7
- package/package.json +1 -1
|
@@ -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
|
|
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 {
|
|
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
|
|
69
|
-
if (!
|
|
21
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
22
|
+
if (!snapshot || snapshot.prState !== "open")
|
|
70
23
|
return undefined;
|
|
71
|
-
if (!
|
|
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
|
|
100
|
-
if (!
|
|
48
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
49
|
+
if (!snapshot || snapshot.prState !== "open")
|
|
101
50
|
return undefined;
|
|
102
|
-
if (!
|
|
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 (
|
|
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
|
|
133
|
-
if (!
|
|
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
|
|
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(
|
|
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
|
-
...(
|
|
146
|
-
...(
|
|
147
|
-
...(
|
|
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:
|
|
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
|
|
193
|
-
if (!
|
|
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
|
-
...(
|
|
201
|
-
...(
|
|
202
|
-
...(
|
|
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 (
|
|
138
|
+
if (snapshot.prState !== "open")
|
|
205
139
|
return context;
|
|
206
|
-
if (
|
|
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,
|
|
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
|
|
238
|
-
if (!
|
|
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
|
-
...(
|
|
246
|
-
...(
|
|
247
|
-
...(
|
|
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 (
|
|
176
|
+
if (snapshot.prState !== "open")
|
|
250
177
|
return undefined;
|
|
251
|
-
if (
|
|
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,
|
|
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
|
+
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
76
|
-
this.runRecovery = new RunRecoveryService(db, logger, this.linearSync,
|
|
77
|
-
this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync,
|
|
78
|
-
this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer,
|
|
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),
|