patchrelay 0.8.9 → 0.9.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 +64 -62
- package/dist/agent-session-plan.js +17 -17
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +1 -1
- package/dist/cli/commands/issues.js +18 -18
- package/dist/cli/data.js +109 -298
- package/dist/cli/formatters/text.js +22 -28
- package/dist/cli/help.js +7 -7
- package/dist/cli/index.js +3 -3
- package/dist/config.js +13 -166
- package/dist/db/migrations.js +46 -154
- package/dist/db.js +369 -45
- package/dist/factory-state.js +55 -0
- package/dist/github-webhook-handler.js +199 -0
- package/dist/github-webhooks.js +166 -0
- package/dist/hook-runner.js +28 -0
- package/dist/http.js +48 -22
- package/dist/issue-query-service.js +33 -38
- package/dist/linear-workflow.js +5 -118
- package/dist/preflight.js +1 -6
- package/dist/project-resolution.js +12 -1
- package/dist/run-orchestrator.js +446 -0
- package/dist/{stage-reporting.js → run-reporting.js} +11 -13
- package/dist/service-runtime.js +12 -61
- package/dist/service-webhooks.js +7 -52
- package/dist/service.js +39 -61
- package/dist/webhook-handler.js +387 -0
- package/dist/webhook-installation-handler.js +3 -8
- package/package.json +2 -1
- package/dist/db/authoritative-ledger-store.js +0 -536
- package/dist/db/issue-projection-store.js +0 -54
- package/dist/db/issue-workflow-coordinator.js +0 -320
- package/dist/db/issue-workflow-store.js +0 -194
- package/dist/db/run-report-store.js +0 -33
- package/dist/db/stage-event-store.js +0 -33
- package/dist/db/webhook-event-store.js +0 -59
- package/dist/db-ports.js +0 -5
- package/dist/ledger-ports.js +0 -1
- package/dist/reconciliation-action-applier.js +0 -68
- package/dist/reconciliation-actions.js +0 -1
- package/dist/reconciliation-engine.js +0 -350
- package/dist/reconciliation-snapshot-builder.js +0 -135
- package/dist/reconciliation-types.js +0 -1
- package/dist/service-stage-finalizer.js +0 -753
- package/dist/service-stage-runner.js +0 -336
- package/dist/service-webhook-processor.js +0 -411
- package/dist/stage-agent-activity-publisher.js +0 -59
- package/dist/stage-event-ports.js +0 -1
- package/dist/stage-failure.js +0 -92
- package/dist/stage-handoff.js +0 -107
- package/dist/stage-launch.js +0 -84
- package/dist/stage-lifecycle-publisher.js +0 -284
- package/dist/stage-turn-input-dispatcher.js +0 -104
- package/dist/webhook-agent-session-handler.js +0 -228
- package/dist/webhook-comment-handler.js +0 -141
- package/dist/webhook-desired-stage-recorder.js +0 -122
- package/dist/webhook-event-ports.js +0 -1
- package/dist/workflow-policy.js +0 -149
- package/dist/workflow-ports.js +0 -1
- /package/dist/{installation-ports.js → github-types.js} +0 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { resolveFactoryStateFromGitHub } from "./factory-state.js";
|
|
2
|
+
import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
|
|
3
|
+
import { safeJsonParse } from "./utils.js";
|
|
4
|
+
export class GitHubWebhookHandler {
|
|
5
|
+
config;
|
|
6
|
+
db;
|
|
7
|
+
linearProvider;
|
|
8
|
+
enqueueIssue;
|
|
9
|
+
logger;
|
|
10
|
+
feed;
|
|
11
|
+
constructor(config, db, linearProvider, enqueueIssue, logger, feed) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.linearProvider = linearProvider;
|
|
15
|
+
this.enqueueIssue = enqueueIssue;
|
|
16
|
+
this.logger = logger;
|
|
17
|
+
this.feed = feed;
|
|
18
|
+
}
|
|
19
|
+
async acceptGitHubWebhook(params) {
|
|
20
|
+
// Deduplicate
|
|
21
|
+
if (this.db.isWebhookDuplicate(params.deliveryId)) {
|
|
22
|
+
return { status: 200, body: { ok: true, duplicate: true } };
|
|
23
|
+
}
|
|
24
|
+
// Store the event
|
|
25
|
+
const stored = this.db.insertWebhookEvent(params.deliveryId, new Date().toISOString());
|
|
26
|
+
// Parse payload
|
|
27
|
+
const payload = safeJsonParse(params.rawBody.toString("utf8"));
|
|
28
|
+
if (!payload) {
|
|
29
|
+
return { status: 400, body: { ok: false, reason: "invalid_json" } };
|
|
30
|
+
}
|
|
31
|
+
// Find matching project by repo
|
|
32
|
+
const repoFullName = typeof payload === "object" && payload !== null && "repository" in payload
|
|
33
|
+
? payload.repository
|
|
34
|
+
: undefined;
|
|
35
|
+
const repoName = typeof repoFullName === "object" && repoFullName !== null && "full_name" in repoFullName
|
|
36
|
+
? String(repoFullName.full_name)
|
|
37
|
+
: undefined;
|
|
38
|
+
const project = repoName
|
|
39
|
+
? this.config.projects.find((p) => p.github?.repoFullName === repoName)
|
|
40
|
+
: undefined;
|
|
41
|
+
// Verify signature using global GitHub App webhook secret
|
|
42
|
+
const webhookSecret = process.env.GITHUB_APP_WEBHOOK_SECRET;
|
|
43
|
+
if (webhookSecret) {
|
|
44
|
+
if (!verifyGitHubWebhookSignature(params.rawBody, webhookSecret, params.signature)) {
|
|
45
|
+
return { status: 401, body: { ok: false, reason: "invalid_signature" } };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (stored.duplicate) {
|
|
49
|
+
return { status: 200, body: { ok: true, duplicate: true } };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
status: 200,
|
|
53
|
+
body: {
|
|
54
|
+
ok: true,
|
|
55
|
+
accepted: true,
|
|
56
|
+
webhookEventId: stored.id,
|
|
57
|
+
eventType: params.eventType,
|
|
58
|
+
projectId: project?.id,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async processGitHubWebhookEvent(params) {
|
|
63
|
+
const payload = safeJsonParse(params.rawBody);
|
|
64
|
+
if (!payload || typeof payload !== "object")
|
|
65
|
+
return;
|
|
66
|
+
const event = normalizeGitHubWebhook({
|
|
67
|
+
eventType: params.eventType,
|
|
68
|
+
payload: payload,
|
|
69
|
+
});
|
|
70
|
+
if (!event) {
|
|
71
|
+
this.logger.debug({ eventType: params.eventType }, "GitHub webhook: unrecognized event type or action");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Route to issue via branch name
|
|
75
|
+
const issue = this.db.getIssueByBranch(event.branchName);
|
|
76
|
+
if (!issue) {
|
|
77
|
+
this.logger.debug({ branchName: event.branchName, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching issue for branch");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Update PR state on the issue
|
|
81
|
+
this.db.upsertIssue({
|
|
82
|
+
projectId: issue.projectId,
|
|
83
|
+
linearIssueId: issue.linearIssueId,
|
|
84
|
+
...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
|
|
85
|
+
...(event.prUrl !== undefined ? { prUrl: event.prUrl } : {}),
|
|
86
|
+
...(event.prState !== undefined ? { prState: event.prState } : {}),
|
|
87
|
+
...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
|
|
88
|
+
...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
|
|
89
|
+
});
|
|
90
|
+
// Drive factory state transitions from GitHub events
|
|
91
|
+
const newState = resolveFactoryStateFromGitHub(event.triggerEvent, issue.factoryState);
|
|
92
|
+
if (newState) {
|
|
93
|
+
this.db.upsertIssue({
|
|
94
|
+
projectId: issue.projectId,
|
|
95
|
+
linearIssueId: issue.linearIssueId,
|
|
96
|
+
factoryState: newState,
|
|
97
|
+
});
|
|
98
|
+
this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
|
|
99
|
+
// Emit Linear activity for significant state changes
|
|
100
|
+
void this.emitLinearActivity(issue, newState, event);
|
|
101
|
+
}
|
|
102
|
+
// Reset repair counters on new push
|
|
103
|
+
if (event.triggerEvent === "pr_synchronize") {
|
|
104
|
+
this.db.upsertIssue({
|
|
105
|
+
projectId: issue.projectId,
|
|
106
|
+
linearIssueId: issue.linearIssueId,
|
|
107
|
+
ciRepairAttempts: 0,
|
|
108
|
+
queueRepairAttempts: 0,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
|
|
112
|
+
this.feed?.publish({
|
|
113
|
+
level: event.triggerEvent.includes("failed") ? "warn" : "info",
|
|
114
|
+
kind: "github",
|
|
115
|
+
issueKey: issue.issueKey,
|
|
116
|
+
projectId: issue.projectId,
|
|
117
|
+
stage: issue.factoryState,
|
|
118
|
+
status: event.triggerEvent,
|
|
119
|
+
summary: `GitHub: ${event.triggerEvent}${event.prNumber ? ` on PR #${event.prNumber}` : ""}`,
|
|
120
|
+
detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
|
|
121
|
+
});
|
|
122
|
+
// Trigger reactive runs if applicable
|
|
123
|
+
this.maybeEnqueueReactiveRun(issue, event);
|
|
124
|
+
}
|
|
125
|
+
maybeEnqueueReactiveRun(issue, event) {
|
|
126
|
+
// Don't trigger if there's already an active run
|
|
127
|
+
if (issue.activeRunId !== undefined)
|
|
128
|
+
return;
|
|
129
|
+
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
130
|
+
this.db.upsertIssue({
|
|
131
|
+
projectId: issue.projectId,
|
|
132
|
+
linearIssueId: issue.linearIssueId,
|
|
133
|
+
pendingRunType: "ci_repair",
|
|
134
|
+
pendingRunContextJson: JSON.stringify({
|
|
135
|
+
checkName: event.checkName,
|
|
136
|
+
checkUrl: event.checkUrl,
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
140
|
+
this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Enqueued CI repair run");
|
|
141
|
+
}
|
|
142
|
+
if (event.triggerEvent === "review_changes_requested") {
|
|
143
|
+
this.db.upsertIssue({
|
|
144
|
+
projectId: issue.projectId,
|
|
145
|
+
linearIssueId: issue.linearIssueId,
|
|
146
|
+
pendingRunType: "review_fix",
|
|
147
|
+
pendingRunContextJson: JSON.stringify({
|
|
148
|
+
reviewBody: event.reviewBody,
|
|
149
|
+
reviewerName: event.reviewerName,
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
153
|
+
this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
|
|
154
|
+
}
|
|
155
|
+
if (event.triggerEvent === "merge_group_failed") {
|
|
156
|
+
this.db.upsertIssue({
|
|
157
|
+
projectId: issue.projectId,
|
|
158
|
+
linearIssueId: issue.linearIssueId,
|
|
159
|
+
pendingRunType: "queue_repair",
|
|
160
|
+
pendingRunContextJson: JSON.stringify({
|
|
161
|
+
failureReason: event.mergeGroupFailureReason,
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
165
|
+
this.logger.info({ issueKey: issue.issueKey }, "Enqueued merge queue repair run");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async emitLinearActivity(issue, newState, event) {
|
|
169
|
+
if (!issue.agentSessionId)
|
|
170
|
+
return;
|
|
171
|
+
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
172
|
+
if (!linear?.createAgentActivity)
|
|
173
|
+
return;
|
|
174
|
+
const messages = {
|
|
175
|
+
pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
|
|
176
|
+
awaiting_queue: "PR approved. Awaiting merge queue.",
|
|
177
|
+
changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
|
|
178
|
+
repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
|
|
179
|
+
repairing_queue: "Merge queue failed. Starting repair.",
|
|
180
|
+
done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
|
|
181
|
+
failed: "PR was closed without merging.",
|
|
182
|
+
};
|
|
183
|
+
const body = messages[newState];
|
|
184
|
+
if (!body)
|
|
185
|
+
return;
|
|
186
|
+
const type = newState === "failed" || newState === "repairing_ci" || newState === "repairing_queue"
|
|
187
|
+
? "error"
|
|
188
|
+
: "response";
|
|
189
|
+
try {
|
|
190
|
+
await linear.createAgentActivity({
|
|
191
|
+
agentSessionId: issue.agentSessionId,
|
|
192
|
+
content: { type, body },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Non-blocking — don't fail the webhook for a Linear activity error
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export function verifyGitHubWebhookSignature(rawBody, secret, signature) {
|
|
3
|
+
if (!signature.startsWith("sha256=")) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
|
|
7
|
+
const provided = signature.slice("sha256=".length);
|
|
8
|
+
if (expected.length !== provided.length) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(provided, "hex"));
|
|
12
|
+
}
|
|
13
|
+
export function normalizeGitHubWebhook(params) {
|
|
14
|
+
const { eventType, payload } = params;
|
|
15
|
+
const repoFullName = payload.repository?.full_name ?? "";
|
|
16
|
+
if (eventType === "pull_request" && payload.pull_request) {
|
|
17
|
+
return normalizePullRequestEvent(payload, repoFullName);
|
|
18
|
+
}
|
|
19
|
+
if (eventType === "pull_request_review" && payload.pull_request && payload.review) {
|
|
20
|
+
return normalizePullRequestReviewEvent(payload, repoFullName);
|
|
21
|
+
}
|
|
22
|
+
if (eventType === "check_suite" && payload.check_suite) {
|
|
23
|
+
return normalizeCheckSuiteEvent(payload, repoFullName);
|
|
24
|
+
}
|
|
25
|
+
if (eventType === "check_run" && payload.check_run) {
|
|
26
|
+
return normalizeCheckRunEvent(payload, repoFullName);
|
|
27
|
+
}
|
|
28
|
+
if (eventType === "merge_group" && payload.merge_group) {
|
|
29
|
+
return normalizeMergeGroupEvent(payload, repoFullName);
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
function normalizePullRequestEvent(payload, repoFullName) {
|
|
34
|
+
const pr = payload.pull_request;
|
|
35
|
+
const action = payload.action;
|
|
36
|
+
let triggerEvent;
|
|
37
|
+
let prState;
|
|
38
|
+
if (action === "opened" || action === "reopened") {
|
|
39
|
+
triggerEvent = "pr_opened";
|
|
40
|
+
prState = "open";
|
|
41
|
+
}
|
|
42
|
+
else if (action === "synchronize") {
|
|
43
|
+
triggerEvent = "pr_synchronize";
|
|
44
|
+
prState = "open";
|
|
45
|
+
}
|
|
46
|
+
else if (action === "closed") {
|
|
47
|
+
if (pr.merged) {
|
|
48
|
+
triggerEvent = "pr_merged";
|
|
49
|
+
prState = "merged";
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
triggerEvent = "pr_closed";
|
|
53
|
+
prState = "closed";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
triggerEvent,
|
|
61
|
+
repoFullName,
|
|
62
|
+
branchName: pr.head.ref,
|
|
63
|
+
headSha: pr.head.sha,
|
|
64
|
+
prNumber: pr.number,
|
|
65
|
+
prUrl: pr.html_url,
|
|
66
|
+
prState,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function normalizePullRequestReviewEvent(payload, repoFullName) {
|
|
70
|
+
if (payload.action !== "submitted")
|
|
71
|
+
return undefined;
|
|
72
|
+
const pr = payload.pull_request;
|
|
73
|
+
const review = payload.review;
|
|
74
|
+
const state = review.state?.toLowerCase();
|
|
75
|
+
let triggerEvent;
|
|
76
|
+
let reviewState;
|
|
77
|
+
if (state === "approved") {
|
|
78
|
+
triggerEvent = "review_approved";
|
|
79
|
+
reviewState = "approved";
|
|
80
|
+
}
|
|
81
|
+
else if (state === "changes_requested") {
|
|
82
|
+
triggerEvent = "review_changes_requested";
|
|
83
|
+
reviewState = "changes_requested";
|
|
84
|
+
}
|
|
85
|
+
else if (state === "commented") {
|
|
86
|
+
triggerEvent = "review_commented";
|
|
87
|
+
reviewState = "commented";
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
triggerEvent,
|
|
94
|
+
repoFullName,
|
|
95
|
+
branchName: pr.head.ref,
|
|
96
|
+
headSha: pr.head.sha,
|
|
97
|
+
prNumber: pr.number,
|
|
98
|
+
prUrl: pr.html_url,
|
|
99
|
+
prState: "open",
|
|
100
|
+
reviewState,
|
|
101
|
+
reviewBody: review.body ?? undefined,
|
|
102
|
+
reviewerName: review.user?.login ?? undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function normalizeCheckSuiteEvent(payload, repoFullName) {
|
|
106
|
+
if (payload.action !== "completed")
|
|
107
|
+
return undefined;
|
|
108
|
+
const suite = payload.check_suite;
|
|
109
|
+
const conclusion = suite.conclusion?.toLowerCase();
|
|
110
|
+
const pr = suite.pull_requests?.[0];
|
|
111
|
+
const branchName = pr?.head.ref ?? suite.head_branch ?? "";
|
|
112
|
+
if (!branchName)
|
|
113
|
+
return undefined;
|
|
114
|
+
const passed = conclusion === "success" || conclusion === "neutral" || conclusion === "skipped";
|
|
115
|
+
return {
|
|
116
|
+
triggerEvent: passed ? "check_passed" : "check_failed",
|
|
117
|
+
repoFullName,
|
|
118
|
+
branchName,
|
|
119
|
+
headSha: suite.head_sha,
|
|
120
|
+
prNumber: pr?.number,
|
|
121
|
+
checkStatus: passed ? "success" : "failure",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function normalizeCheckRunEvent(payload, repoFullName) {
|
|
125
|
+
if (payload.action !== "completed")
|
|
126
|
+
return undefined;
|
|
127
|
+
const run = payload.check_run;
|
|
128
|
+
const conclusion = run.conclusion?.toLowerCase();
|
|
129
|
+
const pr = run.check_suite?.pull_requests?.[0];
|
|
130
|
+
const branchName = pr?.head.ref ?? run.check_suite?.head_branch ?? "";
|
|
131
|
+
if (!branchName)
|
|
132
|
+
return undefined;
|
|
133
|
+
const passed = conclusion === "success" || conclusion === "neutral" || conclusion === "skipped";
|
|
134
|
+
return {
|
|
135
|
+
triggerEvent: passed ? "check_passed" : "check_failed",
|
|
136
|
+
repoFullName,
|
|
137
|
+
branchName,
|
|
138
|
+
headSha: run.head_sha,
|
|
139
|
+
prNumber: pr?.number,
|
|
140
|
+
checkStatus: passed ? "success" : "failure",
|
|
141
|
+
checkName: run.name,
|
|
142
|
+
checkUrl: run.html_url,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function normalizeMergeGroupEvent(payload, repoFullName) {
|
|
146
|
+
const group = payload.merge_group;
|
|
147
|
+
const action = payload.action;
|
|
148
|
+
if (action === "checks_passed") {
|
|
149
|
+
return {
|
|
150
|
+
triggerEvent: "merge_group_passed",
|
|
151
|
+
repoFullName,
|
|
152
|
+
branchName: group.head_ref,
|
|
153
|
+
headSha: group.head_sha,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (action === "checks_failed" || action === "destroyed") {
|
|
157
|
+
return {
|
|
158
|
+
triggerEvent: "merge_group_failed",
|
|
159
|
+
repoFullName,
|
|
160
|
+
branchName: group.head_ref,
|
|
161
|
+
headSha: group.head_sha,
|
|
162
|
+
mergeGroupFailureReason: action,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execCommand } from "./utils.js";
|
|
4
|
+
export async function runProjectHook(repoPath, hookName, options) {
|
|
5
|
+
const hookPath = path.join(repoPath, ".patchrelay", "hooks", hookName);
|
|
6
|
+
if (!existsSync(hookPath)) {
|
|
7
|
+
return { ran: false };
|
|
8
|
+
}
|
|
9
|
+
const result = await execCommand(hookPath, [], {
|
|
10
|
+
cwd: options.cwd,
|
|
11
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
12
|
+
timeoutMs: options.timeoutMs ?? 120_000,
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
ran: true,
|
|
16
|
+
exitCode: result.exitCode,
|
|
17
|
+
stdout: result.stdout,
|
|
18
|
+
stderr: result.stderr,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function buildHookEnv(issueKey, branchName, stage, worktreePath) {
|
|
22
|
+
return {
|
|
23
|
+
PATCHRELAY_ISSUE_KEY: issueKey,
|
|
24
|
+
PATCHRELAY_BRANCH: branchName,
|
|
25
|
+
PATCHRELAY_STAGE: stage,
|
|
26
|
+
PATCHRELAY_WORKTREE: worktreePath,
|
|
27
|
+
};
|
|
28
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -178,6 +178,32 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
178
178
|
});
|
|
179
179
|
return reply.code(result.status).send(result.body);
|
|
180
180
|
});
|
|
181
|
+
app.post(config.ingress.githubWebhookPath, {
|
|
182
|
+
config: {
|
|
183
|
+
rawBody: true,
|
|
184
|
+
},
|
|
185
|
+
}, async (request, reply) => {
|
|
186
|
+
const rawBody = typeof request.rawBody === "string" ? Buffer.from(request.rawBody) : request.rawBody;
|
|
187
|
+
if (!rawBody) {
|
|
188
|
+
return reply.code(400).send({ ok: false, reason: "missing_raw_body" });
|
|
189
|
+
}
|
|
190
|
+
const deliveryId = getHeader(request, "x-github-delivery");
|
|
191
|
+
if (!deliveryId) {
|
|
192
|
+
return reply.code(400).send({ ok: false, reason: "missing_delivery_header" });
|
|
193
|
+
}
|
|
194
|
+
const eventType = getHeader(request, "x-github-event");
|
|
195
|
+
if (!eventType) {
|
|
196
|
+
return reply.code(400).send({ ok: false, reason: "missing_event_type" });
|
|
197
|
+
}
|
|
198
|
+
const signature = getHeader(request, "x-hub-signature-256") ?? "";
|
|
199
|
+
const result = await service.acceptGitHubWebhook({
|
|
200
|
+
deliveryId,
|
|
201
|
+
eventType,
|
|
202
|
+
signature,
|
|
203
|
+
rawBody,
|
|
204
|
+
});
|
|
205
|
+
return reply.code(result.status).send(result.body);
|
|
206
|
+
});
|
|
181
207
|
app.get("/agent/session/:issueKey", async (request, reply) => {
|
|
182
208
|
const issueKey = request.params.issueKey;
|
|
183
209
|
const token = getQueryParam(request, "token");
|
|
@@ -235,17 +261,17 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
235
261
|
});
|
|
236
262
|
app.get("/api/issues/:issueKey/live", async (request, reply) => {
|
|
237
263
|
const issueKey = request.params.issueKey;
|
|
238
|
-
const result = await service.
|
|
264
|
+
const result = await service.getActiveRunStatus(issueKey);
|
|
239
265
|
if (!result) {
|
|
240
|
-
return reply.code(404).send({ ok: false, reason: "
|
|
266
|
+
return reply.code(404).send({ ok: false, reason: "active_run_not_found" });
|
|
241
267
|
}
|
|
242
268
|
return reply.send({ ok: true, ...result });
|
|
243
269
|
});
|
|
244
|
-
app.get("/api/issues/:issueKey/
|
|
245
|
-
const { issueKey,
|
|
246
|
-
const result = await service.
|
|
270
|
+
app.get("/api/issues/:issueKey/runs/:runId/events", async (request, reply) => {
|
|
271
|
+
const { issueKey, runId } = request.params;
|
|
272
|
+
const result = await service.getRunEvents(issueKey, Number(runId));
|
|
247
273
|
if (!result) {
|
|
248
|
-
return reply.code(404).send({ ok: false, reason: "
|
|
274
|
+
return reply.code(404).send({ ok: false, reason: "run_not_found" });
|
|
249
275
|
}
|
|
250
276
|
return reply.send({ ok: true, ...result });
|
|
251
277
|
});
|
|
@@ -448,10 +474,10 @@ function renderAgentSessionStatusErrorPage(message) {
|
|
|
448
474
|
function renderAgentSessionStatusPage(params) {
|
|
449
475
|
const issueTitle = params.sessionStatus.issue.title ?? params.sessionStatus.issue.issueKey ?? params.issueKey;
|
|
450
476
|
const issueUrl = params.sessionStatus.issue.issueUrl;
|
|
451
|
-
const activeStage = formatStageChip(params.sessionStatus.
|
|
452
|
-
const latestStage = formatStageChip(params.sessionStatus.
|
|
477
|
+
const activeStage = formatStageChip(params.sessionStatus.activeRun);
|
|
478
|
+
const latestStage = formatStageChip(params.sessionStatus.latestRun);
|
|
453
479
|
const threadInfo = formatThread(params.sessionStatus.liveThread);
|
|
454
|
-
const stagesRows = params.sessionStatus.
|
|
480
|
+
const stagesRows = params.sessionStatus.runs.slice(-8).map((entry) => formatStageRow(entry.run)).join("");
|
|
455
481
|
return `<!doctype html>
|
|
456
482
|
<html lang="en">
|
|
457
483
|
<head>
|
|
@@ -535,13 +561,13 @@ function renderAgentSessionStatusPage(params) {
|
|
|
535
561
|
</body>
|
|
536
562
|
</html>`;
|
|
537
563
|
}
|
|
538
|
-
function formatStageChip(
|
|
539
|
-
if (!
|
|
564
|
+
function formatStageChip(run) {
|
|
565
|
+
if (!run) {
|
|
540
566
|
return "none";
|
|
541
567
|
}
|
|
542
|
-
const
|
|
543
|
-
const status =
|
|
544
|
-
return `<code>${escapeHtml(
|
|
568
|
+
const runType = run.runType ?? "unknown";
|
|
569
|
+
const status = run.status ?? "unknown";
|
|
570
|
+
return `<code>${escapeHtml(runType)}</code> (${escapeHtml(status)})`;
|
|
545
571
|
}
|
|
546
572
|
function formatThread(liveThread) {
|
|
547
573
|
if (!liveThread) {
|
|
@@ -551,13 +577,13 @@ function formatThread(liveThread) {
|
|
|
551
577
|
const status = liveThread.threadStatus ?? "unknown";
|
|
552
578
|
return `<code>${escapeHtml(threadId)}</code> (${escapeHtml(status)})`;
|
|
553
579
|
}
|
|
554
|
-
function formatStageRow(
|
|
555
|
-
if (!
|
|
556
|
-
return '<tr><td colspan="4">Unknown
|
|
580
|
+
function formatStageRow(run) {
|
|
581
|
+
if (!run) {
|
|
582
|
+
return '<tr><td colspan="4">Unknown run record</td></tr>';
|
|
557
583
|
}
|
|
558
|
-
const
|
|
559
|
-
const status =
|
|
560
|
-
const startedAt =
|
|
561
|
-
const endedAt =
|
|
562
|
-
return `<tr><td><code>${escapeHtml(
|
|
584
|
+
const runType = run.runType ?? "unknown";
|
|
585
|
+
const status = run.status ?? "unknown";
|
|
586
|
+
const startedAt = run.startedAt ?? "-";
|
|
587
|
+
const endedAt = run.endedAt ?? "-";
|
|
588
|
+
return `<tr><td><code>${escapeHtml(runType)}</code></td><td>${escapeHtml(status)}</td><td><code>${escapeHtml(startedAt)}</code></td><td><code>${escapeHtml(endedAt)}</code></td></tr>`;
|
|
563
589
|
}
|
|
@@ -1,82 +1,77 @@
|
|
|
1
|
-
import { summarizeCurrentThread } from "./
|
|
1
|
+
import { summarizeCurrentThread } from "./run-reporting.js";
|
|
2
2
|
import { safeJsonParse } from "./utils.js";
|
|
3
3
|
export class IssueQueryService {
|
|
4
|
-
|
|
4
|
+
db;
|
|
5
5
|
codex;
|
|
6
|
-
|
|
7
|
-
constructor(
|
|
8
|
-
this.
|
|
6
|
+
runStatusProvider;
|
|
7
|
+
constructor(db, codex, runStatusProvider) {
|
|
8
|
+
this.db = db;
|
|
9
9
|
this.codex = codex;
|
|
10
|
-
this.
|
|
10
|
+
this.runStatusProvider = runStatusProvider;
|
|
11
11
|
}
|
|
12
12
|
async getIssueOverview(issueKey) {
|
|
13
|
-
const result = this.
|
|
14
|
-
if (!result)
|
|
13
|
+
const result = this.db.getIssueOverview(issueKey);
|
|
14
|
+
if (!result)
|
|
15
15
|
return undefined;
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const latestStageRun = this.stores.issueWorkflows.getLatestStageRunForIssue(result.issue.projectId, result.issue.linearIssueId);
|
|
16
|
+
const activeStatus = await this.runStatusProvider.getActiveRunStatus(issueKey);
|
|
17
|
+
const activeRun = activeStatus?.run ?? result.activeRun;
|
|
18
|
+
const latestRun = this.db.getLatestRunForIssue(result.issue.projectId, result.issue.linearIssueId);
|
|
20
19
|
let liveThread;
|
|
21
20
|
if (activeStatus?.liveThread) {
|
|
22
21
|
liveThread = activeStatus.liveThread;
|
|
23
22
|
}
|
|
24
|
-
else if (
|
|
25
|
-
liveThread = await this.codex.readThread(
|
|
23
|
+
else if (activeRun?.threadId) {
|
|
24
|
+
liveThread = await this.codex.readThread(activeRun.threadId, true).then(summarizeCurrentThread).catch(() => undefined);
|
|
26
25
|
}
|
|
27
26
|
return {
|
|
28
27
|
...result,
|
|
29
|
-
...(
|
|
30
|
-
...(
|
|
28
|
+
...(activeRun ? { activeRun } : {}),
|
|
29
|
+
...(latestRun ? { latestRun } : {}),
|
|
31
30
|
...(liveThread ? { liveThread } : {}),
|
|
32
31
|
};
|
|
33
32
|
}
|
|
34
33
|
async getIssueReport(issueKey) {
|
|
35
|
-
const issue = this.
|
|
36
|
-
if (!issue)
|
|
34
|
+
const issue = this.db.getTrackedIssueByKey(issueKey);
|
|
35
|
+
if (!issue)
|
|
37
36
|
return undefined;
|
|
38
|
-
}
|
|
39
37
|
return {
|
|
40
38
|
issue,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
...(
|
|
39
|
+
runs: this.db.listRunsForIssue(issue.projectId, issue.linearIssueId).map((run) => ({
|
|
40
|
+
run,
|
|
41
|
+
...(run.reportJson ? { report: JSON.parse(run.reportJson) } : {}),
|
|
44
42
|
})),
|
|
45
43
|
};
|
|
46
44
|
}
|
|
47
|
-
async
|
|
48
|
-
const issue = this.
|
|
49
|
-
if (!issue)
|
|
45
|
+
async getRunEvents(issueKey, runId) {
|
|
46
|
+
const issue = this.db.getTrackedIssueByKey(issueKey);
|
|
47
|
+
if (!issue)
|
|
50
48
|
return undefined;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!stageRun || stageRun.projectId !== issue.projectId || stageRun.linearIssueId !== issue.linearIssueId) {
|
|
49
|
+
const run = this.db.getRun(runId);
|
|
50
|
+
if (!run || run.projectId !== issue.projectId || run.linearIssueId !== issue.linearIssueId)
|
|
54
51
|
return undefined;
|
|
55
|
-
}
|
|
56
52
|
return {
|
|
57
53
|
issue,
|
|
58
|
-
|
|
59
|
-
events: this.
|
|
54
|
+
run,
|
|
55
|
+
events: this.db.listThreadEvents(runId).map((event) => ({
|
|
60
56
|
...event,
|
|
61
57
|
parsedEvent: safeJsonParse(event.eventJson),
|
|
62
58
|
})),
|
|
63
59
|
};
|
|
64
60
|
}
|
|
65
|
-
async
|
|
66
|
-
return await this.
|
|
61
|
+
async getActiveRunStatus(issueKey) {
|
|
62
|
+
return await this.runStatusProvider.getActiveRunStatus(issueKey);
|
|
67
63
|
}
|
|
68
64
|
async getPublicAgentSessionStatus(issueKey) {
|
|
69
65
|
const overview = await this.getIssueOverview(issueKey);
|
|
70
|
-
if (!overview)
|
|
66
|
+
if (!overview)
|
|
71
67
|
return undefined;
|
|
72
|
-
}
|
|
73
68
|
const report = await this.getIssueReport(issueKey);
|
|
74
69
|
return {
|
|
75
70
|
issue: overview.issue,
|
|
76
|
-
...(overview.
|
|
77
|
-
...(overview.
|
|
71
|
+
...(overview.activeRun ? { activeRun: overview.activeRun } : {}),
|
|
72
|
+
...(overview.latestRun ? { latestRun: overview.latestRun } : {}),
|
|
78
73
|
...(overview.liveThread ? { liveThread: overview.liveThread } : {}),
|
|
79
|
-
|
|
74
|
+
runs: report?.runs ?? [],
|
|
80
75
|
generatedAt: new Date().toISOString(),
|
|
81
76
|
};
|
|
82
77
|
}
|