patchrelay 0.35.16 → 0.36.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.
@@ -7,6 +7,20 @@ import { execCommand } from "./utils.js";
7
7
  function isFailingCheckStatus(status) {
8
8
  return status === "failed" || status === "failure";
9
9
  }
10
+ function isReviewDecisionApproved(value) {
11
+ return value?.trim().toUpperCase() === "APPROVED";
12
+ }
13
+ function isReviewDecisionChangesRequested(value) {
14
+ return value?.trim().toUpperCase() === "CHANGES_REQUESTED";
15
+ }
16
+ function isReviewDecisionReviewRequired(value) {
17
+ return value?.trim().toUpperCase() === "REVIEW_REQUIRED";
18
+ }
19
+ function hasCompletedReviewQuillVerdict(entries) {
20
+ return (entries ?? []).some((entry) => entry.__typename === "CheckRun"
21
+ && entry.name === "review-quill/verdict"
22
+ && entry.status === "COMPLETED");
23
+ }
10
24
  function getGateCheckNames(project) {
11
25
  const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
12
26
  return configured.length > 0 ? configured : ["verify"];
@@ -115,6 +129,11 @@ export class IdleIssueReconciler {
115
129
  await this.reconcileFromGitHub(issue);
116
130
  }
117
131
  }
132
+ for (const issue of this.db.listIssues()) {
133
+ if (!this.shouldProbeTerminalIssueFromGitHub(issue))
134
+ continue;
135
+ await this.reconcileFromGitHub(issue);
136
+ }
118
137
  for (const issue of this.db.listBlockedDelegatedIssues()) {
119
138
  const unresolved = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
120
139
  if (unresolved === 0) {
@@ -130,6 +149,15 @@ export class IdleIssueReconciler {
130
149
  }
131
150
  }
132
151
  }
152
+ shouldProbeTerminalIssueFromGitHub(issue) {
153
+ if (issue.prNumber === undefined)
154
+ return false;
155
+ if (issue.activeRunId !== undefined)
156
+ return false;
157
+ if (issue.pendingRunType !== undefined)
158
+ return false;
159
+ return issue.factoryState === "escalated" || issue.factoryState === "failed";
160
+ }
133
161
  advanceIdleIssue(issue, newState, options) {
134
162
  if (issue.factoryState === newState && !options?.pendingRunType && !options?.clearFailureProvenance) {
135
163
  return;
@@ -191,9 +219,9 @@ export class IdleIssueReconciler {
191
219
  eventType = "settled_red_ci";
192
220
  dedupeKey = `${dedupeScope}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`;
193
221
  }
194
- else if (runType === "review_fix") {
222
+ else if (runType === "review_fix" || runType === "branch_upkeep") {
195
223
  eventType = "review_changes_requested";
196
- dedupeKey = `${dedupeScope}:review_fix:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown"}`;
224
+ dedupeKey = `${dedupeScope}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown"}`;
197
225
  }
198
226
  else {
199
227
  eventType = "delegated";
@@ -363,6 +391,7 @@ export class IdleIssueReconciler {
363
391
  "--json", "headRefOid,state,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup",
364
392
  ], { timeoutMs: 10_000 });
365
393
  const pr = JSON.parse(stdout);
394
+ const previousHeadSha = issue.prHeadSha;
366
395
  const gateCheckNames = getGateCheckNames(project);
367
396
  const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
368
397
  this.db.upsertIssue({
@@ -370,11 +399,13 @@ export class IdleIssueReconciler {
370
399
  linearIssueId: issue.linearIssueId,
371
400
  ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
372
401
  ...(pr.state === "OPEN" ? { prState: "open" } : {}),
373
- ...(pr.reviewDecision === "APPROVED"
402
+ ...(isReviewDecisionApproved(pr.reviewDecision)
374
403
  ? { prReviewState: "approved" }
375
- : pr.reviewDecision === "CHANGES_REQUESTED"
404
+ : isReviewDecisionChangesRequested(pr.reviewDecision)
376
405
  ? { prReviewState: "changes_requested" }
377
- : {}),
406
+ : isReviewDecisionReviewRequired(pr.reviewDecision)
407
+ ? { prReviewState: "commented" }
408
+ : {}),
378
409
  ...(gateCheckStatus ? { prCheckStatus: gateCheckStatus } : {}),
379
410
  ...(pr.headRefOid && gateCheckStatus
380
411
  ? {
@@ -399,6 +430,39 @@ export class IdleIssueReconciler {
399
430
  });
400
431
  return;
401
432
  }
433
+ const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== previousHeadSha);
434
+ if (issue.factoryState !== "awaiting_input") {
435
+ const terminalRecoveryState = this.deriveTerminalRecoveryState(issue, pr.reviewDecision, gateCheckStatus, headAdvanced);
436
+ if (terminalRecoveryState) {
437
+ this.logger.info({
438
+ issueKey: issue.issueKey,
439
+ prNumber: issue.prNumber,
440
+ from: issue.factoryState,
441
+ to: terminalRecoveryState,
442
+ gateCheckStatus,
443
+ reviewDecision: pr.reviewDecision,
444
+ headAdvanced,
445
+ }, "Reconciliation: recovered terminal issue from newer GitHub truth");
446
+ this.advanceIdleIssue(issue, terminalRecoveryState, { clearFailureProvenance: true });
447
+ return;
448
+ }
449
+ }
450
+ if (isReviewDecisionReviewRequired(pr.reviewDecision)
451
+ && gateCheckStatus === "success"
452
+ && hasCompletedReviewQuillVerdict(pr.statusCheckRollup)) {
453
+ this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber, reviewDecision: pr.reviewDecision }, "Reconciliation: review-quill completed without a decisive GitHub review; escalating for operator input");
454
+ this.advanceIdleIssue(issue, "awaiting_input");
455
+ this.feed?.publish({
456
+ level: "warn",
457
+ kind: "github",
458
+ issueKey: issue.issueKey,
459
+ projectId: issue.projectId,
460
+ stage: "awaiting_input",
461
+ status: "non_decisive_review",
462
+ summary: `PR #${issue.prNumber} needs operator input: review-quill finished but GitHub still requires review`,
463
+ });
464
+ return;
465
+ }
402
466
  const downstreamOwned = issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved" || pr.reviewDecision === "APPROVED";
403
467
  const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
404
468
  const refreshedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
@@ -432,7 +496,7 @@ export class IdleIssueReconciler {
432
496
  });
433
497
  return;
434
498
  }
435
- if (pr.reviewDecision === "APPROVED") {
499
+ if (isReviewDecisionApproved(pr.reviewDecision)) {
436
500
  this.db.upsertIssue({
437
501
  projectId: issue.projectId,
438
502
  linearIssueId: issue.linearIssueId,
@@ -460,4 +524,22 @@ export class IdleIssueReconciler {
460
524
  }
461
525
  }
462
526
  }
527
+ deriveTerminalRecoveryState(issue, reviewDecision, gateCheckStatus, headAdvanced) {
528
+ if (issue.factoryState !== "escalated" && issue.factoryState !== "failed") {
529
+ return undefined;
530
+ }
531
+ if (isReviewDecisionApproved(reviewDecision) && !isFailingCheckStatus(gateCheckStatus)) {
532
+ return "awaiting_queue";
533
+ }
534
+ if (gateCheckStatus === "pending") {
535
+ return "pr_open";
536
+ }
537
+ if (headAdvanced && !isFailingCheckStatus(gateCheckStatus)) {
538
+ return "pr_open";
539
+ }
540
+ if (isReviewDecisionReviewRequired(reviewDecision) && !isFailingCheckStatus(gateCheckStatus)) {
541
+ return "pr_open";
542
+ }
543
+ return undefined;
544
+ }
463
545
  }
@@ -143,8 +143,10 @@ export class IssueQueryService {
143
143
  factoryState: issueRecord?.factoryState ?? "delegated",
144
144
  pendingRunType: issueRecord?.pendingRunType,
145
145
  prNumber: session.prNumber,
146
+ prHeadSha: issueRecord?.prHeadSha ?? session.prHeadSha,
146
147
  prReviewState: issueRecord?.prReviewState,
147
148
  prCheckStatus: issueRecord?.prCheckStatus,
149
+ lastBlockingReviewHeadSha: issueRecord?.lastBlockingReviewHeadSha,
148
150
  latestFailureCheckName: issueRecord?.lastGitHubFailureCheckName,
149
151
  });
150
152
  const issue = {
@@ -33,8 +33,8 @@ export function deriveSessionWakePlan(issue, events) {
33
33
  break;
34
34
  case "review_changes_requested":
35
35
  if (runType !== "queue_repair" && runType !== "ci_repair") {
36
- runType = "review_fix";
37
- wakeReason = "review_changes_requested";
36
+ runType = payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_fix";
37
+ wakeReason = payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_changes_requested";
38
38
  Object.assign(context, payload ?? {});
39
39
  }
40
40
  break;
@@ -18,6 +18,8 @@ export function deriveIssueSessionWakeReason(params) {
18
18
  return "delegated";
19
19
  if (params.pendingRunType === "review_fix")
20
20
  return "review_changes_requested";
21
+ if (params.pendingRunType === "branch_upkeep")
22
+ return "branch_upkeep";
21
23
  if (params.pendingRunType === "ci_repair")
22
24
  return "settled_red_ci";
23
25
  if (params.pendingRunType === "queue_repair")
@@ -49,6 +49,8 @@ export function buildRunStartedActivity(runType) {
49
49
  switch (runType) {
50
50
  case "review_fix":
51
51
  return { type: "action", action: "Addressing", parameter: "review feedback" };
52
+ case "branch_upkeep":
53
+ return { type: "action", action: "Repairing", parameter: "PR branch upkeep after requested changes" };
52
54
  case "ci_repair":
53
55
  return { type: "action", action: "Repairing", parameter: "failing CI checks" };
54
56
  case "queue_repair":
@@ -241,8 +241,10 @@ function renderStatusComment(db, issue, trackedIssue, options) {
241
241
  factoryState: issue.factoryState,
242
242
  pendingRunType: issue.pendingRunType,
243
243
  ...(issue.prNumber !== undefined ? { prNumber: issue.prNumber } : {}),
244
+ prHeadSha: issue.prHeadSha,
244
245
  prReviewState: issue.prReviewState,
245
246
  prCheckStatus: issue.prCheckStatus,
247
+ lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
246
248
  latestFailureCheckName: issue.lastGitHubFailureCheckName,
247
249
  });
248
250
  const lines = [