patchrelay 0.26.0 → 0.29.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 +83 -31
- package/dist/agent-session-plan.js +0 -7
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +22 -18
- package/dist/cli/commands/feed.js +1 -1
- package/dist/cli/commands/issues.js +44 -4
- package/dist/cli/commands/linear.js +67 -0
- package/dist/cli/commands/repo.js +213 -0
- package/dist/cli/commands/setup.js +140 -21
- package/dist/cli/connect-flow.js +5 -3
- package/dist/cli/formatters/text.js +1 -1
- package/dist/cli/help.js +134 -63
- package/dist/cli/index.js +166 -188
- package/dist/cli/interactive.js +25 -0
- package/dist/cli/operator-client.js +11 -0
- package/dist/cli/service-commands.js +11 -4
- package/dist/cli/watch/App.js +1 -1
- package/dist/cli/watch/FactoryStateGraph.js +31 -0
- package/dist/cli/watch/FeedView.js +3 -2
- package/dist/cli/watch/FreshnessBadge.js +13 -0
- package/dist/cli/watch/IssueDetailView.js +9 -2
- package/dist/cli/watch/IssueListView.js +2 -2
- package/dist/cli/watch/IssueRow.js +9 -11
- package/dist/cli/watch/QueueObservationView.js +15 -0
- package/dist/cli/watch/StateHistoryView.js +0 -1
- package/dist/cli/watch/StatusBar.js +5 -2
- package/dist/cli/watch/format-utils.js +7 -0
- package/dist/cli/watch/freshness.js +30 -0
- package/dist/cli/watch/state-visualization.js +147 -0
- package/dist/cli/watch/theme.js +6 -7
- package/dist/cli/watch/use-watch-stream.js +5 -2
- package/dist/cli/watch/watch-state.js +9 -5
- package/dist/config.js +129 -36
- package/dist/db/linear-installation-store.js +23 -0
- package/dist/db/migrations.js +42 -0
- package/dist/db/repository-link-store.js +103 -0
- package/dist/db.js +61 -11
- package/dist/factory-state.js +1 -5
- package/dist/github-webhook-handler.js +115 -46
- package/dist/github-webhooks.js +4 -0
- package/dist/http.js +162 -0
- package/dist/install.js +93 -13
- package/dist/issue-query-service.js +34 -1
- package/dist/linear-client.js +80 -25
- package/dist/merge-queue-incident.js +104 -0
- package/dist/merge-queue-protocol.js +54 -0
- package/dist/preflight.js +28 -1
- package/dist/repository-linking.js +42 -0
- package/dist/run-orchestrator.js +197 -21
- package/dist/runtime-paths.js +0 -8
- package/dist/service.js +94 -49
- package/package.json +8 -7
- package/dist/cli/commands/connect.js +0 -54
- package/dist/cli/commands/project.js +0 -146
- package/dist/merge-queue.js +0 -200
- package/infra/patchrelay-reload.service +0 -6
- package/infra/patchrelay.path +0 -13
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
|
|
2
|
+
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
1
3
|
import { extractStageSummary, summarizeCurrentThread } from "./run-reporting.js";
|
|
2
4
|
import { safeJsonParse } from "./utils.js";
|
|
3
5
|
export class IssueQueryService {
|
|
6
|
+
config;
|
|
4
7
|
db;
|
|
5
8
|
codex;
|
|
6
9
|
runStatusProvider;
|
|
7
|
-
constructor(db, codex, runStatusProvider) {
|
|
10
|
+
constructor(config, db, codex, runStatusProvider) {
|
|
11
|
+
this.config = config;
|
|
8
12
|
this.db = db;
|
|
9
13
|
this.codex = codex;
|
|
10
14
|
this.runStatusProvider = runStatusProvider;
|
|
@@ -13,6 +17,7 @@ export class IssueQueryService {
|
|
|
13
17
|
const result = this.db.getIssueOverview(issueKey);
|
|
14
18
|
if (!result)
|
|
15
19
|
return undefined;
|
|
20
|
+
const issueRecord = this.db.getIssueByKey(issueKey);
|
|
16
21
|
const activeStatus = await this.runStatusProvider.getActiveRunStatus(issueKey);
|
|
17
22
|
const activeRun = activeStatus?.run ?? result.activeRun;
|
|
18
23
|
const latestRun = this.db.getLatestRunForIssue(result.issue.projectId, result.issue.linearIssueId);
|
|
@@ -25,6 +30,7 @@ export class IssueQueryService {
|
|
|
25
30
|
}
|
|
26
31
|
return {
|
|
27
32
|
...result,
|
|
33
|
+
issue: issueRecord ? { ...result.issue, queueProtocol: this.buildQueueProtocol(issueRecord.projectId, issueRecord) } : result.issue,
|
|
28
34
|
...(activeRun ? { activeRun } : {}),
|
|
29
35
|
...(latestRun ? { latestRun } : {}),
|
|
30
36
|
...(liveThread ? { liveThread } : {}),
|
|
@@ -97,6 +103,7 @@ export class IssueQueryService {
|
|
|
97
103
|
ciRepairAttempts: fullIssue?.ciRepairAttempts ?? 0,
|
|
98
104
|
queueRepairAttempts: fullIssue?.queueRepairAttempts ?? 0,
|
|
99
105
|
reviewFixAttempts: fullIssue?.reviewFixAttempts ?? 0,
|
|
106
|
+
...(fullIssue ? { queueProtocol: this.buildQueueProtocol(fullIssue.projectId, fullIssue) } : {}),
|
|
100
107
|
},
|
|
101
108
|
runs,
|
|
102
109
|
feedEvents,
|
|
@@ -127,13 +134,39 @@ export class IssueQueryService {
|
|
|
127
134
|
...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
|
|
128
135
|
...(issueRecord?.prCheckStatus ? { prCheckStatus: issueRecord.prCheckStatus } : {}),
|
|
129
136
|
...(issueRecord ? { ciRepairAttempts: issueRecord.ciRepairAttempts, queueRepairAttempts: issueRecord.queueRepairAttempts } : {}),
|
|
137
|
+
...(issueRecord ? { queueProtocol: this.buildQueueProtocol(issueRecord.projectId, issueRecord) } : {}),
|
|
130
138
|
},
|
|
131
139
|
...(overview.activeRun ? { activeRun: overview.activeRun } : {}),
|
|
132
140
|
...(overview.latestRun ? { latestRun: overview.latestRun } : {}),
|
|
133
141
|
...(overview.liveThread ? { liveThread: overview.liveThread } : {}),
|
|
134
142
|
...(latestRunReport ? { latestReportSummary: extractStageSummary(latestRunReport) } : {}),
|
|
143
|
+
feedEvents: this.db.operatorFeed.list({ issueKey, limit: 500 }),
|
|
144
|
+
activeRunId: issueRecord?.activeRunId ?? null,
|
|
135
145
|
runs: report?.runs ?? [],
|
|
136
146
|
generatedAt: new Date().toISOString(),
|
|
137
147
|
};
|
|
138
148
|
}
|
|
149
|
+
buildQueueProtocol(projectId, issue) {
|
|
150
|
+
const project = this.config.projects.find((entry) => entry.id === projectId);
|
|
151
|
+
const protocol = resolveMergeQueueProtocol(project);
|
|
152
|
+
const queueIncident = issue.lastQueueIncidentJson
|
|
153
|
+
? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
|
|
154
|
+
: undefined;
|
|
155
|
+
return {
|
|
156
|
+
repoFullName: protocol.repoFullName,
|
|
157
|
+
baseBranch: protocol.baseBranch,
|
|
158
|
+
admissionLabel: protocol.admissionLabel,
|
|
159
|
+
evictionCheckName: protocol.evictionCheckName,
|
|
160
|
+
prNumber: issue.prNumber ?? null,
|
|
161
|
+
lastFailureSource: issue.lastGitHubFailureSource ?? null,
|
|
162
|
+
lastFailureCheckName: issue.lastGitHubFailureCheckName ?? null,
|
|
163
|
+
lastFailureCheckUrl: issue.lastGitHubFailureCheckUrl ?? null,
|
|
164
|
+
lastFailureAt: issue.lastGitHubFailureAt ?? null,
|
|
165
|
+
lastQueueSignalAt: issue.lastQueueSignalAt ?? null,
|
|
166
|
+
lastIncidentId: queueIncident?.incidentId ?? null,
|
|
167
|
+
lastIncidentUrl: queueIncident?.incidentUrl ?? null,
|
|
168
|
+
lastIncidentFailureClass: queueIncident?.incidentContext?.failureClass ?? null,
|
|
169
|
+
lastIncidentSummary: queueIncident?.incidentSummary ?? null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
139
172
|
}
|
package/dist/linear-client.js
CHANGED
|
@@ -269,6 +269,58 @@ export class LinearGraphqlClient {
|
|
|
269
269
|
...(response.viewer?.name ? { actorName: response.viewer.name } : {}),
|
|
270
270
|
};
|
|
271
271
|
}
|
|
272
|
+
async getWorkspaceCatalog() {
|
|
273
|
+
const response = await this.request(`
|
|
274
|
+
query PatchRelayWorkspaceCatalog {
|
|
275
|
+
organization {
|
|
276
|
+
id
|
|
277
|
+
name
|
|
278
|
+
urlKey
|
|
279
|
+
}
|
|
280
|
+
viewer {
|
|
281
|
+
id
|
|
282
|
+
name
|
|
283
|
+
}
|
|
284
|
+
teams {
|
|
285
|
+
nodes {
|
|
286
|
+
id
|
|
287
|
+
key
|
|
288
|
+
name
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
projects {
|
|
292
|
+
nodes {
|
|
293
|
+
id
|
|
294
|
+
name
|
|
295
|
+
teams {
|
|
296
|
+
nodes {
|
|
297
|
+
id
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
`, {});
|
|
304
|
+
return {
|
|
305
|
+
workspace: {
|
|
306
|
+
...(response.organization?.id ? { workspaceId: response.organization.id } : {}),
|
|
307
|
+
...(response.organization?.name ? { workspaceName: response.organization.name } : {}),
|
|
308
|
+
...(response.organization?.urlKey ? { workspaceKey: response.organization.urlKey } : {}),
|
|
309
|
+
...(response.viewer?.id ? { actorId: response.viewer.id } : {}),
|
|
310
|
+
...(response.viewer?.name ? { actorName: response.viewer.name } : {}),
|
|
311
|
+
},
|
|
312
|
+
teams: (response.teams?.nodes ?? []).map((team) => ({
|
|
313
|
+
id: team.id,
|
|
314
|
+
...(team.key ? { key: team.key } : {}),
|
|
315
|
+
...(team.name ? { name: team.name } : {}),
|
|
316
|
+
})),
|
|
317
|
+
projects: (response.projects?.nodes ?? []).map((project) => ({
|
|
318
|
+
id: project.id,
|
|
319
|
+
...(project.name ? { name: project.name } : {}),
|
|
320
|
+
teamIds: (project.teams?.nodes ?? []).map((team) => team.id),
|
|
321
|
+
})),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
272
324
|
async request(query, variables) {
|
|
273
325
|
const response = await fetch(this.options.graphqlUrl, {
|
|
274
326
|
method: "POST",
|
|
@@ -349,34 +401,37 @@ export class DatabaseBackedLinearClientProvider {
|
|
|
349
401
|
async forProject(projectId) {
|
|
350
402
|
const link = this.db.linearInstallations.getProjectInstallation(projectId);
|
|
351
403
|
if (link) {
|
|
352
|
-
|
|
353
|
-
if (!installation) {
|
|
354
|
-
return undefined;
|
|
355
|
-
}
|
|
356
|
-
const encryptionKey = this.config.linear.tokenEncryptionKey;
|
|
357
|
-
let accessToken = decryptSecret(installation.accessTokenCiphertext, encryptionKey);
|
|
358
|
-
const refreshToken = installation.refreshTokenCiphertext
|
|
359
|
-
? decryptSecret(installation.refreshTokenCiphertext, encryptionKey)
|
|
360
|
-
: undefined;
|
|
361
|
-
if (shouldRefreshToken(installation.expiresAt) && refreshToken) {
|
|
362
|
-
const refreshed = await refreshLinearOAuthToken(this.config, refreshToken);
|
|
363
|
-
accessToken = refreshed.accessToken;
|
|
364
|
-
this.db.linearInstallations.updateLinearInstallationTokens(installation.id, {
|
|
365
|
-
accessTokenCiphertext: encryptSecret(refreshed.accessToken, encryptionKey),
|
|
366
|
-
...(refreshed.refreshToken
|
|
367
|
-
? { refreshTokenCiphertext: encryptSecret(refreshed.refreshToken, encryptionKey) }
|
|
368
|
-
: {}),
|
|
369
|
-
scopesJson: JSON.stringify(refreshed.scopes),
|
|
370
|
-
...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}),
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
return new LinearGraphqlClient({
|
|
374
|
-
accessToken,
|
|
375
|
-
graphqlUrl: this.config.linear.graphqlUrl,
|
|
376
|
-
}, this.logger);
|
|
404
|
+
return await this.forInstallationId(link.installationId);
|
|
377
405
|
}
|
|
378
406
|
return undefined;
|
|
379
407
|
}
|
|
408
|
+
async forInstallationId(installationId) {
|
|
409
|
+
const installation = this.db.linearInstallations.getLinearInstallation(installationId);
|
|
410
|
+
if (!installation) {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
const encryptionKey = this.config.linear.tokenEncryptionKey;
|
|
414
|
+
let accessToken = decryptSecret(installation.accessTokenCiphertext, encryptionKey);
|
|
415
|
+
const refreshToken = installation.refreshTokenCiphertext
|
|
416
|
+
? decryptSecret(installation.refreshTokenCiphertext, encryptionKey)
|
|
417
|
+
: undefined;
|
|
418
|
+
if (shouldRefreshToken(installation.expiresAt) && refreshToken) {
|
|
419
|
+
const refreshed = await refreshLinearOAuthToken(this.config, refreshToken);
|
|
420
|
+
accessToken = refreshed.accessToken;
|
|
421
|
+
this.db.linearInstallations.updateLinearInstallationTokens(installation.id, {
|
|
422
|
+
accessTokenCiphertext: encryptSecret(refreshed.accessToken, encryptionKey),
|
|
423
|
+
...(refreshed.refreshToken
|
|
424
|
+
? { refreshTokenCiphertext: encryptSecret(refreshed.refreshToken, encryptionKey) }
|
|
425
|
+
: {}),
|
|
426
|
+
scopesJson: JSON.stringify(refreshed.scopes),
|
|
427
|
+
...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return new LinearGraphqlClient({
|
|
431
|
+
accessToken,
|
|
432
|
+
graphqlUrl: this.config.linear.graphqlUrl,
|
|
433
|
+
}, this.logger);
|
|
434
|
+
}
|
|
380
435
|
}
|
|
381
436
|
function shouldRefreshToken(expiresAt) {
|
|
382
437
|
if (!expiresAt) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { safeJsonParse } from "./utils.js";
|
|
2
|
+
export function buildQueueRepairContextFromEvent(event) {
|
|
3
|
+
const payload = parseQueueEvictionPayload(event.checkOutputText);
|
|
4
|
+
const incidentUrl = event.checkDetailsUrl ?? payload?.incidentUrl;
|
|
5
|
+
return {
|
|
6
|
+
failureReason: "queue_eviction",
|
|
7
|
+
...(event.checkName ? { checkName: event.checkName } : {}),
|
|
8
|
+
...(event.checkUrl ? { checkUrl: event.checkUrl } : {}),
|
|
9
|
+
...(payload?.incidentId ? { incidentId: payload.incidentId } : {}),
|
|
10
|
+
...(incidentUrl ? { incidentUrl } : {}),
|
|
11
|
+
...(event.checkOutputTitle ? { incidentTitle: event.checkOutputTitle } : {}),
|
|
12
|
+
...(event.checkOutputSummary ? { incidentSummary: event.checkOutputSummary } : {}),
|
|
13
|
+
...(payload?.incidentContext ? { incidentContext: payload.incidentContext } : {}),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function parseStoredQueueRepairContext(json) {
|
|
17
|
+
if (!json)
|
|
18
|
+
return undefined;
|
|
19
|
+
const parsed = safeJsonParse(json);
|
|
20
|
+
if (!parsed || typeof parsed !== "object")
|
|
21
|
+
return undefined;
|
|
22
|
+
if (parsed.failureReason !== "queue_eviction")
|
|
23
|
+
return undefined;
|
|
24
|
+
const incidentContext = normalizeIncidentContext(parsed.incidentContext);
|
|
25
|
+
return {
|
|
26
|
+
failureReason: "queue_eviction",
|
|
27
|
+
...(typeof parsed.checkName === "string" ? { checkName: parsed.checkName } : {}),
|
|
28
|
+
...(typeof parsed.checkUrl === "string" ? { checkUrl: parsed.checkUrl } : {}),
|
|
29
|
+
...(typeof parsed.incidentId === "string" ? { incidentId: parsed.incidentId } : {}),
|
|
30
|
+
...(typeof parsed.incidentUrl === "string" ? { incidentUrl: parsed.incidentUrl } : {}),
|
|
31
|
+
...(typeof parsed.incidentTitle === "string" ? { incidentTitle: parsed.incidentTitle } : {}),
|
|
32
|
+
...(typeof parsed.incidentSummary === "string" ? { incidentSummary: parsed.incidentSummary } : {}),
|
|
33
|
+
...(incidentContext ? { incidentContext } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function parseQueueEvictionPayload(text) {
|
|
37
|
+
if (!text)
|
|
38
|
+
return undefined;
|
|
39
|
+
const parsed = safeJsonParse(text);
|
|
40
|
+
if (!parsed || typeof parsed !== "object")
|
|
41
|
+
return undefined;
|
|
42
|
+
const incidentContext = normalizeIncidentContext(parsed.incidentContext ?? parsed);
|
|
43
|
+
if (!incidentContext && typeof parsed.incidentId !== "string" && typeof parsed.incidentUrl !== "string") {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
...(typeof parsed.incidentId === "string" ? { incidentId: parsed.incidentId } : {}),
|
|
48
|
+
...(typeof parsed.incidentUrl === "string" ? { incidentUrl: parsed.incidentUrl } : {}),
|
|
49
|
+
...(incidentContext ? { incidentContext } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function normalizeIncidentContext(value) {
|
|
53
|
+
if (!value || typeof value !== "object")
|
|
54
|
+
return undefined;
|
|
55
|
+
const record = value;
|
|
56
|
+
const failedChecks = Array.isArray(record.failedChecks)
|
|
57
|
+
? record.failedChecks
|
|
58
|
+
.filter((entry) => entry && typeof entry === "object")
|
|
59
|
+
.map((entry) => {
|
|
60
|
+
const check = entry;
|
|
61
|
+
if (typeof check.name !== "string" || typeof check.conclusion !== "string")
|
|
62
|
+
return undefined;
|
|
63
|
+
return {
|
|
64
|
+
name: check.name,
|
|
65
|
+
conclusion: check.conclusion,
|
|
66
|
+
...(typeof check.url === "string" ? { url: check.url } : {}),
|
|
67
|
+
};
|
|
68
|
+
})
|
|
69
|
+
.filter((entry) => Boolean(entry))
|
|
70
|
+
: undefined;
|
|
71
|
+
const retryHistory = Array.isArray(record.retryHistory)
|
|
72
|
+
? record.retryHistory
|
|
73
|
+
.filter((entry) => entry && typeof entry === "object")
|
|
74
|
+
.map((entry) => {
|
|
75
|
+
const retry = entry;
|
|
76
|
+
if (typeof retry.at !== "string" || typeof retry.baseSha !== "string" || typeof retry.outcome !== "string") {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
at: retry.at,
|
|
81
|
+
baseSha: retry.baseSha,
|
|
82
|
+
outcome: retry.outcome,
|
|
83
|
+
};
|
|
84
|
+
})
|
|
85
|
+
.filter((entry) => Boolean(entry))
|
|
86
|
+
: undefined;
|
|
87
|
+
const conflictFiles = Array.isArray(record.conflictFiles)
|
|
88
|
+
? record.conflictFiles.filter((entry) => typeof entry === "string")
|
|
89
|
+
: undefined;
|
|
90
|
+
const normalized = {
|
|
91
|
+
...(typeof record.version === "number" ? { version: record.version } : {}),
|
|
92
|
+
...(typeof record.failureClass === "string" ? { failureClass: record.failureClass } : {}),
|
|
93
|
+
...(typeof record.baseSha === "string" ? { baseSha: record.baseSha } : {}),
|
|
94
|
+
...(typeof record.prHeadSha === "string" ? { prHeadSha: record.prHeadSha } : {}),
|
|
95
|
+
...(typeof record.queuePosition === "number" ? { queuePosition: record.queuePosition } : {}),
|
|
96
|
+
...(typeof record.baseBranch === "string" ? { baseBranch: record.baseBranch } : {}),
|
|
97
|
+
...(typeof record.branch === "string" ? { branch: record.branch } : {}),
|
|
98
|
+
...(typeof record.issueKey === "string" || record.issueKey === null ? { issueKey: record.issueKey } : {}),
|
|
99
|
+
...(conflictFiles?.length ? { conflictFiles } : {}),
|
|
100
|
+
...(failedChecks?.length ? { failedChecks } : {}),
|
|
101
|
+
...(retryHistory?.length ? { retryHistory } : {}),
|
|
102
|
+
};
|
|
103
|
+
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
104
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execCommand } from "./utils.js";
|
|
2
|
+
export const DEFAULT_MERGE_QUEUE_LABEL = "queue";
|
|
3
|
+
export const DEFAULT_MERGE_QUEUE_CHECK_NAME = "merge-steward/queue";
|
|
4
|
+
export function resolveMergeQueueProtocol(project) {
|
|
5
|
+
return {
|
|
6
|
+
repoFullName: project?.github?.repoFullName,
|
|
7
|
+
baseBranch: project?.github?.baseBranch,
|
|
8
|
+
admissionLabel: project?.github?.mergeQueueLabel ?? DEFAULT_MERGE_QUEUE_LABEL,
|
|
9
|
+
evictionCheckName: project?.github?.mergeQueueCheckName ?? DEFAULT_MERGE_QUEUE_CHECK_NAME,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export async function requestMergeQueueAdmission(params) {
|
|
13
|
+
const { issue, protocol, logger, feed } = params;
|
|
14
|
+
if (!protocol.repoFullName || !issue.prNumber)
|
|
15
|
+
return;
|
|
16
|
+
feed?.publish({
|
|
17
|
+
level: "info",
|
|
18
|
+
kind: "github",
|
|
19
|
+
issueKey: issue.issueKey,
|
|
20
|
+
projectId: issue.projectId,
|
|
21
|
+
stage: "awaiting_queue",
|
|
22
|
+
status: "queue_label_requested",
|
|
23
|
+
summary: `Queue hand-off requested via label "${protocol.admissionLabel}" on PR #${issue.prNumber}`,
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
await execCommand("gh", [
|
|
27
|
+
"pr", "edit", String(issue.prNumber),
|
|
28
|
+
"--repo", protocol.repoFullName,
|
|
29
|
+
"--add-label", protocol.admissionLabel,
|
|
30
|
+
], { timeoutMs: 15_000 });
|
|
31
|
+
feed?.publish({
|
|
32
|
+
level: "info",
|
|
33
|
+
kind: "github",
|
|
34
|
+
issueKey: issue.issueKey,
|
|
35
|
+
projectId: issue.projectId,
|
|
36
|
+
stage: "awaiting_queue",
|
|
37
|
+
status: "queue_label_applied",
|
|
38
|
+
summary: `Queue label "${protocol.admissionLabel}" applied to PR #${issue.prNumber}`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
logger.warn({ issueKey: issue.issueKey, err: error }, "Failed to add merge queue label");
|
|
43
|
+
feed?.publish({
|
|
44
|
+
level: "warn",
|
|
45
|
+
kind: "github",
|
|
46
|
+
issueKey: issue.issueKey,
|
|
47
|
+
projectId: issue.projectId,
|
|
48
|
+
stage: "awaiting_queue",
|
|
49
|
+
status: "queue_label_failed",
|
|
50
|
+
summary: `Queue hand-off failed while adding label "${protocol.admissionLabel}" to PR #${issue.prNumber}`,
|
|
51
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/preflight.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { accessSync, constants, existsSync, mkdirSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { runPatchRelayMigrations } from "./db/migrations.js";
|
|
4
|
+
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
4
5
|
import { SqliteConnection } from "./db/shared.js";
|
|
5
6
|
import { execCommand } from "./utils.js";
|
|
6
7
|
export async function runPreflight(config, options) {
|
|
@@ -56,11 +57,12 @@ export async function runPreflight(config, options) {
|
|
|
56
57
|
checks.push(...checkDatabaseHealth(config));
|
|
57
58
|
checks.push(...checkPath("logging", path.dirname(config.logging.filePath), "directory", { createIfMissing: true, writable: true }));
|
|
58
59
|
if (config.projects.length === 0) {
|
|
59
|
-
checks.push(warn("projects", "No
|
|
60
|
+
checks.push(warn("projects", "No repos are configured yet; connect a Linear workspace with `patchrelay linear connect` and then link a GitHub repo with `patchrelay repo link <owner/repo> --workspace <workspace> --team <team>`"));
|
|
60
61
|
}
|
|
61
62
|
for (const project of config.projects) {
|
|
62
63
|
checks.push(...checkPath(`project:${project.id}:repo`, project.repoPath, "directory", { writable: true }));
|
|
63
64
|
checks.push(...checkPath(`project:${project.id}:worktrees`, project.worktreeRoot, "directory", { createIfMissing: true, writable: true }));
|
|
65
|
+
checks.push(...checkGitHubProtocol(project, config.server.publicBaseUrl));
|
|
64
66
|
// Workflow file checks removed — factory state machine replaces workflow definitions
|
|
65
67
|
}
|
|
66
68
|
checks.push(await checkExecutable("git", config.runner.gitBin));
|
|
@@ -272,6 +274,31 @@ function checkOAuthRedirectUri(config) {
|
|
|
272
274
|
return [fail("linear_oauth", `Invalid linear.oauth.redirect_uri: ${formatError(error)}`)];
|
|
273
275
|
}
|
|
274
276
|
}
|
|
277
|
+
function checkGitHubProtocol(project, publicBaseUrl) {
|
|
278
|
+
const protocol = resolveMergeQueueProtocol(project);
|
|
279
|
+
const scope = `project:${project.id}:github_protocol`;
|
|
280
|
+
if (!protocol.repoFullName) {
|
|
281
|
+
return [
|
|
282
|
+
warn(scope, "GitHub repo is not configured; PR state tracking, queue hand-off, and queue repair automation are disabled for this project"),
|
|
283
|
+
];
|
|
284
|
+
}
|
|
285
|
+
const checks = [
|
|
286
|
+
pass(scope, `GitHub protocol configured for ${protocol.repoFullName} (label "${protocol.admissionLabel}", eviction check "${protocol.evictionCheckName}")`),
|
|
287
|
+
];
|
|
288
|
+
if (!publicBaseUrl) {
|
|
289
|
+
checks.push(warn(scope, "PatchRelay public base URL is not configured; public operator/session links will be incomplete"));
|
|
290
|
+
}
|
|
291
|
+
if (!protocol.baseBranch) {
|
|
292
|
+
checks.push(warn(scope, "GitHub base branch is not configured; defaults may diverge from the target repository"));
|
|
293
|
+
}
|
|
294
|
+
if (!protocol.admissionLabel.trim()) {
|
|
295
|
+
checks.push(fail(scope, "Merge queue admission label must not be empty"));
|
|
296
|
+
}
|
|
297
|
+
if (!protocol.evictionCheckName.trim()) {
|
|
298
|
+
checks.push(fail(scope, "Merge queue eviction check name must not be empty"));
|
|
299
|
+
}
|
|
300
|
+
return checks;
|
|
301
|
+
}
|
|
275
302
|
function isLoopbackHost(host) {
|
|
276
303
|
return host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
277
304
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execCommand } from "./utils.js";
|
|
4
|
+
export function normalizeGitHubRepo(input) {
|
|
5
|
+
const trimmed = input.trim();
|
|
6
|
+
if (!trimmed) {
|
|
7
|
+
throw new Error("GitHub repo is required.");
|
|
8
|
+
}
|
|
9
|
+
const withoutProtocol = trimmed
|
|
10
|
+
.replace(/^https?:\/\/github\.com\//i, "")
|
|
11
|
+
.replace(/^git@github\.com:/i, "")
|
|
12
|
+
.replace(/\.git$/i, "")
|
|
13
|
+
.replace(/^github\.com\//i, "")
|
|
14
|
+
.replace(/^\/+|\/+$/g, "");
|
|
15
|
+
const parts = withoutProtocol.split("/");
|
|
16
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
17
|
+
throw new Error(`Invalid GitHub repo: ${input}`);
|
|
18
|
+
}
|
|
19
|
+
return `${parts[0]}/${parts[1]}`;
|
|
20
|
+
}
|
|
21
|
+
export function defaultLocalRepoPath(reposRoot, githubRepo) {
|
|
22
|
+
const repoName = githubRepo.split("/").pop();
|
|
23
|
+
if (!repoName) {
|
|
24
|
+
throw new Error(`Invalid GitHub repo: ${githubRepo}`);
|
|
25
|
+
}
|
|
26
|
+
return path.join(reposRoot, repoName);
|
|
27
|
+
}
|
|
28
|
+
export async function ensureLocalRepository(params) {
|
|
29
|
+
const githubRepo = normalizeGitHubRepo(params.githubRepo);
|
|
30
|
+
const localPath = path.resolve(params.localPath);
|
|
31
|
+
const originUrl = `https://github.com/${githubRepo}.git`;
|
|
32
|
+
if (!existsSync(localPath)) {
|
|
33
|
+
await execCommand(params.config.runner.gitBin, ["clone", originUrl, localPath], { timeoutMs: 300_000 });
|
|
34
|
+
return { reused: false, localPath, originUrl };
|
|
35
|
+
}
|
|
36
|
+
const remote = await execCommand(params.config.runner.gitBin, ["-C", localPath, "remote", "get-url", "origin"], { timeoutMs: 10_000 });
|
|
37
|
+
const existingRepo = normalizeGitHubRepo(remote.stdout.trim());
|
|
38
|
+
if (existingRepo !== githubRepo) {
|
|
39
|
+
throw new Error(`Existing repo at ${localPath} points to ${existingRepo}, not ${githubRepo}`);
|
|
40
|
+
}
|
|
41
|
+
return { reused: true, localPath, originUrl };
|
|
42
|
+
}
|