patchrelay 0.1.0
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/LICENSE +21 -0
- package/README.md +271 -0
- package/config/patchrelay.example.json +5 -0
- package/dist/build-info.js +29 -0
- package/dist/build-info.json +6 -0
- package/dist/cli/data.js +461 -0
- package/dist/cli/formatters/json.js +3 -0
- package/dist/cli/formatters/text.js +119 -0
- package/dist/cli/index.js +761 -0
- package/dist/codex-app-server.js +353 -0
- package/dist/codex-types.js +1 -0
- package/dist/config-types.js +1 -0
- package/dist/config.js +494 -0
- package/dist/db/authoritative-ledger-store.js +437 -0
- package/dist/db/issue-workflow-store.js +690 -0
- package/dist/db/linear-installation-store.js +184 -0
- package/dist/db/migrations.js +183 -0
- package/dist/db/shared.js +101 -0
- package/dist/db/stage-event-store.js +33 -0
- package/dist/db/webhook-event-store.js +46 -0
- package/dist/db-ports.js +5 -0
- package/dist/db-types.js +1 -0
- package/dist/db.js +40 -0
- package/dist/file-permissions.js +40 -0
- package/dist/http.js +321 -0
- package/dist/index.js +69 -0
- package/dist/install.js +302 -0
- package/dist/installation-ports.js +1 -0
- package/dist/issue-query-service.js +68 -0
- package/dist/ledger-ports.js +1 -0
- package/dist/linear-client.js +338 -0
- package/dist/linear-oauth-service.js +131 -0
- package/dist/linear-oauth.js +154 -0
- package/dist/linear-types.js +1 -0
- package/dist/linear-workflow.js +78 -0
- package/dist/logging.js +62 -0
- package/dist/preflight.js +227 -0
- package/dist/project-resolution.js +51 -0
- package/dist/reconciliation-action-applier.js +55 -0
- package/dist/reconciliation-actions.js +1 -0
- package/dist/reconciliation-engine.js +312 -0
- package/dist/reconciliation-snapshot-builder.js +96 -0
- package/dist/reconciliation-types.js +1 -0
- package/dist/runtime-paths.js +89 -0
- package/dist/service-queue.js +49 -0
- package/dist/service-runtime.js +96 -0
- package/dist/service-stage-finalizer.js +348 -0
- package/dist/service-stage-runner.js +233 -0
- package/dist/service-webhook-processor.js +181 -0
- package/dist/service-webhooks.js +148 -0
- package/dist/service.js +139 -0
- package/dist/stage-agent-activity-publisher.js +33 -0
- package/dist/stage-event-ports.js +1 -0
- package/dist/stage-failure.js +92 -0
- package/dist/stage-launch.js +54 -0
- package/dist/stage-lifecycle-publisher.js +213 -0
- package/dist/stage-reporting.js +153 -0
- package/dist/stage-turn-input-dispatcher.js +102 -0
- package/dist/token-crypto.js +21 -0
- package/dist/types.js +5 -0
- package/dist/utils.js +163 -0
- package/dist/webhook-agent-session-handler.js +157 -0
- package/dist/webhook-archive.js +24 -0
- package/dist/webhook-comment-handler.js +89 -0
- package/dist/webhook-desired-stage-recorder.js +150 -0
- package/dist/webhook-event-ports.js +1 -0
- package/dist/webhook-installation-handler.js +57 -0
- package/dist/webhooks.js +301 -0
- package/dist/workflow-policy.js +42 -0
- package/dist/workflow-ports.js +1 -0
- package/dist/workflow-types.js +1 -0
- package/dist/worktree-manager.js +66 -0
- package/infra/patchrelay-reload.service +6 -0
- package/infra/patchrelay.path +11 -0
- package/infra/patchrelay.service +28 -0
- package/package.json +55 -0
- package/runtime.env.example +8 -0
- package/service.env.example +7 -0
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
function deriveTriggerEvent(payload) {
|
|
2
|
+
if (payload.type === "AgentSessionEvent") {
|
|
3
|
+
if (payload.action === "created") {
|
|
4
|
+
return "agentSessionCreated";
|
|
5
|
+
}
|
|
6
|
+
if (payload.action === "prompted") {
|
|
7
|
+
return "agentPrompted";
|
|
8
|
+
}
|
|
9
|
+
return "issueUpdated";
|
|
10
|
+
}
|
|
11
|
+
if (payload.type === "Issue") {
|
|
12
|
+
if (payload.action === "create") {
|
|
13
|
+
return "issueCreated";
|
|
14
|
+
}
|
|
15
|
+
if (payload.action === "remove") {
|
|
16
|
+
return "issueRemoved";
|
|
17
|
+
}
|
|
18
|
+
const updatedFields = new Set(Object.keys(payload.updatedFrom ?? {}));
|
|
19
|
+
if (updatedFields.has("labels")) {
|
|
20
|
+
return "labelChanged";
|
|
21
|
+
}
|
|
22
|
+
if (updatedFields.has("stateId") || updatedFields.has("state")) {
|
|
23
|
+
return "statusChanged";
|
|
24
|
+
}
|
|
25
|
+
if (updatedFields.has("assigneeId") || updatedFields.has("assignee")) {
|
|
26
|
+
return "assignmentChanged";
|
|
27
|
+
}
|
|
28
|
+
if (updatedFields.has("delegateId") || updatedFields.has("delegate")) {
|
|
29
|
+
return "delegateChanged";
|
|
30
|
+
}
|
|
31
|
+
return "issueUpdated";
|
|
32
|
+
}
|
|
33
|
+
if (payload.type === "Comment") {
|
|
34
|
+
if (payload.action === "create") {
|
|
35
|
+
return "commentCreated";
|
|
36
|
+
}
|
|
37
|
+
if (payload.action === "remove") {
|
|
38
|
+
return "commentRemoved";
|
|
39
|
+
}
|
|
40
|
+
return "commentUpdated";
|
|
41
|
+
}
|
|
42
|
+
if (payload.type === "PermissionChange") {
|
|
43
|
+
return "installationPermissionsChanged";
|
|
44
|
+
}
|
|
45
|
+
if (payload.type === "OAuthApp" && payload.action === "revoked") {
|
|
46
|
+
return "installationRevoked";
|
|
47
|
+
}
|
|
48
|
+
if (payload.type === "AppUserNotification") {
|
|
49
|
+
return "appUserNotification";
|
|
50
|
+
}
|
|
51
|
+
return "issueUpdated";
|
|
52
|
+
}
|
|
53
|
+
function asRecord(value) {
|
|
54
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
55
|
+
}
|
|
56
|
+
function getString(record, key) {
|
|
57
|
+
const value = record[key];
|
|
58
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
59
|
+
}
|
|
60
|
+
function getBoolean(record, key) {
|
|
61
|
+
const value = record[key];
|
|
62
|
+
return typeof value === "boolean" ? value : undefined;
|
|
63
|
+
}
|
|
64
|
+
function getStringArray(record, key) {
|
|
65
|
+
const value = record[key];
|
|
66
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.trim().length > 0) : [];
|
|
67
|
+
}
|
|
68
|
+
function extractLabelNames(record) {
|
|
69
|
+
const source = record.labels;
|
|
70
|
+
if (Array.isArray(source)) {
|
|
71
|
+
return source
|
|
72
|
+
.flatMap((entry) => {
|
|
73
|
+
if (typeof entry === "string") {
|
|
74
|
+
return [entry];
|
|
75
|
+
}
|
|
76
|
+
const entryRecord = asRecord(entry);
|
|
77
|
+
const name = entryRecord ? getString(entryRecord, "name") : undefined;
|
|
78
|
+
return name ? [name] : [];
|
|
79
|
+
})
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
const labelsRecord = asRecord(source);
|
|
83
|
+
const nodes = labelsRecord?.nodes;
|
|
84
|
+
if (Array.isArray(nodes)) {
|
|
85
|
+
return nodes
|
|
86
|
+
.flatMap((entry) => {
|
|
87
|
+
const entryRecord = asRecord(entry);
|
|
88
|
+
const name = entryRecord ? getString(entryRecord, "name") : undefined;
|
|
89
|
+
return name ? [name] : [];
|
|
90
|
+
})
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
function extractIssueMetadata(payload) {
|
|
96
|
+
const data = asRecord(payload.data);
|
|
97
|
+
if (!data) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const issueRecord = payload.type === "Issue"
|
|
101
|
+
? data
|
|
102
|
+
: payload.type === "AgentSessionEvent"
|
|
103
|
+
? (() => {
|
|
104
|
+
const sessionRecord = asRecord(data.agentSession) ?? data;
|
|
105
|
+
return asRecord(sessionRecord.issue) ?? asRecord(data.issue) ?? sessionRecord;
|
|
106
|
+
})()
|
|
107
|
+
: payload.type === "AppUserNotification"
|
|
108
|
+
? (() => {
|
|
109
|
+
const notificationRecord = asRecord(data.notification) ?? data;
|
|
110
|
+
return (asRecord(notificationRecord.issue) ??
|
|
111
|
+
asRecord(asRecord(notificationRecord.comment)?.issue) ??
|
|
112
|
+
asRecord(data.issue));
|
|
113
|
+
})()
|
|
114
|
+
: (() => {
|
|
115
|
+
const nestedIssue = asRecord(data.issue);
|
|
116
|
+
return nestedIssue ?? data;
|
|
117
|
+
})();
|
|
118
|
+
if (!issueRecord) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const id = getString(issueRecord, "id") ?? getString(data, "issueId");
|
|
122
|
+
if (!id) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const teamRecord = asRecord(issueRecord.team);
|
|
126
|
+
const identifier = getString(issueRecord, "identifier");
|
|
127
|
+
const title = getString(issueRecord, "title");
|
|
128
|
+
const url = getString(issueRecord, "url") ?? payload.url;
|
|
129
|
+
const delegateRecord = asRecord(issueRecord.delegate);
|
|
130
|
+
const teamId = getString(issueRecord, "teamId") ?? getString(teamRecord ?? {}, "id");
|
|
131
|
+
const teamKey = getString(teamRecord ?? {}, "key");
|
|
132
|
+
const stateRecord = asRecord(issueRecord.state);
|
|
133
|
+
const stateId = getString(issueRecord, "stateId") ?? getString(stateRecord ?? {}, "id");
|
|
134
|
+
const stateName = getString(stateRecord ?? {}, "name");
|
|
135
|
+
const stateType = getString(stateRecord ?? {}, "type");
|
|
136
|
+
const delegateId = getString(issueRecord, "delegateId") ?? getString(delegateRecord ?? {}, "id");
|
|
137
|
+
const delegateName = getString(delegateRecord ?? {}, "name");
|
|
138
|
+
return {
|
|
139
|
+
id,
|
|
140
|
+
...(identifier ? { identifier } : {}),
|
|
141
|
+
...(title ? { title } : {}),
|
|
142
|
+
...(url ? { url } : {}),
|
|
143
|
+
...(teamId ? { teamId } : {}),
|
|
144
|
+
...(teamKey ? { teamKey } : {}),
|
|
145
|
+
...(stateId ? { stateId } : {}),
|
|
146
|
+
...(stateName ? { stateName } : {}),
|
|
147
|
+
...(stateType ? { stateType } : {}),
|
|
148
|
+
...(delegateId ? { delegateId } : {}),
|
|
149
|
+
...(delegateName ? { delegateName } : {}),
|
|
150
|
+
labelNames: extractLabelNames(issueRecord),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function extractActorFromRecord(record) {
|
|
154
|
+
if (!record) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
const nestedUser = asRecord(record.user);
|
|
158
|
+
const id = getString(record, "id") ?? getString(record, "actorId") ?? getString(record, "userId") ?? getString(nestedUser ?? {}, "id");
|
|
159
|
+
const name = getString(record, "name") ?? getString(nestedUser ?? {}, "name");
|
|
160
|
+
const email = getString(record, "email") ?? getString(nestedUser ?? {}, "email");
|
|
161
|
+
const type = getString(record, "type") ?? getString(record, "__typename");
|
|
162
|
+
if (!id && !name && !email && !type) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
...(id ? { id } : {}),
|
|
167
|
+
...(name ? { name } : {}),
|
|
168
|
+
...(email ? { email } : {}),
|
|
169
|
+
...(type ? { type } : {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function extractActorMetadata(payload) {
|
|
173
|
+
const payloadActor = extractActorFromRecord(asRecord(payload.actor));
|
|
174
|
+
if (payloadActor) {
|
|
175
|
+
return payloadActor;
|
|
176
|
+
}
|
|
177
|
+
const data = asRecord(payload.data);
|
|
178
|
+
const fallbacks = [
|
|
179
|
+
extractActorFromRecord(asRecord(data?.actor)),
|
|
180
|
+
extractActorFromRecord(asRecord(data?.user)),
|
|
181
|
+
extractActorFromRecord(asRecord(data?.creator)),
|
|
182
|
+
extractActorFromRecord(asRecord(data?.createdBy)),
|
|
183
|
+
];
|
|
184
|
+
return fallbacks.find(Boolean);
|
|
185
|
+
}
|
|
186
|
+
function extractCommentMetadata(payload) {
|
|
187
|
+
if (payload.type !== "Comment") {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
const data = asRecord(payload.data);
|
|
191
|
+
if (!data) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
const id = getString(data, "id");
|
|
195
|
+
const body = getString(data, "body");
|
|
196
|
+
const userRecord = asRecord(data.user);
|
|
197
|
+
const userName = getString(userRecord ?? {}, "name");
|
|
198
|
+
if (!id) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
id,
|
|
203
|
+
...(body ? { body } : {}),
|
|
204
|
+
...(userName ? { userName } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function extractAgentSessionMetadata(payload) {
|
|
208
|
+
if (payload.type !== "AgentSessionEvent") {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
const data = asRecord(payload.data);
|
|
212
|
+
if (!data) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
const sessionRecord = asRecord(data.agentSession) ?? data;
|
|
216
|
+
const id = getString(sessionRecord, "id") ?? getString(data, "agentSessionId");
|
|
217
|
+
if (!id) {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
const agentActivity = asRecord(data.agentActivity);
|
|
221
|
+
const commentRecord = asRecord(data.comment) ?? asRecord(sessionRecord.comment);
|
|
222
|
+
const promptContext = getString(data, "promptContext") ?? getString(sessionRecord, "promptContext");
|
|
223
|
+
const promptBody = getString(agentActivity ?? {}, "body") ?? getString(commentRecord ?? {}, "body");
|
|
224
|
+
const issueCommentId = getString(commentRecord ?? {}, "id");
|
|
225
|
+
return {
|
|
226
|
+
id,
|
|
227
|
+
...(promptContext ? { promptContext } : {}),
|
|
228
|
+
...(promptBody ? { promptBody } : {}),
|
|
229
|
+
...(issueCommentId ? { issueCommentId } : {}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function extractInstallationMetadata(payload) {
|
|
233
|
+
const data = asRecord(payload.data);
|
|
234
|
+
if (!data) {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
if (payload.type === "PermissionChange") {
|
|
238
|
+
const organizationId = getString(data, "organizationId");
|
|
239
|
+
const oauthClientId = getString(data, "oauthClientId");
|
|
240
|
+
const appUserId = getString(data, "appUserId");
|
|
241
|
+
const canAccessAllPublicTeams = getBoolean(data, "canAccessAllPublicTeams");
|
|
242
|
+
return {
|
|
243
|
+
...(organizationId ? { organizationId } : {}),
|
|
244
|
+
...(oauthClientId ? { oauthClientId } : {}),
|
|
245
|
+
...(appUserId ? { appUserId } : {}),
|
|
246
|
+
...(canAccessAllPublicTeams !== undefined ? { canAccessAllPublicTeams } : {}),
|
|
247
|
+
addedTeamIds: getStringArray(data, "addedTeamIds"),
|
|
248
|
+
removedTeamIds: getStringArray(data, "removedTeamIds"),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (payload.type === "OAuthApp") {
|
|
252
|
+
const organizationId = getString(data, "organizationId");
|
|
253
|
+
const oauthClientId = getString(data, "oauthClientId");
|
|
254
|
+
return {
|
|
255
|
+
...(organizationId ? { organizationId } : {}),
|
|
256
|
+
...(oauthClientId ? { oauthClientId } : {}),
|
|
257
|
+
addedTeamIds: [],
|
|
258
|
+
removedTeamIds: [],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (payload.type === "AppUserNotification") {
|
|
262
|
+
const notificationRecord = asRecord(data.notification) ?? data;
|
|
263
|
+
const organizationId = getString(data, "organizationId");
|
|
264
|
+
const oauthClientId = getString(data, "oauthClientId");
|
|
265
|
+
const appUserId = getString(data, "appUserId");
|
|
266
|
+
const notificationType = getString(notificationRecord, "type");
|
|
267
|
+
return {
|
|
268
|
+
...(organizationId ? { organizationId } : {}),
|
|
269
|
+
...(oauthClientId ? { oauthClientId } : {}),
|
|
270
|
+
...(appUserId ? { appUserId } : {}),
|
|
271
|
+
...(notificationType ? { notificationType } : {}),
|
|
272
|
+
addedTeamIds: [],
|
|
273
|
+
removedTeamIds: [],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
export function normalizeWebhook(params) {
|
|
279
|
+
const issue = extractIssueMetadata(params.payload);
|
|
280
|
+
const comment = extractCommentMetadata(params.payload);
|
|
281
|
+
const agentSession = extractAgentSessionMetadata(params.payload);
|
|
282
|
+
const installation = extractInstallationMetadata(params.payload);
|
|
283
|
+
const actor = extractActorMetadata(params.payload);
|
|
284
|
+
if (!issue && !installation) {
|
|
285
|
+
throw new Error(`Unable to determine issue metadata from ${params.payload.type} webhook`);
|
|
286
|
+
}
|
|
287
|
+
const triggerEvent = deriveTriggerEvent(params.payload);
|
|
288
|
+
return {
|
|
289
|
+
webhookId: params.webhookId,
|
|
290
|
+
entityType: params.payload.type,
|
|
291
|
+
action: params.payload.action,
|
|
292
|
+
triggerEvent,
|
|
293
|
+
eventType: `${params.payload.type}.${params.payload.action}`,
|
|
294
|
+
...(actor ? { actor } : {}),
|
|
295
|
+
...(issue ? { issue } : {}),
|
|
296
|
+
...(comment ? { comment } : {}),
|
|
297
|
+
...(agentSession ? { agentSession } : {}),
|
|
298
|
+
...(installation ? { installation } : {}),
|
|
299
|
+
payload: params.payload,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function normalize(value) {
|
|
2
|
+
const trimmed = value?.trim();
|
|
3
|
+
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
4
|
+
}
|
|
5
|
+
function extractIssuePrefix(identifier) {
|
|
6
|
+
const value = identifier?.trim();
|
|
7
|
+
if (!value) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const [prefix] = value.split("-", 1);
|
|
11
|
+
return prefix ? prefix.toUpperCase() : undefined;
|
|
12
|
+
}
|
|
13
|
+
export function resolveWorkflow(project, stateName) {
|
|
14
|
+
const normalized = normalize(stateName);
|
|
15
|
+
if (!normalized) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
return project.workflows.find((workflow) => normalize(workflow.whenState) === normalized);
|
|
19
|
+
}
|
|
20
|
+
export function resolveWorkflowStage(project, stateName) {
|
|
21
|
+
return resolveWorkflow(project, stateName)?.id;
|
|
22
|
+
}
|
|
23
|
+
export function resolveWorkflowById(project, workflowId) {
|
|
24
|
+
const normalized = normalize(workflowId);
|
|
25
|
+
if (!normalized) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return project.workflows.find((workflow) => normalize(workflow.id) === normalized);
|
|
29
|
+
}
|
|
30
|
+
export function listRunnableStates(project) {
|
|
31
|
+
return project.workflows.map((workflow) => workflow.whenState);
|
|
32
|
+
}
|
|
33
|
+
export function matchesProject(issue, project) {
|
|
34
|
+
const issuePrefix = extractIssuePrefix(issue.identifier);
|
|
35
|
+
const teamCandidates = [issue.teamId, issue.teamKey].filter((value) => Boolean(value));
|
|
36
|
+
const labelNames = new Set(issue.labelNames.map((label) => label.toLowerCase()));
|
|
37
|
+
const matchesPrefix = project.issueKeyPrefixes.length === 0 ||
|
|
38
|
+
(issuePrefix ? project.issueKeyPrefixes.map((value) => value.toUpperCase()).includes(issuePrefix) : false);
|
|
39
|
+
const matchesTeam = project.linearTeamIds.length === 0 || teamCandidates.some((candidate) => project.linearTeamIds.includes(candidate));
|
|
40
|
+
const matchesLabel = project.allowLabels.length === 0 || project.allowLabels.some((label) => labelNames.has(label.toLowerCase()));
|
|
41
|
+
return matchesPrefix && matchesTeam && matchesLabel;
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ensureDir, execCommand } from "./utils.js";
|
|
4
|
+
export class WorktreeManager {
|
|
5
|
+
config;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
}
|
|
9
|
+
async ensureIssueWorktree(repoPath, worktreeRoot, worktreePath, branchName) {
|
|
10
|
+
if (existsSync(worktreePath)) {
|
|
11
|
+
await this.assertTrustedExistingWorktree(repoPath, worktreeRoot, worktreePath);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
await ensureDir(path.dirname(worktreePath));
|
|
15
|
+
await execCommand(this.config.runner.gitBin, ["-C", repoPath, "worktree", "add", "--force", "-B", branchName, worktreePath, "HEAD"], {
|
|
16
|
+
timeoutMs: 120_000,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async assertTrustedExistingWorktree(repoPath, worktreeRoot, worktreePath) {
|
|
20
|
+
const worktreeStats = lstatSync(worktreePath);
|
|
21
|
+
if (worktreeStats.isSymbolicLink()) {
|
|
22
|
+
throw new Error(`Refusing to reuse symlinked worktree path: ${worktreePath}`);
|
|
23
|
+
}
|
|
24
|
+
if (!worktreeStats.isDirectory()) {
|
|
25
|
+
throw new Error(`Refusing to reuse non-directory worktree path: ${worktreePath}`);
|
|
26
|
+
}
|
|
27
|
+
const resolvedRoot = realpathSync(worktreeRoot);
|
|
28
|
+
const resolvedWorktree = realpathSync(worktreePath);
|
|
29
|
+
if (!isPathWithinRoot(resolvedRoot, resolvedWorktree)) {
|
|
30
|
+
throw new Error(`Refusing to reuse worktree outside configured root: ${worktreePath}`);
|
|
31
|
+
}
|
|
32
|
+
const listedWorktrees = await this.listRegisteredWorktrees(repoPath);
|
|
33
|
+
if (!listedWorktrees.has(resolvedWorktree)) {
|
|
34
|
+
throw new Error(`Refusing to reuse unregistered worktree path: ${worktreePath}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async listRegisteredWorktrees(repoPath) {
|
|
38
|
+
const result = await execCommand(this.config.runner.gitBin, ["-C", repoPath, "worktree", "list", "--porcelain"], {
|
|
39
|
+
timeoutMs: 120_000,
|
|
40
|
+
});
|
|
41
|
+
if (result.exitCode !== 0) {
|
|
42
|
+
throw new Error(`Unable to verify registered worktrees for ${repoPath}`);
|
|
43
|
+
}
|
|
44
|
+
const worktrees = new Set();
|
|
45
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
46
|
+
if (!line.startsWith("worktree ")) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const listedPath = line.slice("worktree ".length).trim();
|
|
50
|
+
if (!listedPath) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
worktrees.add(realpathSync(listedPath));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
worktrees.add(path.resolve(listedPath));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return worktrees;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function isPathWithinRoot(rootPath, candidatePath) {
|
|
64
|
+
const relative = path.relative(rootPath, candidatePath);
|
|
65
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
66
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Watch PatchRelay config and env changes
|
|
3
|
+
|
|
4
|
+
[Path]
|
|
5
|
+
Unit=patchrelay-reload.service
|
|
6
|
+
PathChanged=/home/your-user/.config/patchrelay/runtime.env
|
|
7
|
+
PathChanged=/home/your-user/.config/patchrelay/service.env
|
|
8
|
+
PathChanged=/home/your-user/.config/patchrelay/patchrelay.json
|
|
9
|
+
|
|
10
|
+
[Install]
|
|
11
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=PatchRelay (systemd user service)
|
|
3
|
+
After=network-online.target
|
|
4
|
+
Wants=network-online.target
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=simple
|
|
8
|
+
WorkingDirectory=/home/your-user
|
|
9
|
+
EnvironmentFile=-/home/your-user/.config/patchrelay/runtime.env
|
|
10
|
+
EnvironmentFile=/home/your-user/.config/patchrelay/service.env
|
|
11
|
+
Environment=NODE_ENV=production
|
|
12
|
+
Environment=PATCHRELAY_CONFIG=/home/your-user/.config/patchrelay/patchrelay.json
|
|
13
|
+
Environment=PATH=/home/your-user/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
14
|
+
ExecStart=/usr/bin/env patchrelay serve
|
|
15
|
+
Restart=on-failure
|
|
16
|
+
RestartSec=5
|
|
17
|
+
NoNewPrivileges=true
|
|
18
|
+
PrivateTmp=true
|
|
19
|
+
ProtectSystem=full
|
|
20
|
+
ProtectHome=false
|
|
21
|
+
ReadWritePaths=/home/your-user/.config/patchrelay /home/your-user/.local/state/patchrelay /home/your-user/.local/share/patchrelay
|
|
22
|
+
|
|
23
|
+
# PatchRelay is intended to run as your real user so Codex inherits your
|
|
24
|
+
# existing git, SSH, and local tool permissions.
|
|
25
|
+
# Add your managed repository roots to ReadWritePaths if you keep hardening enabled.
|
|
26
|
+
|
|
27
|
+
[Install]
|
|
28
|
+
WantedBy=default.target
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "patchrelay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/krasnoperov/patchrelay.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"patchrelay": "dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"runtime.env.example",
|
|
19
|
+
"service.env.example",
|
|
20
|
+
"config/patchrelay.example.json",
|
|
21
|
+
"infra/patchrelay.service",
|
|
22
|
+
"infra/patchrelay-reload.service",
|
|
23
|
+
"infra/patchrelay.path"
|
|
24
|
+
],
|
|
25
|
+
"description": "Self-hosted harness for Linear-driven Codex work with durable issue worktrees, staged runs, and inspection.",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=24.0.0"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "node --watch --experimental-transform-types src/index.ts",
|
|
31
|
+
"build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/index.js && node scripts/write-build-info.mjs",
|
|
32
|
+
"prepack": "npm run build",
|
|
33
|
+
"start": "node dist/index.js serve",
|
|
34
|
+
"doctor": "node dist/index.js doctor",
|
|
35
|
+
"restart": "node dist/index.js restart-service",
|
|
36
|
+
"lint": "eslint .",
|
|
37
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"test": "node --experimental-transform-types --test test/**/*.test.ts",
|
|
39
|
+
"ci": "npm run lint && npm run check && npm test && npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"fastify": "^5.8.2",
|
|
43
|
+
"fastify-raw-body": "^5.0.0",
|
|
44
|
+
"pino": "^10.3.1",
|
|
45
|
+
"pino-logfmt": "^1.1.3",
|
|
46
|
+
"zod": "^4.3.6"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@eslint/js": "^10.0.1",
|
|
50
|
+
"@types/node": "^24.12.0",
|
|
51
|
+
"eslint": "^10.0.3",
|
|
52
|
+
"typescript": "^5.9.3",
|
|
53
|
+
"typescript-eslint": "^8.57.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# PatchRelay runtime overrides.
|
|
2
|
+
# These are non-secret process-level overrides that both the service and local
|
|
3
|
+
# CLI may need to read.
|
|
4
|
+
|
|
5
|
+
# PATCHRELAY_CONFIG=$HOME/.config/patchrelay/patchrelay.json
|
|
6
|
+
# PATCHRELAY_DB_PATH=$HOME/.local/state/patchrelay/patchrelay.sqlite
|
|
7
|
+
# PATCHRELAY_LOG_LEVEL=info
|
|
8
|
+
# PATCHRELAY_LOG_FILE=$HOME/.local/state/patchrelay/patchrelay.log
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# PatchRelay service secrets.
|
|
2
|
+
# Keep these values at the machine level for this PatchRelay instance.
|
|
3
|
+
|
|
4
|
+
LINEAR_WEBHOOK_SECRET=replace-with-linear-webhook-secret
|
|
5
|
+
PATCHRELAY_TOKEN_ENCRYPTION_KEY=replace-with-long-random-secret
|
|
6
|
+
LINEAR_OAUTH_CLIENT_ID=replace-with-linear-oauth-client-id
|
|
7
|
+
LINEAR_OAUTH_CLIENT_SECRET=replace-with-linear-oauth-client-secret
|