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
package/dist/run-orchestrator.js
CHANGED
|
@@ -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"
|
|
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.
|
|
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
|
|
522
|
-
if (issue.prCheckStatus === "failed"
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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,
|
|
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
|
-
...(
|
|
568
|
-
...(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"
|
|
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"
|
|
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.
|
|
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
|
+
}
|
package/dist/runtime-paths.js
CHANGED
|
@@ -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),
|
|
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.
|
|
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": "^
|
|
55
|
+
"@types/node": "^25.5.0",
|
|
55
56
|
"@types/react": "^19.2.14",
|
|
56
57
|
"eslint": "^10.0.3",
|
|
57
|
-
"typescript": "^5.
|
|
58
|
-
"typescript-eslint": "^8.
|
|
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
|
-
}
|