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
@@ -5,6 +5,8 @@ import { buildHookEnv, runProjectHook } from "./hook-runner.js";
5
5
  import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
6
6
  import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
7
7
  import { buildRunCompletedActivity, buildRunFailureActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
8
+ import { requestMergeQueueAdmission, resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
9
+ import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
8
10
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
9
11
  import { WorktreeManager } from "./worktree-manager.js";
10
12
  import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
@@ -61,6 +63,7 @@ function buildRunPrompt(issue, runType, repoPath, context) {
61
63
  lines.push("## Review Changes Requested", "", "A reviewer has requested changes on your PR. Address the feedback and push.", context?.reviewerName ? `Reviewer: ${String(context.reviewerName)}` : "", context?.reviewBody ? `\n## Review comment\n\n${String(context.reviewBody)}` : "", "", "Steps:", "1. Read the review feedback and PR comments (`gh pr view --comments`).", "2. Check the current diff (`git diff origin/main`) — a prior rebase may have already resolved some concerns (e.g., scope-bundling from stale commits).", "3. For each review point: if already resolved, note why. If not, fix it.", "4. Run verification, commit and push.", "5. If you believe all concerns are resolved, request a re-review: `gh pr edit <PR#> --add-reviewer <reviewer>`.", " Do NOT just post a comment saying \"resolved\" — the reviewer must re-review to dismiss the CHANGES_REQUESTED state.", "");
62
64
  break;
63
65
  case "queue_repair":
66
+ appendQueueRepairContext(lines, context);
64
67
  lines.push("## Merge Queue Failure", "", "The merge queue rejected this PR. Rebase onto latest main and fix conflicts.", context?.failureReason ? `Failure reason: ${String(context.failureReason)}` : "", "", "Fetch and rebase onto latest main, resolve conflicts, run verification, push.", "If the conflict is a semantic contradiction, explain and stop.", "");
65
68
  break;
66
69
  }
@@ -413,12 +416,20 @@ export class RunOrchestrator {
413
416
  linearIssueId: run.linearIssueId,
414
417
  activeRunId: null,
415
418
  ...(postRunState ? { factoryState: postRunState } : {}),
416
- ...(postRunState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
419
+ ...(postRunState === "awaiting_queue" || postRunState === "done"
420
+ ? {
421
+ lastGitHubFailureSource: null,
422
+ lastGitHubFailureCheckName: null,
423
+ lastGitHubFailureCheckUrl: null,
424
+ lastGitHubFailureAt: null,
425
+ lastQueueIncidentJson: null,
426
+ }
427
+ : {}),
417
428
  });
418
429
  });
419
430
  // If we advanced to awaiting_queue, enqueue for merge prep
420
431
  if (postRunState === "awaiting_queue") {
421
- this.enqueueIssue(run.projectId, run.linearIssueId);
432
+ this.requestMergeQueueAdmission(issue, run.projectId);
422
433
  }
423
434
  this.feed?.publish({
424
435
  level: "info",
@@ -510,22 +521,56 @@ export class RunOrchestrator {
510
521
  for (const issue of this.db.listIdleNonTerminalIssues()) {
511
522
  // PR already merged — advance to done regardless of current state
512
523
  if (issue.prState === "merged") {
513
- this.advanceIdleIssue(issue, "done");
524
+ this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
514
525
  continue;
515
526
  }
516
527
  // Review approved + checks not failed — advance to awaiting_queue
517
528
  if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
518
- this.advanceIdleIssue(issue, "awaiting_queue");
529
+ this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
519
530
  continue;
520
531
  }
521
- // Checks failed + idle (not already in a repair state) enqueue ci_repair
522
- if (issue.prCheckStatus === "failed" && issue.factoryState !== "repairing_ci") {
523
- this.advanceIdleIssue(issue, "repairing_ci", "ci_repair");
524
- continue;
525
- }
526
- // Awaiting queue with stale pending merge prep — re-enqueue
527
- if (issue.factoryState === "awaiting_queue" && issue.pendingMergePrep) {
528
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
532
+ // Checks failed + idle route based on durable GitHub failure provenance.
533
+ if (issue.prCheckStatus === "failed") {
534
+ if (issue.lastGitHubFailureSource === "queue_eviction") {
535
+ if (issue.factoryState !== "repairing_queue") {
536
+ const pendingRunContext = buildFailureContext(issue);
537
+ this.advanceIdleIssue(issue, "repairing_queue", {
538
+ pendingRunType: "queue_repair",
539
+ ...(pendingRunContext ? { pendingRunContext } : {}),
540
+ });
541
+ }
542
+ continue;
543
+ }
544
+ if (issue.lastGitHubFailureSource === "branch_ci") {
545
+ if (issue.factoryState !== "repairing_ci") {
546
+ const pendingRunContext = buildFailureContext(issue);
547
+ this.advanceIdleIssue(issue, "repairing_ci", {
548
+ pendingRunType: "ci_repair",
549
+ ...(pendingRunContext ? { pendingRunContext } : {}),
550
+ });
551
+ }
552
+ continue;
553
+ }
554
+ if (issue.factoryState === "awaiting_queue") {
555
+ this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation skipped failed awaiting_queue issue with unknown failure provenance");
556
+ this.feed?.publish({
557
+ level: "warn",
558
+ kind: "github",
559
+ issueKey: issue.issueKey,
560
+ projectId: issue.projectId,
561
+ stage: issue.factoryState,
562
+ status: "failure_source_unknown",
563
+ summary: "Reconciliation saw failed checks but could not determine whether the failure came from CI or the merge queue",
564
+ });
565
+ continue;
566
+ }
567
+ if (issue.factoryState !== "repairing_ci") {
568
+ const pendingRunContext = buildFailureContext(issue);
569
+ this.advanceIdleIssue(issue, "repairing_ci", {
570
+ pendingRunType: "ci_repair",
571
+ ...(pendingRunContext ? { pendingRunContext } : {}),
572
+ });
573
+ }
529
574
  continue;
530
575
  }
531
576
  // For pr_open issues with no review decision, check GitHub for stale metadata
@@ -547,25 +592,38 @@ export class RunOrchestrator {
547
592
  const pr = JSON.parse(stdout);
548
593
  if (pr.state === "MERGED") {
549
594
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
550
- this.advanceIdleIssue(issue, "done");
595
+ this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
551
596
  }
552
597
  else if (pr.reviewDecision === "APPROVED") {
553
598
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
554
- this.advanceIdleIssue(issue, "awaiting_queue");
599
+ this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
555
600
  }
556
601
  }
557
602
  catch (error) {
558
603
  this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
559
604
  }
560
605
  }
561
- advanceIdleIssue(issue, newState, pendingRunType) {
562
- this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType }, "Reconciliation: advancing idle issue");
606
+ advanceIdleIssue(issue, newState, options) {
607
+ this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
563
608
  this.db.upsertIssue({
564
609
  projectId: issue.projectId,
565
610
  linearIssueId: issue.linearIssueId,
566
611
  factoryState: newState,
567
- ...(newState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
568
- ...(pendingRunType ? { pendingRunType: pendingRunType } : {}),
612
+ ...(options?.pendingRunType ? { pendingRunType: options.pendingRunType } : {}),
613
+ ...(options?.pendingRunType
614
+ ? {
615
+ pendingRunContextJson: options.pendingRunContext ? JSON.stringify(options.pendingRunContext) : null,
616
+ }
617
+ : {}),
618
+ ...(options?.clearFailureProvenance
619
+ ? {
620
+ lastGitHubFailureSource: null,
621
+ lastGitHubFailureCheckName: null,
622
+ lastGitHubFailureCheckUrl: null,
623
+ lastGitHubFailureAt: null,
624
+ lastQueueIncidentJson: null,
625
+ }
626
+ : {}),
569
627
  });
570
628
  this.feed?.publish({
571
629
  level: "info",
@@ -576,7 +634,10 @@ export class RunOrchestrator {
576
634
  status: "reconciled",
577
635
  summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
578
636
  });
579
- if (newState === "awaiting_queue" || pendingRunType) {
637
+ if (newState === "awaiting_queue") {
638
+ this.requestMergeQueueAdmission(issue, issue.projectId);
639
+ }
640
+ if (options?.pendingRunType) {
580
641
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
581
642
  }
582
643
  }
@@ -752,7 +813,15 @@ export class RunOrchestrator {
752
813
  linearIssueId: run.linearIssueId,
753
814
  activeRunId: null,
754
815
  ...(postRunState ? { factoryState: postRunState } : {}),
755
- ...(postRunState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
816
+ ...(postRunState === "awaiting_queue" || postRunState === "done"
817
+ ? {
818
+ lastGitHubFailureSource: null,
819
+ lastGitHubFailureCheckName: null,
820
+ lastGitHubFailureCheckUrl: null,
821
+ lastGitHubFailureAt: null,
822
+ lastQueueIncidentJson: null,
823
+ }
824
+ : {}),
756
825
  });
757
826
  });
758
827
  if (postRunState) {
@@ -767,7 +836,7 @@ export class RunOrchestrator {
767
836
  });
768
837
  }
769
838
  if (postRunState === "awaiting_queue") {
770
- this.enqueueIssue(run.projectId, run.linearIssueId);
839
+ this.requestMergeQueueAdmission(issue, run.projectId);
771
840
  }
772
841
  }
773
842
  }
@@ -801,6 +870,17 @@ export class RunOrchestrator {
801
870
  });
802
871
  void this.syncLinearSession(escalatedIssue);
803
872
  }
873
+ /** Add the merge queue admission label for external-queue projects (best-effort). */
874
+ requestMergeQueueAdmission(issue, projectId) {
875
+ const project = this.config.projects.find((p) => p.id === projectId);
876
+ const protocol = resolveMergeQueueProtocol(project);
877
+ void requestMergeQueueAdmission({
878
+ issue,
879
+ protocol,
880
+ logger: this.logger,
881
+ feed: this.feed,
882
+ });
883
+ }
804
884
  failRunAndClear(run, message) {
805
885
  this.db.transaction(() => {
806
886
  this.db.finishRun(run.id, { status: "failed", failureReason: message });
@@ -929,3 +1009,99 @@ function resolvePostRunState(issue) {
929
1009
  }
930
1010
  return undefined;
931
1011
  }
1012
+ function buildFailureContext(issue) {
1013
+ const queueRepairContext = issue.lastQueueIncidentJson
1014
+ ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
1015
+ : undefined;
1016
+ if (!queueRepairContext
1017
+ && !issue.lastGitHubFailureSource
1018
+ && !issue.lastGitHubFailureCheckName
1019
+ && !issue.lastGitHubFailureCheckUrl) {
1020
+ return undefined;
1021
+ }
1022
+ return {
1023
+ ...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
1024
+ ...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
1025
+ ...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
1026
+ ...(queueRepairContext ? queueRepairContext : {}),
1027
+ };
1028
+ }
1029
+ function appendQueueRepairContext(lines, context) {
1030
+ const incidentTitle = typeof context?.incidentTitle === "string" ? context.incidentTitle.trim() : "";
1031
+ const incidentSummary = typeof context?.incidentSummary === "string" ? context.incidentSummary.trim() : "";
1032
+ const incidentId = typeof context?.incidentId === "string" ? context.incidentId.trim() : "";
1033
+ const incidentUrl = typeof context?.incidentUrl === "string" ? context.incidentUrl.trim() : "";
1034
+ const incidentContext = context?.incidentContext && typeof context.incidentContext === "object"
1035
+ ? context.incidentContext
1036
+ : undefined;
1037
+ const failureClass = typeof incidentContext?.failureClass === "string" ? incidentContext.failureClass : "";
1038
+ const baseSha = typeof incidentContext?.baseSha === "string" ? incidentContext.baseSha : "";
1039
+ const prHeadSha = typeof incidentContext?.prHeadSha === "string" ? incidentContext.prHeadSha : "";
1040
+ const baseBranch = typeof incidentContext?.baseBranch === "string" ? incidentContext.baseBranch : "";
1041
+ const branch = typeof incidentContext?.branch === "string" ? incidentContext.branch : "";
1042
+ const queuePosition = typeof incidentContext?.queuePosition === "number" ? String(incidentContext.queuePosition) : "";
1043
+ const conflictFiles = Array.isArray(incidentContext?.conflictFiles)
1044
+ ? incidentContext.conflictFiles.filter((entry) => typeof entry === "string")
1045
+ : [];
1046
+ const failedChecks = Array.isArray(incidentContext?.failedChecks)
1047
+ ? incidentContext.failedChecks
1048
+ .filter((entry) => Boolean(entry) && typeof entry === "object")
1049
+ .map((entry) => ({
1050
+ name: typeof entry.name === "string" ? entry.name : "unknown",
1051
+ conclusion: typeof entry.conclusion === "string" ? entry.conclusion : "unknown",
1052
+ ...(typeof entry.url === "string" ? { url: entry.url } : {}),
1053
+ }))
1054
+ : [];
1055
+ const retryHistory = Array.isArray(incidentContext?.retryHistory)
1056
+ ? incidentContext.retryHistory
1057
+ .filter((entry) => Boolean(entry) && typeof entry === "object")
1058
+ .map((entry) => ({
1059
+ at: typeof entry.at === "string" ? entry.at : "unknown",
1060
+ baseSha: typeof entry.baseSha === "string" ? entry.baseSha : "unknown",
1061
+ outcome: typeof entry.outcome === "string" ? entry.outcome : "unknown",
1062
+ }))
1063
+ : [];
1064
+ if (!incidentTitle && !incidentSummary && !incidentId && !incidentUrl && !failureClass && !baseSha && !prHeadSha
1065
+ && !queuePosition && conflictFiles.length === 0 && failedChecks.length === 0 && retryHistory.length === 0) {
1066
+ return;
1067
+ }
1068
+ lines.push("## Queue Incident Context", "");
1069
+ if (incidentTitle)
1070
+ lines.push(`Incident: ${incidentTitle}`);
1071
+ if (incidentId)
1072
+ lines.push(`Incident ID: ${incidentId}`);
1073
+ if (incidentUrl)
1074
+ lines.push(`Incident URL: ${incidentUrl}`);
1075
+ if (incidentSummary)
1076
+ lines.push("", incidentSummary, "");
1077
+ if (failureClass)
1078
+ lines.push(`Failure class: ${failureClass}`);
1079
+ if (baseBranch)
1080
+ lines.push(`Base branch: ${baseBranch}`);
1081
+ if (baseSha)
1082
+ lines.push(`Base SHA: ${baseSha}`);
1083
+ if (branch)
1084
+ lines.push(`Queue branch: ${branch}`);
1085
+ if (prHeadSha)
1086
+ lines.push(`Queue branch head SHA: ${prHeadSha}`);
1087
+ if (queuePosition)
1088
+ lines.push(`Queue position at eviction: ${queuePosition}`);
1089
+ if (conflictFiles.length > 0) {
1090
+ lines.push("", "Conflicting files:");
1091
+ for (const file of conflictFiles)
1092
+ lines.push(`- ${file}`);
1093
+ }
1094
+ if (failedChecks.length > 0) {
1095
+ lines.push("", "Failed checks:");
1096
+ for (const check of failedChecks) {
1097
+ lines.push(`- ${check.name} (${check.conclusion})${check.url ? ` ${check.url}` : ""}`);
1098
+ }
1099
+ }
1100
+ if (retryHistory.length > 0) {
1101
+ lines.push("", "Retry history:");
1102
+ for (const retry of retryHistory) {
1103
+ lines.push(`- ${retry.at}: ${retry.outcome} on base ${retry.baseSha}`);
1104
+ }
1105
+ }
1106
+ lines.push("");
1107
+ }
@@ -27,8 +27,6 @@ export function getPatchRelayPathLayout() {
27
27
  logFilePath: ensureAbsolutePath(process.env.PATCHRELAY_LOG_FILE ?? path.join(stateDir, "patchrelay.log")),
28
28
  systemdDir,
29
29
  systemdUnitPath: path.join(systemdDir, "patchrelay.service"),
30
- systemdReloadUnitPath: path.join(systemdDir, "patchrelay-reload.service"),
31
- systemdPathUnitPath: path.join(systemdDir, "patchrelay.path"),
32
30
  };
33
31
  }
34
32
  export function getPatchRelayConfigDir() {
@@ -61,12 +59,6 @@ export function getDefaultWebhookArchiveDir() {
61
59
  export function getSystemdUnitPath() {
62
60
  return getPatchRelayPathLayout().systemdUnitPath;
63
61
  }
64
- export function getSystemdReloadUnitPath() {
65
- return getPatchRelayPathLayout().systemdReloadUnitPath;
66
- }
67
- export function getSystemdPathUnitPath() {
68
- return getPatchRelayPathLayout().systemdPathUnitPath;
69
- }
70
62
  export function getPackageRoot() {
71
63
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
72
64
  }
package/dist/service.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { resolveGitHubAppCredentials, createGitHubAppTokenManager, ensureGhWrapper, } from "./github-app-token.js";
2
2
  import { GitHubWebhookHandler } from "./github-webhook-handler.js";
3
3
  import { IssueQueryService } from "./issue-query-service.js";
4
+ import { DatabaseBackedLinearClientProvider } from "./linear-client.js";
4
5
  import { LinearOAuthService } from "./linear-oauth-service.js";
5
- import { MergeQueue } from "./merge-queue.js";
6
6
  import { RunOrchestrator } from "./run-orchestrator.js";
7
7
  import { OperatorEventFeed } from "./operator-feed.js";
8
8
  import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSigningSecret, verifySessionStatusToken, } from "./public-agent-session-status.js";
@@ -41,7 +41,6 @@ export class PatchRelayService {
41
41
  logger;
42
42
  linearProvider;
43
43
  orchestrator;
44
- mergeQueue;
45
44
  githubAppTokenManager;
46
45
  webhookHandler;
47
46
  githubWebhookHandler;
@@ -60,56 +59,16 @@ export class PatchRelayService {
60
59
  throw new Error("Service runtime enqueueIssue is not initialized");
61
60
  };
62
61
  this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
63
- this.mergeQueue = new MergeQueue(config, db, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed, (issue, content, options) => {
64
- if (!issue.agentSessionId)
65
- return;
66
- void (async () => {
67
- try {
68
- const linear = await this.linearProvider.forProject(issue.projectId);
69
- if (!linear)
70
- return;
71
- const allowEphemeral = content.type === "thought" || content.type === "action";
72
- await linear.createAgentActivity({
73
- agentSessionId: issue.agentSessionId,
74
- content,
75
- ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
76
- });
77
- }
78
- catch (error) {
79
- const msg = error instanceof Error ? error.message : String(error);
80
- logger.warn({ issueKey: issue.issueKey, type: content.type, error: msg }, "Failed to emit merge-prep Linear activity");
81
- }
82
- })();
83
- });
84
62
  this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
85
- this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, codex, this.feed);
63
+ this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, codex, this.feed);
86
64
  const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
87
65
  processIssue: async (item) => {
88
- const issue = db.getIssue(item.projectId, item.issueId);
89
- // Repairs take priority over merge prep — a check_failed or
90
- // review_changes_requested that arrived while merge prep was
91
- // queued must not be swallowed.
92
- if (issue?.pendingRunType) {
93
- await this.orchestrator.run(item);
94
- return;
95
- }
96
- if (issue?.pendingMergePrep) {
97
- const project = config.projects.find((p) => p.id === item.projectId);
98
- if (project)
99
- await this.mergeQueue.prepareForMerge(issue, project);
100
- // Re-check: a repair run may have been enqueued during prep
101
- const after = db.getIssue(item.projectId, item.issueId);
102
- if (after?.pendingRunType) {
103
- runtime.enqueueIssue(item.projectId, item.issueId);
104
- }
105
- return;
106
- }
107
66
  await this.orchestrator.run(item);
108
67
  },
109
68
  });
110
69
  enqueueIssue = (projectId, issueId) => runtime.enqueueIssue(projectId, issueId);
111
70
  this.oauthService = new LinearOAuthService(config, { linearInstallations: db.linearInstallations }, logger);
112
- this.queryService = new IssueQueryService(db, codex, this.orchestrator);
71
+ this.queryService = new IssueQueryService(config, db, codex, this.orchestrator);
113
72
  this.runtime = runtime;
114
73
  // Optional GitHub App token management for bot identity
115
74
  const ghAppCredentials = resolveGitHubAppCredentials();
@@ -127,7 +86,7 @@ export class PatchRelayService {
127
86
  async start() {
128
87
  // Verify Linear connectivity for all configured projects before starting.
129
88
  // Auth errors do not prevent startup (the OAuth callback must be reachable
130
- // for `patchrelay connect`), but the service reports NOT READY until at
89
+ // for `patchrelay linear connect`), but the service reports NOT READY until at
131
90
  // least one project has a working Linear token.
132
91
  let anyLinearConnected = false;
133
92
  for (const project of this.config.projects) {
@@ -137,17 +96,17 @@ export class PatchRelayService {
137
96
  anyLinearConnected = true;
138
97
  }
139
98
  else {
140
- this.logger.warn({ projectId: project.id }, "No Linear installation linked — run 'patchrelay connect' to authorize");
99
+ this.logger.warn({ projectId: project.id }, "No Linear installation linked — run 'patchrelay linear connect' and then 'patchrelay repo link' to authorize");
141
100
  }
142
101
  }
143
102
  catch (error) {
144
103
  const msg = error instanceof Error ? error.message : String(error);
145
- this.logger.error({ projectId: project.id, error: msg }, "Linear auth failed — run 'patchrelay connect' to refresh the token. Runs for this project will fail until re-authorized.");
104
+ this.logger.error({ projectId: project.id, error: msg }, "Linear auth failed — run 'patchrelay linear connect' to refresh the token. Runs for this project will fail until re-authorized.");
146
105
  }
147
106
  }
148
107
  this.runtime.setLinearConnected(anyLinearConnected);
149
108
  if (!anyLinearConnected && this.config.projects.length > 0) {
150
- this.logger.error("No projects have working Linear auth — service is NOT READY. Run 'patchrelay connect' to authorize.");
109
+ this.logger.error("No projects have working Linear auth — service is NOT READY. Run 'patchrelay linear connect' to authorize.");
151
110
  }
152
111
  if (this.githubAppTokenManager) {
153
112
  await ensureGhWrapper(this.logger);
@@ -158,7 +117,6 @@ export class PatchRelayService {
158
117
  }
159
118
  }
160
119
  await this.runtime.start();
161
- this.mergeQueue.seedOnStartup();
162
120
  }
163
121
  async stop() {
164
122
  this.githubAppTokenManager?.stop();
@@ -167,6 +125,9 @@ export class PatchRelayService {
167
125
  async createLinearOAuthStart(params) {
168
126
  return await this.oauthService.createStart(params);
169
127
  }
128
+ async createLinearWorkspaceOAuthStart() {
129
+ return await this.oauthService.createStart();
130
+ }
170
131
  async completeLinearOAuth(params) {
171
132
  const result = await this.oauthService.complete(params);
172
133
  // A successful OAuth completion means at least one project now has
@@ -180,6 +141,71 @@ export class PatchRelayService {
180
141
  listLinearInstallations() {
181
142
  return this.oauthService.listInstallations();
182
143
  }
144
+ listLinearWorkspaces() {
145
+ return this.db.linearInstallations.listLinearInstallations().map((installation) => {
146
+ const linkedRepos = this.config.repositories
147
+ .filter((repository) => repository.workspace && workspaceMatches(repository.workspace, installation))
148
+ .map((repository) => repository.githubRepo);
149
+ const teams = this.db.repositories.listCatalogTeams(installation.id).map((team) => ({
150
+ id: team.teamId,
151
+ ...(team.key ? { key: team.key } : {}),
152
+ ...(team.name ? { name: team.name } : {}),
153
+ }));
154
+ const projects = this.db.repositories.listCatalogProjects(installation.id).map((project) => ({
155
+ id: project.projectId,
156
+ ...(project.name ? { name: project.name } : {}),
157
+ teamIds: parseStringArray(project.teamIdsJson),
158
+ }));
159
+ return {
160
+ installation: this.oauthService.getInstallationSummary(installation),
161
+ linkedRepos,
162
+ teams,
163
+ projects,
164
+ };
165
+ });
166
+ }
167
+ async syncLinearWorkspace(workspace) {
168
+ const installation = workspace
169
+ ? this.db.linearInstallations.findLinearInstallationByWorkspace(workspace)
170
+ : this.db.linearInstallations.listLinearInstallations()[0];
171
+ if (!installation) {
172
+ throw new Error(workspace ? `Workspace not found: ${workspace}` : "No Linear workspace connected");
173
+ }
174
+ const provider = this.linearProvider instanceof DatabaseBackedLinearClientProvider ? this.linearProvider : undefined;
175
+ if (!provider) {
176
+ throw new Error("Linear provider does not support installation sync");
177
+ }
178
+ const client = await provider.forInstallationId(installation.id);
179
+ if (!client) {
180
+ throw new Error(`Linear installation ${installation.id} is unavailable`);
181
+ }
182
+ const catalog = await client.getWorkspaceCatalog();
183
+ this.db.repositories.replaceCatalog({
184
+ installationId: installation.id,
185
+ teams: catalog.teams,
186
+ projects: catalog.projects,
187
+ });
188
+ return {
189
+ installation: this.oauthService.getInstallationSummary(installation),
190
+ teams: catalog.teams,
191
+ projects: catalog.projects,
192
+ };
193
+ }
194
+ disconnectLinearWorkspace(workspace) {
195
+ const installation = this.db.linearInstallations.findLinearInstallationByWorkspace(workspace);
196
+ if (!installation) {
197
+ throw new Error(`Workspace not found: ${workspace}`);
198
+ }
199
+ this.db.transaction(() => {
200
+ this.db.linearInstallations.unlinkInstallationProjects(installation.id);
201
+ this.db.connection.prepare("DELETE FROM linear_catalog_teams WHERE installation_id = ?").run(installation.id);
202
+ this.db.connection.prepare("DELETE FROM linear_catalog_projects WHERE installation_id = ?").run(installation.id);
203
+ this.db.linearInstallations.deleteLinearInstallation(installation.id);
204
+ });
205
+ return {
206
+ installation: this.oauthService.getInstallationSummary(installation),
207
+ };
208
+ }
183
209
  getReadiness() {
184
210
  return this.runtime.getReadiness();
185
211
  }
@@ -492,3 +518,22 @@ function toLinearClientProvider(linear) {
492
518
  },
493
519
  };
494
520
  }
521
+ function parseStringArray(value) {
522
+ if (!value)
523
+ return [];
524
+ try {
525
+ const parsed = JSON.parse(value);
526
+ return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
527
+ }
528
+ catch {
529
+ return [];
530
+ }
531
+ }
532
+ function workspaceMatches(workspace, installation) {
533
+ const normalized = workspace.trim().toLowerCase();
534
+ return [
535
+ installation.workspaceKey,
536
+ installation.workspaceName,
537
+ installation.workspaceId,
538
+ ].some((value) => value?.trim().toLowerCase() === normalized);
539
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.26.0",
3
+ "version": "0.29.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -18,14 +18,15 @@
18
18
  "runtime.env.example",
19
19
  "service.env.example",
20
20
  "config/patchrelay.example.json",
21
- "infra/patchrelay.service",
22
- "infra/patchrelay-reload.service",
23
- "infra/patchrelay.path"
21
+ "infra/patchrelay.service"
24
22
  ],
25
23
  "description": "Self-hosted harness for Linear-driven Codex work with durable issue worktrees, staged runs, and inspection.",
26
24
  "engines": {
27
25
  "node": ">=24.0.0"
28
26
  },
27
+ "workspaces": [
28
+ "packages/merge-steward"
29
+ ],
29
30
  "scripts": {
30
31
  "dev": "node --watch --experimental-transform-types src/index.ts",
31
32
  "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/index.js && node scripts/write-build-info.mjs",
@@ -51,10 +52,10 @@
51
52
  },
52
53
  "devDependencies": {
53
54
  "@eslint/js": "^10.0.1",
54
- "@types/node": "^24.12.0",
55
+ "@types/node": "^25.5.0",
55
56
  "@types/react": "^19.2.14",
56
57
  "eslint": "^10.0.3",
57
- "typescript": "^5.9.3",
58
- "typescript-eslint": "^8.57.0"
58
+ "typescript": "^5.8.3",
59
+ "typescript-eslint": "^8.58.0"
59
60
  }
60
61
  }
@@ -1,54 +0,0 @@
1
- import { runConnectFlow, parseTimeoutSeconds } from "../connect-flow.js";
2
- import { formatJson } from "../formatters/json.js";
3
- import { openExternalUrl } from "../interactive.js";
4
- import { writeOutput } from "../output.js";
5
- export async function handleConnectCommand(params) {
6
- return await runConnectFlow({
7
- config: params.config,
8
- data: params.data,
9
- stdout: params.stdout,
10
- noOpen: params.parsed.flags.get("no-open") === true,
11
- timeoutSeconds: parseTimeoutSeconds(params.parsed.flags.get("timeout"), "connect"),
12
- json: params.json,
13
- openExternal: params.options?.openExternal ?? openExternalUrl,
14
- ...(params.options?.connectPollIntervalMs !== undefined ? { connectPollIntervalMs: params.options.connectPollIntervalMs } : {}),
15
- ...(typeof params.parsed.flags.get("project") === "string" ? { projectId: String(params.parsed.flags.get("project")) } : {}),
16
- });
17
- }
18
- export async function handleInstallationsCommand(params) {
19
- const result = await params.data.listInstallations();
20
- if (params.json) {
21
- writeOutput(params.stdout, formatJson({
22
- ...result,
23
- installations: result.installations.map((item) => ({
24
- ...item,
25
- projects: item.linkedProjects.map((id) => {
26
- const p = params.config.projects.find((proj) => proj.id === id);
27
- return p ? { id: p.id, repoPath: p.repoPath, issueKeyPrefixes: p.issueKeyPrefixes, linearTeamIds: p.linearTeamIds } : { id };
28
- }),
29
- })),
30
- }));
31
- return 0;
32
- }
33
- if (result.installations.length === 0) {
34
- writeOutput(params.stdout, "No installations found.\n");
35
- return 0;
36
- }
37
- const lines = [];
38
- for (const item of result.installations) {
39
- const label = item.installation.workspaceName ?? item.installation.actorName ?? "-";
40
- lines.push(`${item.installation.id} ${label} projects=${item.linkedProjects.join(",") || "-"}`);
41
- for (const projectId of item.linkedProjects) {
42
- const p = params.config.projects.find((proj) => proj.id === projectId);
43
- if (!p)
44
- continue;
45
- const routing = [
46
- ...(p.issueKeyPrefixes.length > 0 ? [`prefixes=${p.issueKeyPrefixes.join(",")}`] : []),
47
- ...(p.linearTeamIds.length > 0 ? [`teams=${p.linearTeamIds.join(",")}`] : []),
48
- ].join(" ") || "no routing";
49
- lines.push(` ${projectId} ${p.repoPath} ${routing}`);
50
- }
51
- }
52
- writeOutput(params.stdout, `${lines.join("\n")}\n`);
53
- return 0;
54
- }