patchrelay 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,33 +14,33 @@ export function formatRunTypeLabel(runType) {
14
14
  function implementationPlan() {
15
15
  return [
16
16
  { content: "Prepare workspace", status: "pending" },
17
- { content: "Implement or update branch", status: "pending" },
18
- { content: "Await review", status: "pending" },
19
- { content: "Land change", status: "pending" },
17
+ { content: "Implementing", status: "pending" },
18
+ { content: "Awaiting verification", status: "pending" },
19
+ { content: "Merge", status: "pending" },
20
20
  ];
21
21
  }
22
22
  function reviewFixPlan() {
23
23
  return [
24
24
  { content: "Prepare workspace", status: "completed" },
25
- { content: "Address review feedback", status: "pending" },
26
- { content: "Await re-review", status: "pending" },
27
- { content: "Land change", status: "pending" },
25
+ { content: "Addressing review feedback", status: "pending" },
26
+ { content: "Awaiting re-verification", status: "pending" },
27
+ { content: "Merge", status: "pending" },
28
28
  ];
29
29
  }
30
30
  function ciRepairPlan(attempt) {
31
31
  return [
32
32
  { content: "Prepare workspace", status: "completed" },
33
- { content: "Implement or update branch", status: "completed" },
34
- { content: `Repair failing checks (${attemptLabel(attempt)})`, status: "pending" },
35
- { content: "Return to merge flow", status: "pending" },
33
+ { content: "Implementing", status: "completed" },
34
+ { content: `Repairing checks (${attemptLabel(attempt)})`, status: "pending" },
35
+ { content: "Merge", status: "pending" },
36
36
  ];
37
37
  }
38
38
  function queueRepairPlan(attempt) {
39
39
  return [
40
40
  { content: "Prepare workspace", status: "completed" },
41
- { content: "Implement or update branch", status: "completed" },
42
- { content: "Review approved", status: "completed" },
43
- { content: `Repair merge queue (${attemptLabel(attempt)})`, status: "pending" },
41
+ { content: "Implementing", status: "completed" },
42
+ { content: "Verification passed", status: "completed" },
43
+ { content: `Repairing merge (${attemptLabel(attempt)})`, status: "pending" },
44
44
  ];
45
45
  }
46
46
  function awaitingInputPlan() {
@@ -101,9 +101,9 @@ export function buildAgentSessionPlan(params) {
101
101
  case "awaiting_queue":
102
102
  return setStatuses([
103
103
  { content: "Prepare workspace", status: "completed" },
104
- { content: "Implement or update branch", status: "completed" },
105
- { content: "Review approved", status: "completed" },
106
- { content: "Queued for merge", status: "inProgress" },
104
+ { content: "Implementing", status: "completed" },
105
+ { content: "Verification passed", status: "completed" },
106
+ { content: "Awaiting merge", status: "inProgress" },
107
107
  ], ["completed", "completed", "completed", "inProgress"]);
108
108
  case "repairing_queue":
109
109
  return setStatuses(queueRepairPlan(params.queueRepairAttempts ?? 1), ["completed", "completed", "completed", "inProgress"]);
@@ -116,8 +116,8 @@ export function buildAgentSessionPlan(params) {
116
116
  case "done":
117
117
  return setStatuses([
118
118
  { content: "Prepare workspace", status: "completed" },
119
- { content: "Implement or update branch", status: "completed" },
120
- { content: "Review approved", status: "completed" },
119
+ { content: "Implementing", status: "completed" },
120
+ { content: "Verification passed", status: "completed" },
121
121
  { content: "Merged", status: "completed" },
122
122
  ], ["completed", "completed", "completed", "completed"]);
123
123
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.18.0",
4
- "commit": "37f3bf8f344f",
5
- "builtAt": "2026-03-25T19:56:49.875Z"
3
+ "version": "0.19.0",
4
+ "commit": "0172365b885d",
5
+ "builtAt": "2026-03-25T20:06:12.646Z"
6
6
  }
@@ -28,15 +28,24 @@ function FeedRow({ entry }) {
28
28
  const statusLabel = feed.status ?? feed.feedKind;
29
29
  return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: "cyan", children: statusLabel.padEnd(16) }), _jsx(Text, { children: feed.summary })] }));
30
30
  }
31
+ const RUN_TYPE_LABELS = {
32
+ implementation: "implementing",
33
+ ci_repair: "repairing checks",
34
+ review_fix: "addressing feedback",
35
+ queue_repair: "repairing merge",
36
+ };
37
+ function runLabel(runType) {
38
+ return RUN_TYPE_LABELS[runType] ?? runType;
39
+ }
31
40
  function RunStartRow({ entry }) {
32
41
  const run = entry.run;
33
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: "yellow", children: run.runType.padEnd(16) }), _jsx(Text, { bold: true, children: "run started" })] }));
42
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: "yellow", children: runLabel(run.runType).padEnd(20) }), _jsx(Text, { bold: true, children: "started" })] }));
34
43
  }
35
44
  function RunEndRow({ entry }) {
36
45
  const run = entry.run;
37
46
  const color = run.status === "completed" ? "green" : "red";
38
47
  const duration = run.endedAt ? formatDuration(run.startedAt, run.endedAt) : "";
39
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: color, children: run.runType.padEnd(16) }), _jsx(Text, { bold: true, color: color, children: run.status }), duration ? _jsxs(Text, { dimColor: true, children: ["(", duration, ")"] }) : null] }));
48
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: color, children: runLabel(run.runType).padEnd(20) }), _jsx(Text, { bold: true, color: color, children: run.status }), duration ? _jsxs(Text, { dimColor: true, children: ["(", duration, ")"] }) : null] }));
40
49
  }
41
50
  function ItemRow({ entry }) {
42
51
  const item = entry.item;
package/dist/config.js CHANGED
@@ -30,6 +30,10 @@ const projectSchema = z.object({
30
30
  allow_labels: z.array(z.string().min(1)).default([]),
31
31
  trigger_events: z.array(z.string().min(1)).min(1).optional(),
32
32
  branch_prefix: z.string().min(1).optional(),
33
+ /** Check names that are review gates (AI Review, quality analysis). Default: code class. */
34
+ review_checks: z.array(z.string().min(1)).default([]),
35
+ /** Check names that are policy gates (conventional title, release policy). Default: code class. */
36
+ gate_checks: z.array(z.string().min(1)).default([]),
33
37
  github: z.object({
34
38
  webhook_secret: z.string().min(1).optional(),
35
39
  repo_full_name: z.string().min(1).optional(),
@@ -394,6 +398,8 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
394
398
  issueKeyPrefixes: project.issue_key_prefixes,
395
399
  linearTeamIds: project.linear_team_ids,
396
400
  allowLabels: project.allow_labels,
401
+ reviewChecks: project.review_checks,
402
+ gateChecks: project.gate_checks,
397
403
  triggerEvents: normalizeTriggerEvents(parsed.linear.oauth.actor, repoSettings?.trigger_events ??
398
404
  project.trigger_events),
399
405
  branchPrefix: repoSettings?.branch_prefix ?? project.branch_prefix ?? defaultBranchPrefix(project.id),
package/dist/db.js CHANGED
@@ -246,9 +246,13 @@ export class PatchRelayDatabase {
246
246
  * Issues idle in pr_open with no active run — candidates for state
247
247
  * advancement based on stored PR metadata (missed GitHub webhooks).
248
248
  */
249
- listIdlePrOpenIssues() {
249
+ listIdleNonTerminalIssues() {
250
250
  const rows = this.connection
251
- .prepare("SELECT * FROM issues WHERE factory_state = 'pr_open' AND active_run_id IS NULL AND pending_run_type IS NULL AND pr_number IS NOT NULL")
251
+ .prepare(`SELECT * FROM issues
252
+ WHERE factory_state NOT IN ('done', 'escalated', 'failed', 'awaiting_input')
253
+ AND active_run_id IS NULL
254
+ AND pending_run_type IS NULL
255
+ AND pr_number IS NOT NULL`)
252
256
  .all();
253
257
  return rows.map(mapIssueRow);
254
258
  }
@@ -182,10 +182,11 @@ export class GitHubWebhookHandler {
182
182
  detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
183
183
  });
184
184
  if (!isMetadataOnlyCheckEvent(event)) {
185
- this.maybeEnqueueReactiveRun(freshIssue, event);
185
+ const project = this.config.projects.find((p) => p.id === freshIssue.projectId);
186
+ this.maybeEnqueueReactiveRun(freshIssue, event, project);
186
187
  }
187
188
  }
188
- maybeEnqueueReactiveRun(issue, event) {
189
+ maybeEnqueueReactiveRun(issue, event, project) {
189
190
  // Don't trigger if there's already an active run
190
191
  if (issue.activeRunId !== undefined)
191
192
  return;
@@ -197,6 +198,7 @@ export class GitHubWebhookHandler {
197
198
  pendingRunContextJson: JSON.stringify({
198
199
  checkName: event.checkName,
199
200
  checkUrl: event.checkUrl,
201
+ checkClass: resolveCheckClass(event.checkName, project),
200
202
  }),
201
203
  });
202
204
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
@@ -281,3 +283,12 @@ export class GitHubWebhookHandler {
281
283
  }
282
284
  }
283
285
  }
286
+ function resolveCheckClass(checkName, project) {
287
+ if (!checkName || !project)
288
+ return "code";
289
+ if (project.reviewChecks.some((name) => checkName.includes(name)))
290
+ return "review";
291
+ if (project.gateChecks.some((name) => checkName.includes(name)))
292
+ return "gate";
293
+ return "code";
294
+ }
@@ -410,53 +410,70 @@ export class RunOrchestrator {
410
410
  }
411
411
  // Advance issues stuck in pr_open whose stored PR metadata already
412
412
  // shows they should transition (e.g. approved PR, missed webhook).
413
- await this.reconcileIdlePrOpenIssues();
413
+ await this.reconcileIdleIssues();
414
414
  }
415
- async reconcileIdlePrOpenIssues() {
416
- for (const issue of this.db.listIdlePrOpenIssues()) {
415
+ async reconcileIdleIssues() {
416
+ for (const issue of this.db.listIdleNonTerminalIssues()) {
417
+ // PR already merged — advance to done regardless of current state
417
418
  if (issue.prState === "merged") {
418
419
  this.advanceIdleIssue(issue, "done");
419
420
  continue;
420
421
  }
421
- if (issue.prReviewState === "approved") {
422
+ // Review approved + checks not failed — advance to awaiting_queue
423
+ if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
422
424
  this.advanceIdleIssue(issue, "awaiting_queue");
423
425
  continue;
424
426
  }
425
- // Stored metadata may be stale (missed webhooks during downtime).
426
- // Query GitHub for the actual PR review state.
427
- const project = this.config.projects.find((p) => p.id === issue.projectId);
428
- if (!project?.github?.repoFullName || !issue.prNumber)
427
+ // Checks failed + idle (not already in a repair state) — enqueue ci_repair
428
+ if (issue.prCheckStatus === "failed" && issue.factoryState !== "repairing_ci") {
429
+ this.advanceIdleIssue(issue, "repairing_ci", "ci_repair");
430
+ continue;
431
+ }
432
+ // Awaiting queue with stale pending merge prep — re-enqueue
433
+ if (issue.factoryState === "awaiting_queue" && issue.pendingMergePrep) {
434
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
429
435
  continue;
430
- try {
431
- const { stdout } = await execCommand("gh", [
432
- "pr", "view", String(issue.prNumber),
433
- "--repo", project.github.repoFullName,
434
- "--json", "state,reviewDecision",
435
- ], { timeoutMs: 10_000 });
436
- const pr = JSON.parse(stdout);
437
- if (pr.state === "MERGED") {
438
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
439
- this.advanceIdleIssue(issue, "done");
440
- }
441
- else if (pr.reviewDecision === "APPROVED") {
442
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
443
- this.advanceIdleIssue(issue, "awaiting_queue");
444
- }
445
436
  }
446
- catch (error) {
447
- this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
437
+ // For pr_open issues with no review decision, check GitHub for stale metadata
438
+ if (issue.factoryState === "pr_open" && !issue.prReviewState) {
439
+ await this.reconcileFromGitHub(issue);
448
440
  }
449
441
  }
450
442
  }
451
- advanceIdleIssue(issue, newState) {
452
- this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState }, "Reconciliation: advancing idle issue from stored PR metadata");
443
+ async reconcileFromGitHub(issue) {
444
+ const project = this.config.projects.find((p) => p.id === issue.projectId);
445
+ if (!project?.github?.repoFullName || !issue.prNumber)
446
+ return;
447
+ try {
448
+ const { stdout } = await execCommand("gh", [
449
+ "pr", "view", String(issue.prNumber),
450
+ "--repo", project.github.repoFullName,
451
+ "--json", "state,reviewDecision",
452
+ ], { timeoutMs: 10_000 });
453
+ const pr = JSON.parse(stdout);
454
+ if (pr.state === "MERGED") {
455
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
456
+ this.advanceIdleIssue(issue, "done");
457
+ }
458
+ else if (pr.reviewDecision === "APPROVED") {
459
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
460
+ this.advanceIdleIssue(issue, "awaiting_queue");
461
+ }
462
+ }
463
+ catch (error) {
464
+ this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
465
+ }
466
+ }
467
+ advanceIdleIssue(issue, newState, pendingRunType) {
468
+ this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType }, "Reconciliation: advancing idle issue");
453
469
  this.db.upsertIssue({
454
470
  projectId: issue.projectId,
455
471
  linearIssueId: issue.linearIssueId,
456
472
  factoryState: newState,
457
473
  ...(newState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
474
+ ...(pendingRunType ? { pendingRunType: pendingRunType } : {}),
458
475
  });
459
- if (newState === "awaiting_queue") {
476
+ if (newState === "awaiting_queue" || pendingRunType) {
460
477
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
461
478
  }
462
479
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {