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.
Files changed (57) hide show
  1. package/README.md +83 -31
  2. package/dist/agent-session-plan.js +0 -7
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/args.js +22 -18
  5. package/dist/cli/commands/feed.js +1 -1
  6. package/dist/cli/commands/issues.js +44 -4
  7. package/dist/cli/commands/linear.js +67 -0
  8. package/dist/cli/commands/repo.js +213 -0
  9. package/dist/cli/commands/setup.js +140 -21
  10. package/dist/cli/connect-flow.js +5 -3
  11. package/dist/cli/formatters/text.js +1 -1
  12. package/dist/cli/help.js +134 -63
  13. package/dist/cli/index.js +166 -188
  14. package/dist/cli/interactive.js +25 -0
  15. package/dist/cli/operator-client.js +11 -0
  16. package/dist/cli/service-commands.js +11 -4
  17. package/dist/cli/watch/App.js +1 -1
  18. package/dist/cli/watch/FactoryStateGraph.js +31 -0
  19. package/dist/cli/watch/FeedView.js +3 -2
  20. package/dist/cli/watch/FreshnessBadge.js +13 -0
  21. package/dist/cli/watch/IssueDetailView.js +9 -2
  22. package/dist/cli/watch/IssueListView.js +2 -2
  23. package/dist/cli/watch/IssueRow.js +9 -11
  24. package/dist/cli/watch/QueueObservationView.js +15 -0
  25. package/dist/cli/watch/StateHistoryView.js +0 -1
  26. package/dist/cli/watch/StatusBar.js +5 -2
  27. package/dist/cli/watch/format-utils.js +7 -0
  28. package/dist/cli/watch/freshness.js +30 -0
  29. package/dist/cli/watch/state-visualization.js +147 -0
  30. package/dist/cli/watch/theme.js +6 -7
  31. package/dist/cli/watch/use-watch-stream.js +5 -2
  32. package/dist/cli/watch/watch-state.js +9 -5
  33. package/dist/config.js +129 -36
  34. package/dist/db/linear-installation-store.js +23 -0
  35. package/dist/db/migrations.js +42 -0
  36. package/dist/db/repository-link-store.js +103 -0
  37. package/dist/db.js +61 -11
  38. package/dist/factory-state.js +1 -5
  39. package/dist/github-webhook-handler.js +115 -46
  40. package/dist/github-webhooks.js +4 -0
  41. package/dist/http.js +162 -0
  42. package/dist/install.js +93 -13
  43. package/dist/issue-query-service.js +34 -1
  44. package/dist/linear-client.js +80 -25
  45. package/dist/merge-queue-incident.js +104 -0
  46. package/dist/merge-queue-protocol.js +54 -0
  47. package/dist/preflight.js +28 -1
  48. package/dist/repository-linking.js +42 -0
  49. package/dist/run-orchestrator.js +197 -21
  50. package/dist/runtime-paths.js +0 -8
  51. package/dist/service.js +94 -49
  52. package/package.json +8 -7
  53. package/dist/cli/commands/connect.js +0 -54
  54. package/dist/cli/commands/project.js +0 -146
  55. package/dist/merge-queue.js +0 -200
  56. package/infra/patchrelay-reload.service +0 -6
  57. 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
  }
@@ -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
- const installation = this.db.linearInstallations.getLinearInstallation(link.installationId);
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 projects are configured yet; add one with `patchrelay project apply <id> <repo-path>` before connecting Linear"));
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
+ }