patchrelay 0.38.0 → 0.38.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +4 -0
- package/dist/cli/commands/issues.js +20 -1
- package/dist/cli/data.js +54 -7
- package/dist/cli/formatters/text.js +10 -0
- package/dist/cli/help.js +4 -0
- package/dist/cli/index.js +3 -0
- package/dist/config.js +26 -0
- package/dist/db/issue-store.js +10 -2
- package/dist/db/migrations.js +5 -0
- package/dist/factory-state.js +1 -0
- package/dist/github-linear-session-sync.js +57 -0
- package/dist/github-pr-comment-handler.js +74 -0
- package/dist/github-webhook-failure-context.js +70 -0
- package/dist/github-webhook-handler.js +52 -975
- package/dist/github-webhook-issue-resolution.js +46 -0
- package/dist/github-webhook-late-publication-guard.js +94 -0
- package/dist/github-webhook-policy.js +105 -0
- package/dist/github-webhook-reactive-run.js +302 -0
- package/dist/github-webhook-state-projector.js +245 -0
- package/dist/github-webhook-terminal-handler.js +111 -0
- package/dist/github-webhooks.js +39 -4
- package/dist/http.js +17 -0
- package/dist/idle-reconciliation.js +4 -2
- package/dist/issue-overview-query.js +8 -57
- package/dist/issue-session-events.js +1 -0
- package/dist/legacy-issue-overview.js +58 -0
- package/dist/linear-activity-key.js +11 -0
- package/dist/linear-agent-session-client.js +14 -1
- package/dist/linear-progress-reporter.js +7 -181
- package/dist/linear-status-comment-sync.js +3 -19
- package/dist/manual-issue-actions.js +37 -0
- package/dist/presentation-text.js +11 -1
- package/dist/prompting/patchrelay.js +8 -6
- package/dist/reactive-pr-state.js +65 -0
- package/dist/reactive-run-policy.js +35 -118
- package/dist/remote-pr-state.js +11 -0
- package/dist/run-budgets.js +12 -0
- package/dist/run-notification-handler.js +4 -0
- package/dist/run-orchestrator.js +28 -8
- package/dist/run-wake-planner.js +11 -10
- package/dist/service-issue-actions.js +80 -27
- package/dist/service.js +3 -0
- package/dist/webhooks/desired-stage-recorder.js +34 -10
- package/package.json +1 -1
|
@@ -0,0 +1,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,94 @@
|
|
|
1
|
+
function isPatchRelayBot(login) {
|
|
2
|
+
return login === "patchrelay[bot]" || login === "app/patchrelay";
|
|
3
|
+
}
|
|
4
|
+
function parseRepo(repoFullName) {
|
|
5
|
+
const [owner, repo] = repoFullName.split("/", 2);
|
|
6
|
+
if (!owner || !repo)
|
|
7
|
+
return undefined;
|
|
8
|
+
return { owner, repo };
|
|
9
|
+
}
|
|
10
|
+
export async function maybeCloseLatePublishedImplementationPr(params) {
|
|
11
|
+
const { db, logger, feed, issue, event, fetchImpl } = params;
|
|
12
|
+
if (event.triggerEvent !== "pr_opened")
|
|
13
|
+
return false;
|
|
14
|
+
if (event.prNumber === undefined)
|
|
15
|
+
return false;
|
|
16
|
+
if (issue.prNumber !== undefined)
|
|
17
|
+
return false;
|
|
18
|
+
if (!isPatchRelayBot(event.prAuthorLogin))
|
|
19
|
+
return false;
|
|
20
|
+
const latestRun = db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
21
|
+
if (!latestRun || latestRun.runType !== "implementation")
|
|
22
|
+
return false;
|
|
23
|
+
if (latestRun.status === "running" || latestRun.status === "completed")
|
|
24
|
+
return false;
|
|
25
|
+
const repo = parseRepo(event.repoFullName);
|
|
26
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
27
|
+
if (!repo || !token) {
|
|
28
|
+
logger.warn({
|
|
29
|
+
issueKey: issue.issueKey,
|
|
30
|
+
prNumber: event.prNumber,
|
|
31
|
+
latestRunId: latestRun.id,
|
|
32
|
+
latestRunStatus: latestRun.status,
|
|
33
|
+
}, "Late PatchRelay PR was detected after the implementation run had already stopped, but PatchRelay could not auto-close it");
|
|
34
|
+
feed?.publish({
|
|
35
|
+
level: "warn",
|
|
36
|
+
kind: "github",
|
|
37
|
+
issueKey: issue.issueKey,
|
|
38
|
+
projectId: issue.projectId,
|
|
39
|
+
stage: issue.factoryState,
|
|
40
|
+
status: "late_pr_detected",
|
|
41
|
+
summary: `Detected late PR #${event.prNumber} from an inactive implementation run`,
|
|
42
|
+
detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
|
|
43
|
+
});
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const response = await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/pulls/${event.prNumber}`, {
|
|
47
|
+
method: "PATCH",
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${token}`,
|
|
50
|
+
Accept: "application/vnd.github+json",
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"User-Agent": "patchrelay",
|
|
53
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({ state: "closed" }),
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
logger.warn({
|
|
59
|
+
issueKey: issue.issueKey,
|
|
60
|
+
prNumber: event.prNumber,
|
|
61
|
+
status: response.status,
|
|
62
|
+
latestRunId: latestRun.id,
|
|
63
|
+
latestRunStatus: latestRun.status,
|
|
64
|
+
}, "Failed to auto-close late PatchRelay PR from an inactive implementation run");
|
|
65
|
+
feed?.publish({
|
|
66
|
+
level: "warn",
|
|
67
|
+
kind: "github",
|
|
68
|
+
issueKey: issue.issueKey,
|
|
69
|
+
projectId: issue.projectId,
|
|
70
|
+
stage: issue.factoryState,
|
|
71
|
+
status: "late_pr_close_failed",
|
|
72
|
+
summary: `Could not auto-close late PR #${event.prNumber}`,
|
|
73
|
+
detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
|
|
74
|
+
});
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
logger.warn({
|
|
78
|
+
issueKey: issue.issueKey,
|
|
79
|
+
prNumber: event.prNumber,
|
|
80
|
+
latestRunId: latestRun.id,
|
|
81
|
+
latestRunStatus: latestRun.status,
|
|
82
|
+
}, "Auto-closed late PatchRelay PR from an inactive implementation run");
|
|
83
|
+
feed?.publish({
|
|
84
|
+
level: "warn",
|
|
85
|
+
kind: "github",
|
|
86
|
+
issueKey: issue.issueKey,
|
|
87
|
+
projectId: issue.projectId,
|
|
88
|
+
stage: issue.factoryState,
|
|
89
|
+
status: "late_pr_closed",
|
|
90
|
+
summary: `Auto-closed late PR #${event.prNumber} from an inactive implementation run`,
|
|
91
|
+
detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
|
|
92
|
+
});
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
@@ -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
|
+
}
|