patchrelay 0.37.1 → 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/README.md +47 -9
- package/dist/awaiting-input-reason.js +9 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/cluster-health.js +59 -3
- package/dist/cli/help.js +1 -1
- package/dist/cli/output.js +2 -0
- package/dist/db/issue-session-store.js +0 -14
- package/dist/db/issue-store.js +8 -16
- package/dist/db/migrations.js +6 -13
- package/dist/db.js +1 -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 +49 -965
- 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/github-webhooks.js +4 -0
- package/dist/idle-reconciliation.js +22 -23
- package/dist/issue-overview-query.js +11 -57
- package/dist/issue-session-projector.js +1 -0
- package/dist/issue-session.js +8 -0
- package/dist/legacy-issue-overview.js +58 -0
- package/dist/linear-session-reporting.js +30 -1
- package/dist/linear-session-sync.js +9 -1
- package/dist/linear-status-comment-sync.js +34 -1
- package/dist/linear-workflow-state-sync.js +2 -2
- package/dist/operator-retry-event.js +15 -12
- package/dist/paused-issue-state.js +24 -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-launcher.js +0 -1
- package/dist/run-orchestrator.js +22 -11
- package/dist/run-reconciler.js +10 -0
- package/dist/run-recovery-service.js +1 -10
- package/dist/service-issue-actions.js +5 -0
- package/dist/service-startup-recovery.js +9 -6
- package/dist/service.js +0 -1
- package/dist/tracked-issue-list-query.js +3 -1
- package/dist/tracked-issue-projector.js +3 -0
- package/dist/waiting-reason.js +10 -0
- package/dist/webhooks/agent-session-handler.js +9 -1
- package/dist/webhooks/comment-wake-handler.js +12 -0
- package/dist/webhooks/decision-helpers.js +44 -3
- package/dist/webhooks/dependency-readiness-handler.js +1 -0
- package/dist/webhooks/desired-stage-recorder.js +40 -10
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export function resolveGitHubWebhookIssue(db, project, event) {
|
|
2
|
+
if (event.prNumber !== undefined) {
|
|
3
|
+
const byPr = db.issues.getIssueByPrNumber(event.prNumber);
|
|
4
|
+
if (byPr && byPr.projectId === project.id) {
|
|
5
|
+
return { issue: byPr, linkedBy: "pr" };
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
const byBranch = db.issues.getIssueByBranch(event.branchName);
|
|
9
|
+
if (byBranch && byBranch.projectId === project.id) {
|
|
10
|
+
return { issue: byBranch, linkedBy: "branch" };
|
|
11
|
+
}
|
|
12
|
+
const byIssueKey = resolveGitHubWebhookIssueByKey(db, project, event);
|
|
13
|
+
if (byIssueKey) {
|
|
14
|
+
return { issue: byIssueKey, linkedBy: "issue_key" };
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
export function resolveGitHubWebhookIssueByKey(db, project, event) {
|
|
19
|
+
const candidates = new Set();
|
|
20
|
+
const sources = [event.prTitle, event.prBody, event.branchName];
|
|
21
|
+
for (const prefix of project.issueKeyPrefixes) {
|
|
22
|
+
const normalizedPrefix = prefix.trim();
|
|
23
|
+
if (!normalizedPrefix)
|
|
24
|
+
continue;
|
|
25
|
+
const pattern = new RegExp(`\\b${escapeRegExp(normalizedPrefix)}-\\d+\\b`, "gi");
|
|
26
|
+
for (const source of sources) {
|
|
27
|
+
if (!source)
|
|
28
|
+
continue;
|
|
29
|
+
for (const match of source.matchAll(pattern)) {
|
|
30
|
+
candidates.add(match[0].toUpperCase());
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (candidates.size !== 1) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const [issueKey] = [...candidates];
|
|
38
|
+
if (!issueKey) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const issue = db.issues.getIssueByKey(issueKey);
|
|
42
|
+
return issue?.projectId === project.id ? issue : undefined;
|
|
43
|
+
}
|
|
44
|
+
function escapeRegExp(value) {
|
|
45
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
46
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { resolveFactoryStateFromGitHub } from "./factory-state.js";
|
|
2
|
+
import { resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
|
|
3
|
+
import { isIssueTerminal } from "./pr-state.js";
|
|
4
|
+
const DEFAULT_GATE_CHECK_NAMES = ["verify", "tests"];
|
|
5
|
+
/**
|
|
6
|
+
* GitHub sends both check_run and check_suite completion events.
|
|
7
|
+
* A single CI run generates many individual check_run events as each job finishes,
|
|
8
|
+
* but PatchRelay should only start ci_repair once the configured gate check
|
|
9
|
+
* has gone terminal for the current PR head SHA. We still treat most check_run
|
|
10
|
+
* events as metadata-only and only react to queue eviction checks or the settled
|
|
11
|
+
* gate check.
|
|
12
|
+
*/
|
|
13
|
+
export function isMetadataOnlyCheckEvent(event) {
|
|
14
|
+
return event.eventSource === "check_run"
|
|
15
|
+
&& (event.triggerEvent === "check_passed" || event.triggerEvent === "check_failed");
|
|
16
|
+
}
|
|
17
|
+
export function getGateCheckNames(project) {
|
|
18
|
+
const configured = (project?.gateChecks ?? []).map((entry) => entry.trim()).filter(Boolean);
|
|
19
|
+
return configured.length > 0 ? configured : DEFAULT_GATE_CHECK_NAMES;
|
|
20
|
+
}
|
|
21
|
+
export function getPrimaryGateCheckName(project) {
|
|
22
|
+
return getGateCheckNames(project)[0] ?? "verify";
|
|
23
|
+
}
|
|
24
|
+
export function isGateCheckEvent(event, project) {
|
|
25
|
+
if (event.eventSource !== "check_run" || !event.checkName)
|
|
26
|
+
return false;
|
|
27
|
+
const normalized = event.checkName.trim().toLowerCase();
|
|
28
|
+
return getGateCheckNames(project).some((entry) => entry.trim().toLowerCase() === normalized);
|
|
29
|
+
}
|
|
30
|
+
export function deriveImmediatePrCheckStatus(issue, event, project) {
|
|
31
|
+
if (event.triggerEvent === "pr_synchronize") {
|
|
32
|
+
return "pending";
|
|
33
|
+
}
|
|
34
|
+
if (event.eventSource !== "check_run") {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
if (!isGateCheckEvent(event, project)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (isStaleGateEvent(issue, event)) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return event.checkStatus;
|
|
44
|
+
}
|
|
45
|
+
export function isStaleGateEvent(issue, event) {
|
|
46
|
+
return Boolean(issue.lastGitHubCiSnapshotHeadSha
|
|
47
|
+
&& event.headSha
|
|
48
|
+
&& issue.lastGitHubCiSnapshotHeadSha !== event.headSha);
|
|
49
|
+
}
|
|
50
|
+
export function isQueueEvictionFailure(issue, event, project) {
|
|
51
|
+
const protocol = resolveMergeQueueProtocol(project);
|
|
52
|
+
return event.eventSource === "check_run"
|
|
53
|
+
&& event.checkName === protocol.evictionCheckName;
|
|
54
|
+
}
|
|
55
|
+
export function isSettledBranchFailure(db, issue, event, project) {
|
|
56
|
+
if (event.triggerEvent !== "check_failed" || issue.prState !== "open")
|
|
57
|
+
return false;
|
|
58
|
+
if (!isGateCheckEvent(event, project))
|
|
59
|
+
return false;
|
|
60
|
+
const snapshot = db.issues.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
|
|
61
|
+
if (!snapshot || snapshot.headSha !== event.headSha)
|
|
62
|
+
return false;
|
|
63
|
+
return snapshot?.gateCheckStatus === "failure" && snapshot.headSha === event.headSha;
|
|
64
|
+
}
|
|
65
|
+
export function canClearFailureProvenance(issue, event, project) {
|
|
66
|
+
if (event.triggerEvent !== "check_passed")
|
|
67
|
+
return true;
|
|
68
|
+
if (isQueueEvictionFailure(issue, event, project)) {
|
|
69
|
+
return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
|
|
70
|
+
}
|
|
71
|
+
if (!isGateCheckEvent(event, project)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (isStaleGateEvent(issue, event)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
|
|
78
|
+
}
|
|
79
|
+
export function resolveGitHubFactoryStateForEvent(issue, event, project) {
|
|
80
|
+
if (event.triggerEvent === "pr_closed") {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
const effectiveCurrentState = (issue.factoryState === "awaiting_input" || issue.factoryState === "delegated")
|
|
84
|
+
&& (event.prState === "open" || event.prNumber !== undefined)
|
|
85
|
+
? "pr_open"
|
|
86
|
+
: issue.factoryState;
|
|
87
|
+
if (event.triggerEvent === "check_failed"
|
|
88
|
+
&& isQueueEvictionFailure(issue, event, project)
|
|
89
|
+
&& issue.prState === "open"
|
|
90
|
+
&& issue.activeRunId === undefined
|
|
91
|
+
&& !isIssueTerminal(issue)) {
|
|
92
|
+
return "repairing_queue";
|
|
93
|
+
}
|
|
94
|
+
const resolved = resolveFactoryStateFromGitHub(event.triggerEvent, effectiveCurrentState, {
|
|
95
|
+
prReviewState: issue.prReviewState,
|
|
96
|
+
activeRunId: issue.activeRunId,
|
|
97
|
+
});
|
|
98
|
+
if (resolved !== undefined) {
|
|
99
|
+
return resolved;
|
|
100
|
+
}
|
|
101
|
+
if (effectiveCurrentState !== issue.factoryState) {
|
|
102
|
+
return effectiveCurrentState;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { summarizeGitHubFailureContext } from "./github-failure-context.js";
|
|
2
|
+
import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
|
|
3
|
+
import { isIssueTerminal } from "./pr-state.js";
|
|
4
|
+
import { buildGitHubQueueFailureContext, getRelevantGitHubCiSnapshot, resolveGitHubBranchFailureContext, resolveGitHubCheckClass, } from "./github-webhook-failure-context.js";
|
|
5
|
+
import { isQueueEvictionFailure, isSettledBranchFailure } from "./github-webhook-policy.js";
|
|
6
|
+
export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
7
|
+
const { issue, event, project, logger, feed, enqueueIssue, db, fetchImpl, failureContextResolver } = params;
|
|
8
|
+
if (issue.activeRunId !== undefined)
|
|
9
|
+
return;
|
|
10
|
+
if (isIssueTerminal(issue))
|
|
11
|
+
return;
|
|
12
|
+
if (!issue.delegatedToPatchRelay) {
|
|
13
|
+
feed?.publish({
|
|
14
|
+
level: "info",
|
|
15
|
+
kind: "github",
|
|
16
|
+
issueKey: issue.issueKey,
|
|
17
|
+
projectId: issue.projectId,
|
|
18
|
+
stage: issue.factoryState,
|
|
19
|
+
status: "ignored_undelegated",
|
|
20
|
+
summary: `Ignored ${event.triggerEvent} because the issue is undelegated`,
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
25
|
+
await handleCheckFailedEvent({
|
|
26
|
+
db,
|
|
27
|
+
logger,
|
|
28
|
+
feed,
|
|
29
|
+
enqueueIssue,
|
|
30
|
+
issue,
|
|
31
|
+
event,
|
|
32
|
+
project,
|
|
33
|
+
failureContextResolver,
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (event.triggerEvent === "review_changes_requested") {
|
|
38
|
+
await handleRequestedChangesEvent({
|
|
39
|
+
db,
|
|
40
|
+
logger,
|
|
41
|
+
feed,
|
|
42
|
+
enqueueIssue,
|
|
43
|
+
issue,
|
|
44
|
+
event,
|
|
45
|
+
fetchImpl,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function handleCheckFailedEvent(params) {
|
|
50
|
+
const { db, logger, feed, enqueueIssue, issue, event, project, failureContextResolver } = params;
|
|
51
|
+
if (isQueueEvictionFailure(issue, event, project)) {
|
|
52
|
+
const queueRepairContext = buildQueueRepairContextFromEvent(event);
|
|
53
|
+
const failureContext = buildGitHubQueueFailureContext(event, project, queueRepairContext);
|
|
54
|
+
if (hasDuplicatePendingReactiveRun(db, feed, issue, "queue_repair", failureContext)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const hadPendingWake = db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
58
|
+
db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
59
|
+
projectId: issue.projectId,
|
|
60
|
+
linearIssueId: issue.linearIssueId,
|
|
61
|
+
eventType: "merge_steward_incident",
|
|
62
|
+
eventJson: JSON.stringify({
|
|
63
|
+
...queueRepairContext,
|
|
64
|
+
...failureContext,
|
|
65
|
+
}),
|
|
66
|
+
dedupeKey: failureContext.failureSignature,
|
|
67
|
+
});
|
|
68
|
+
const queuedRunType = hadPendingWake
|
|
69
|
+
? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
|
|
70
|
+
: enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId);
|
|
71
|
+
logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
|
|
72
|
+
feed?.publish({
|
|
73
|
+
level: "warn",
|
|
74
|
+
kind: "github",
|
|
75
|
+
issueKey: issue.issueKey,
|
|
76
|
+
projectId: issue.projectId,
|
|
77
|
+
stage: "repairing_queue",
|
|
78
|
+
status: "queue_repair_queued",
|
|
79
|
+
summary: `${queuedRunType ?? "queue_repair"} queued after external failure from ${event.checkName}`,
|
|
80
|
+
detail: queueRepairContext.incidentSummary ?? queueRepairContext.incidentUrl ?? event.checkUrl,
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!isSettledBranchFailure(db, issue, event, project)) {
|
|
85
|
+
feed?.publish({
|
|
86
|
+
level: "info",
|
|
87
|
+
kind: "github",
|
|
88
|
+
issueKey: issue.issueKey,
|
|
89
|
+
projectId: issue.projectId,
|
|
90
|
+
stage: issue.factoryState,
|
|
91
|
+
status: "ci_waiting_for_settlement",
|
|
92
|
+
summary: `Waiting for settled ${project?.gateChecks?.[0] ?? "verify"} result before starting CI repair`,
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const failureContext = await resolveGitHubBranchFailureContext({
|
|
97
|
+
db,
|
|
98
|
+
issue,
|
|
99
|
+
event,
|
|
100
|
+
project,
|
|
101
|
+
failureContextResolver,
|
|
102
|
+
});
|
|
103
|
+
if (hasDuplicatePendingReactiveRun(db, feed, issue, "ci_repair", failureContext)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const hadPendingWake = db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
107
|
+
const snapshot = getRelevantGitHubCiSnapshot(db, issue, event);
|
|
108
|
+
db.issues.upsertIssue({
|
|
109
|
+
projectId: issue.projectId,
|
|
110
|
+
linearIssueId: issue.linearIssueId,
|
|
111
|
+
lastGitHubFailureSource: "branch_ci",
|
|
112
|
+
lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
|
|
113
|
+
lastGitHubFailureSignature: failureContext.failureSignature ?? null,
|
|
114
|
+
lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
|
|
115
|
+
lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
|
|
116
|
+
lastGitHubFailureContextJson: JSON.stringify(failureContext),
|
|
117
|
+
lastGitHubFailureAt: new Date().toISOString(),
|
|
118
|
+
lastQueueIncidentJson: null,
|
|
119
|
+
});
|
|
120
|
+
db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
121
|
+
projectId: issue.projectId,
|
|
122
|
+
linearIssueId: issue.linearIssueId,
|
|
123
|
+
eventType: "settled_red_ci",
|
|
124
|
+
eventJson: JSON.stringify({
|
|
125
|
+
...failureContext,
|
|
126
|
+
checkClass: resolveGitHubCheckClass(failureContext.checkName ?? event.checkName, project),
|
|
127
|
+
...(snapshot ? { ciSnapshot: snapshot } : {}),
|
|
128
|
+
}),
|
|
129
|
+
dedupeKey: failureContext.failureSignature,
|
|
130
|
+
});
|
|
131
|
+
const queuedRunType = hadPendingWake
|
|
132
|
+
? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
|
|
133
|
+
: enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId);
|
|
134
|
+
logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
|
|
135
|
+
feed?.publish({
|
|
136
|
+
level: "warn",
|
|
137
|
+
kind: "github",
|
|
138
|
+
issueKey: issue.issueKey,
|
|
139
|
+
projectId: issue.projectId,
|
|
140
|
+
stage: "repairing_ci",
|
|
141
|
+
status: "ci_repair_queued",
|
|
142
|
+
summary: `${queuedRunType ?? "ci_repair"} queued for ${failureContext.jobName ?? failureContext.checkName ?? "failed check"}`,
|
|
143
|
+
detail: summarizeGitHubFailureContext(failureContext),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function handleRequestedChangesEvent(params) {
|
|
147
|
+
const { db, logger, feed, enqueueIssue, issue, event, fetchImpl } = params;
|
|
148
|
+
const hadPendingWake = db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
149
|
+
const reviewComments = await fetchReviewCommentsForEvent(event, fetchImpl).catch((error) => {
|
|
150
|
+
logger.warn({
|
|
151
|
+
issueKey: issue.issueKey,
|
|
152
|
+
prNumber: event.prNumber,
|
|
153
|
+
reviewId: event.reviewId,
|
|
154
|
+
error: error instanceof Error ? error.message : String(error),
|
|
155
|
+
}, "Failed to fetch inline review comments for requested-changes event");
|
|
156
|
+
return undefined;
|
|
157
|
+
});
|
|
158
|
+
db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
159
|
+
projectId: issue.projectId,
|
|
160
|
+
linearIssueId: issue.linearIssueId,
|
|
161
|
+
eventType: "review_changes_requested",
|
|
162
|
+
eventJson: JSON.stringify({
|
|
163
|
+
reviewBody: event.reviewBody,
|
|
164
|
+
reviewCommitId: event.reviewCommitId,
|
|
165
|
+
reviewId: event.reviewId,
|
|
166
|
+
reviewUrl: buildGitHubReviewUrl(event.repoFullName, event.prNumber, event.reviewId),
|
|
167
|
+
reviewerName: event.reviewerName,
|
|
168
|
+
...(reviewComments && reviewComments.length > 0 ? { reviewComments } : {}),
|
|
169
|
+
}),
|
|
170
|
+
dedupeKey: [
|
|
171
|
+
"review_changes_requested",
|
|
172
|
+
issue.prHeadSha ?? event.headSha ?? "unknown-sha",
|
|
173
|
+
event.reviewerName ?? "unknown-reviewer",
|
|
174
|
+
].join("::"),
|
|
175
|
+
});
|
|
176
|
+
const queuedRunType = hadPendingWake
|
|
177
|
+
? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
|
|
178
|
+
: enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId);
|
|
179
|
+
logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
|
|
180
|
+
feed?.publish({
|
|
181
|
+
level: "warn",
|
|
182
|
+
kind: "github",
|
|
183
|
+
issueKey: issue.issueKey,
|
|
184
|
+
projectId: issue.projectId,
|
|
185
|
+
stage: "changes_requested",
|
|
186
|
+
status: "review_fix_queued",
|
|
187
|
+
summary: `${queuedRunType ?? "review_fix"} queued after requested changes`,
|
|
188
|
+
detail: reviewComments && reviewComments.length > 0
|
|
189
|
+
? `${reviewComments.length} inline review comment${reviewComments.length === 1 ? "" : "s"} captured`
|
|
190
|
+
: event.reviewBody?.slice(0, 200) ?? event.reviewerName,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
function hasDuplicatePendingReactiveRun(db, feed, issue, runType, failureContext) {
|
|
194
|
+
const signature = typeof failureContext.failureSignature === "string" ? failureContext.failureSignature : undefined;
|
|
195
|
+
const headSha = typeof failureContext.failureHeadSha === "string"
|
|
196
|
+
? failureContext.failureHeadSha
|
|
197
|
+
: typeof failureContext.headSha === "string" ? failureContext.headSha : undefined;
|
|
198
|
+
if (!signature)
|
|
199
|
+
return false;
|
|
200
|
+
const pendingWake = db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
|
|
201
|
+
if (pendingWake?.runType === runType && pendingWake.eventIds.length > 0) {
|
|
202
|
+
const existing = pendingWake.context;
|
|
203
|
+
if (existing?.failureSignature === signature
|
|
204
|
+
&& (headSha === undefined || existing.failureHeadSha === headSha || existing.headSha === headSha)) {
|
|
205
|
+
feed?.publish({
|
|
206
|
+
level: "info",
|
|
207
|
+
kind: "github",
|
|
208
|
+
issueKey: issue.issueKey,
|
|
209
|
+
projectId: issue.projectId,
|
|
210
|
+
stage: issue.factoryState,
|
|
211
|
+
status: "repair_deduped",
|
|
212
|
+
summary: `Skipped duplicate ${runType} for ${signature}`,
|
|
213
|
+
});
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (issue.lastAttemptedFailureSignature === signature
|
|
218
|
+
&& (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha)) {
|
|
219
|
+
feed?.publish({
|
|
220
|
+
level: "info",
|
|
221
|
+
kind: "github",
|
|
222
|
+
issueKey: issue.issueKey,
|
|
223
|
+
projectId: issue.projectId,
|
|
224
|
+
stage: issue.factoryState,
|
|
225
|
+
status: "repair_deduped",
|
|
226
|
+
summary: `Already attempted ${runType} for this failing PR head`,
|
|
227
|
+
});
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
function enqueuePendingSessionWake(db, enqueueIssue, projectId, issueId) {
|
|
233
|
+
const wake = db.issueSessions.peekIssueSessionWake(projectId, issueId);
|
|
234
|
+
if (!wake) {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
enqueueIssue(projectId, issueId);
|
|
238
|
+
return wake.runType;
|
|
239
|
+
}
|
|
240
|
+
async function fetchReviewCommentsForEvent(event, fetchImpl) {
|
|
241
|
+
if (event.triggerEvent !== "review_changes_requested") {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
if (!event.repoFullName || event.prNumber === undefined || event.reviewId === undefined) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
248
|
+
if (!token) {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
const [owner, repo] = event.repoFullName.split("/", 2);
|
|
252
|
+
if (!owner || !repo) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const response = await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${event.prNumber}/reviews/${event.reviewId}/comments?per_page=100`, {
|
|
256
|
+
headers: {
|
|
257
|
+
Authorization: `Bearer ${token}`,
|
|
258
|
+
Accept: "application/vnd.github+json",
|
|
259
|
+
"User-Agent": "patchrelay",
|
|
260
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
throw new Error(`GitHub review comment fetch failed (${response.status})`);
|
|
265
|
+
}
|
|
266
|
+
const payload = await response.json();
|
|
267
|
+
if (!Array.isArray(payload)) {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
const comments = [];
|
|
271
|
+
for (const entry of payload) {
|
|
272
|
+
if (!entry || typeof entry !== "object")
|
|
273
|
+
continue;
|
|
274
|
+
const record = entry;
|
|
275
|
+
const body = typeof record.body === "string" ? record.body.trim() : "";
|
|
276
|
+
const id = typeof record.id === "number" ? record.id : undefined;
|
|
277
|
+
if (!body || id === undefined)
|
|
278
|
+
continue;
|
|
279
|
+
comments.push({
|
|
280
|
+
id,
|
|
281
|
+
body,
|
|
282
|
+
...(typeof record.path === "string" ? { path: record.path } : {}),
|
|
283
|
+
...(typeof record.line === "number" ? { line: record.line } : {}),
|
|
284
|
+
...(typeof record.side === "string" ? { side: record.side } : {}),
|
|
285
|
+
...(typeof record.start_line === "number" ? { startLine: record.start_line } : {}),
|
|
286
|
+
...(typeof record.start_side === "string" ? { startSide: record.start_side } : {}),
|
|
287
|
+
...(typeof record.commit_id === "string" ? { commitId: record.commit_id } : {}),
|
|
288
|
+
...(typeof record.html_url === "string" ? { url: record.html_url } : {}),
|
|
289
|
+
...(typeof record.diff_hunk === "string" ? { diffHunk: record.diff_hunk } : {}),
|
|
290
|
+
...(typeof record.user?.login === "string"
|
|
291
|
+
? { authorLogin: String(record.user.login) }
|
|
292
|
+
: {}),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return comments;
|
|
296
|
+
}
|
|
297
|
+
function buildGitHubReviewUrl(repoFullName, prNumber, reviewId) {
|
|
298
|
+
if (!repoFullName || prNumber === undefined || reviewId === undefined) {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
return `https://github.com/${repoFullName}/pull/${prNumber}#pullrequestreview-${reviewId}`;
|
|
302
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver } from "./github-failure-context.js";
|
|
2
|
+
import { buildClosedPrCleanupFields } from "./pr-state.js";
|
|
3
|
+
import { canClearFailureProvenance, deriveImmediatePrCheckStatus, getGateCheckNames, getPrimaryGateCheckName, isGateCheckEvent, isMetadataOnlyCheckEvent, isQueueEvictionFailure, isStaleGateEvent, isSettledBranchFailure, resolveGitHubFactoryStateForEvent, } from "./github-webhook-policy.js";
|
|
4
|
+
import { buildGitHubQueueFailureContext, resolveGitHubBranchFailureContext, } from "./github-webhook-failure-context.js";
|
|
5
|
+
import { emitGitHubLinearActivity, syncGitHubLinearSession } from "./github-linear-session-sync.js";
|
|
6
|
+
import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
|
|
7
|
+
export async function projectGitHubWebhookState(deps, issue, event, project, linkedBy) {
|
|
8
|
+
const failureContextResolver = deps.failureContextResolver ?? createGitHubFailureContextResolver();
|
|
9
|
+
const ciSnapshotResolver = deps.ciSnapshotResolver ?? createGitHubCiSnapshotResolver();
|
|
10
|
+
const immediateCheckStatus = deriveImmediatePrCheckStatus(issue, event, project);
|
|
11
|
+
deps.db.issues.upsertIssue({
|
|
12
|
+
projectId: issue.projectId,
|
|
13
|
+
linearIssueId: issue.linearIssueId,
|
|
14
|
+
...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
|
|
15
|
+
...(event.prUrl !== undefined ? { prUrl: event.prUrl } : {}),
|
|
16
|
+
...(event.prState !== undefined ? { prState: event.prState } : {}),
|
|
17
|
+
...(event.headSha !== undefined ? { prHeadSha: event.headSha } : {}),
|
|
18
|
+
...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
|
|
19
|
+
...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
|
|
20
|
+
...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
|
|
21
|
+
...(linkedBy === "issue_key" ? { branchName: event.branchName } : {}),
|
|
22
|
+
...(event.reviewState === "changes_requested"
|
|
23
|
+
? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
|
|
24
|
+
: event.reviewState === "approved"
|
|
25
|
+
? { lastBlockingReviewHeadSha: null }
|
|
26
|
+
: {}),
|
|
27
|
+
...(event.triggerEvent === "pr_closed"
|
|
28
|
+
? buildClosedPrCleanupFields()
|
|
29
|
+
: {}),
|
|
30
|
+
});
|
|
31
|
+
await updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotResolver);
|
|
32
|
+
await updateGitHubFailureProvenance(deps, issue, event, project, failureContextResolver);
|
|
33
|
+
const queueEvictionCheck = isQueueEvictionFailure(issue, event, project);
|
|
34
|
+
if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
|
|
35
|
+
const afterMetadata = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
36
|
+
const newState = resolveGitHubFactoryStateForEvent(afterMetadata, event, project);
|
|
37
|
+
if (newState && newState !== afterMetadata.factoryState) {
|
|
38
|
+
deps.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
39
|
+
projectId: issue.projectId,
|
|
40
|
+
linearIssueId: issue.linearIssueId,
|
|
41
|
+
factoryState: newState,
|
|
42
|
+
});
|
|
43
|
+
deps.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
|
|
44
|
+
const transitionedIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
45
|
+
void emitGitHubLinearActivity({
|
|
46
|
+
linearProvider: deps.linearProvider,
|
|
47
|
+
logger: deps.logger,
|
|
48
|
+
feed: deps.feed,
|
|
49
|
+
issue: transitionedIssue,
|
|
50
|
+
newState,
|
|
51
|
+
event,
|
|
52
|
+
});
|
|
53
|
+
void syncGitHubLinearSession({
|
|
54
|
+
config: deps.config,
|
|
55
|
+
linearProvider: deps.linearProvider,
|
|
56
|
+
logger: deps.logger,
|
|
57
|
+
issue: transitionedIssue,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const freshIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
62
|
+
if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
|
|
63
|
+
deps.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
64
|
+
projectId: issue.projectId,
|
|
65
|
+
linearIssueId: issue.linearIssueId,
|
|
66
|
+
ciRepairAttempts: 0,
|
|
67
|
+
queueRepairAttempts: 0,
|
|
68
|
+
lastGitHubFailureSource: null,
|
|
69
|
+
lastGitHubFailureHeadSha: null,
|
|
70
|
+
lastGitHubFailureSignature: null,
|
|
71
|
+
lastGitHubFailureCheckName: null,
|
|
72
|
+
lastGitHubFailureCheckUrl: null,
|
|
73
|
+
lastGitHubFailureContextJson: null,
|
|
74
|
+
lastGitHubFailureAt: null,
|
|
75
|
+
lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
|
|
76
|
+
lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
|
|
77
|
+
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
78
|
+
lastGitHubCiSnapshotJson: null,
|
|
79
|
+
lastGitHubCiSnapshotSettledAt: null,
|
|
80
|
+
lastQueueIncidentJson: null,
|
|
81
|
+
lastAttemptedFailureHeadSha: null,
|
|
82
|
+
lastAttemptedFailureSignature: null,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
deps.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
|
|
86
|
+
deps.feed?.publish({
|
|
87
|
+
level: event.triggerEvent.includes("failed") ? "warn" : "info",
|
|
88
|
+
kind: "github",
|
|
89
|
+
issueKey: freshIssue.issueKey,
|
|
90
|
+
projectId: freshIssue.projectId,
|
|
91
|
+
stage: freshIssue.factoryState,
|
|
92
|
+
status: event.triggerEvent,
|
|
93
|
+
summary: `GitHub: ${event.triggerEvent}${event.prNumber ? ` on PR #${event.prNumber}` : ""}`,
|
|
94
|
+
detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
|
|
95
|
+
});
|
|
96
|
+
return freshIssue;
|
|
97
|
+
}
|
|
98
|
+
async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotResolver) {
|
|
99
|
+
if (event.triggerEvent === "pr_merged") {
|
|
100
|
+
deps.db.issues.upsertIssue({
|
|
101
|
+
projectId: issue.projectId,
|
|
102
|
+
linearIssueId: issue.linearIssueId,
|
|
103
|
+
lastGitHubCiSnapshotHeadSha: null,
|
|
104
|
+
lastGitHubCiSnapshotGateCheckName: null,
|
|
105
|
+
lastGitHubCiSnapshotGateCheckStatus: null,
|
|
106
|
+
lastGitHubCiSnapshotJson: null,
|
|
107
|
+
lastGitHubCiSnapshotSettledAt: null,
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (event.triggerEvent === "pr_synchronize") {
|
|
112
|
+
deps.db.issues.upsertIssue({
|
|
113
|
+
projectId: issue.projectId,
|
|
114
|
+
linearIssueId: issue.linearIssueId,
|
|
115
|
+
lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
|
|
116
|
+
lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
|
|
117
|
+
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
118
|
+
lastGitHubCiSnapshotJson: null,
|
|
119
|
+
lastGitHubCiSnapshotSettledAt: null,
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (issue.prState !== "open")
|
|
124
|
+
return;
|
|
125
|
+
if (event.eventSource !== "check_run")
|
|
126
|
+
return;
|
|
127
|
+
if (isQueueEvictionFailure(issue, event, project))
|
|
128
|
+
return;
|
|
129
|
+
if (!isGateCheckEvent(event, project))
|
|
130
|
+
return;
|
|
131
|
+
if (isStaleGateEvent(issue, event))
|
|
132
|
+
return;
|
|
133
|
+
const snapshot = await ciSnapshotResolver.resolve({
|
|
134
|
+
repoFullName: project?.github?.repoFullName ?? event.repoFullName,
|
|
135
|
+
event,
|
|
136
|
+
gateCheckNames: getGateCheckNames(project),
|
|
137
|
+
});
|
|
138
|
+
if (!snapshot) {
|
|
139
|
+
deps.db.issues.upsertIssue({
|
|
140
|
+
projectId: issue.projectId,
|
|
141
|
+
linearIssueId: issue.linearIssueId,
|
|
142
|
+
lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
|
|
143
|
+
lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
|
|
144
|
+
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
145
|
+
lastGitHubCiSnapshotJson: null,
|
|
146
|
+
lastGitHubCiSnapshotSettledAt: null,
|
|
147
|
+
});
|
|
148
|
+
deps.logger.warn({ issueKey: issue.issueKey, repoFullName: project?.github?.repoFullName ?? event.repoFullName, headSha: event.headSha }, "Could not resolve settled CI snapshot; waiting before CI repair");
|
|
149
|
+
deps.feed?.publish({
|
|
150
|
+
level: "warn",
|
|
151
|
+
kind: "github",
|
|
152
|
+
issueKey: issue.issueKey,
|
|
153
|
+
projectId: issue.projectId,
|
|
154
|
+
stage: issue.factoryState,
|
|
155
|
+
status: "ci_snapshot_unavailable",
|
|
156
|
+
summary: `Could not resolve settled ${getPrimaryGateCheckName(project)} snapshot; waiting before CI repair`,
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
deps.db.issues.upsertIssue({
|
|
161
|
+
projectId: issue.projectId,
|
|
162
|
+
linearIssueId: issue.linearIssueId,
|
|
163
|
+
prCheckStatus: snapshot.gateCheckStatus,
|
|
164
|
+
lastGitHubCiSnapshotHeadSha: snapshot.headSha,
|
|
165
|
+
lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? getPrimaryGateCheckName(project),
|
|
166
|
+
lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
|
|
167
|
+
lastGitHubCiSnapshotJson: JSON.stringify(snapshot),
|
|
168
|
+
lastGitHubCiSnapshotSettledAt: snapshot.settledAt ?? null,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async function updateGitHubFailureProvenance(deps, issue, event, project, failureContextResolver) {
|
|
172
|
+
const isQueueEvictionCheck = isQueueEvictionFailure(issue, event, project);
|
|
173
|
+
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
174
|
+
const source = isQueueEvictionCheck
|
|
175
|
+
? "queue_eviction"
|
|
176
|
+
: "branch_ci";
|
|
177
|
+
if (source === "branch_ci" && !isSettledBranchFailure(deps.db, issue, event, project)) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const failureContext = source === "queue_eviction"
|
|
181
|
+
? buildGitHubQueueFailureContext(event, project, buildQueueRepairContextFromEvent(event))
|
|
182
|
+
: await resolveGitHubBranchFailureContext({
|
|
183
|
+
db: deps.db,
|
|
184
|
+
issue,
|
|
185
|
+
event,
|
|
186
|
+
project,
|
|
187
|
+
failureContextResolver,
|
|
188
|
+
});
|
|
189
|
+
deps.db.issues.upsertIssue({
|
|
190
|
+
projectId: issue.projectId,
|
|
191
|
+
linearIssueId: issue.linearIssueId,
|
|
192
|
+
lastGitHubFailureSource: source,
|
|
193
|
+
lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? event.headSha ?? null,
|
|
194
|
+
lastGitHubFailureSignature: failureContext.failureSignature ?? null,
|
|
195
|
+
lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
|
|
196
|
+
lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
|
|
197
|
+
lastGitHubFailureContextJson: JSON.stringify(failureContext),
|
|
198
|
+
lastGitHubFailureAt: new Date().toISOString(),
|
|
199
|
+
...(source === "queue_eviction"
|
|
200
|
+
? {
|
|
201
|
+
lastQueueSignalAt: new Date().toISOString(),
|
|
202
|
+
lastQueueIncidentJson: JSON.stringify(buildQueueRepairContextFromEvent(event)),
|
|
203
|
+
}
|
|
204
|
+
: {
|
|
205
|
+
lastQueueIncidentJson: null,
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionFailure(issue, event, project) || isGateCheckEvent(event, project)))
|
|
211
|
+
|| event.triggerEvent === "pr_synchronize"
|
|
212
|
+
|| event.triggerEvent === "pr_merged") {
|
|
213
|
+
if (event.triggerEvent === "check_passed" && !canClearFailureProvenance(issue, event, project)) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
deps.db.issues.upsertIssue({
|
|
217
|
+
projectId: issue.projectId,
|
|
218
|
+
linearIssueId: issue.linearIssueId,
|
|
219
|
+
lastGitHubFailureSource: null,
|
|
220
|
+
lastGitHubFailureHeadSha: null,
|
|
221
|
+
lastGitHubFailureSignature: null,
|
|
222
|
+
lastGitHubFailureCheckName: null,
|
|
223
|
+
lastGitHubFailureCheckUrl: null,
|
|
224
|
+
lastGitHubFailureContextJson: null,
|
|
225
|
+
lastGitHubFailureAt: null,
|
|
226
|
+
lastQueueIncidentJson: null,
|
|
227
|
+
lastAttemptedFailureHeadSha: null,
|
|
228
|
+
lastAttemptedFailureSignature: null,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|