patchrelay 0.36.19 → 0.37.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 (36) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/cluster-health.js +25 -22
  3. package/dist/cli/data.js +1 -0
  4. package/dist/cli/formatters/text.js +5 -1
  5. package/dist/cli/watch/App.js +226 -27
  6. package/dist/cli/watch/HelpBar.js +18 -9
  7. package/dist/cli/watch/IssueDetailView.js +32 -14
  8. package/dist/cli/watch/IssueRow.js +4 -3
  9. package/dist/cli/watch/StatusBar.js +2 -1
  10. package/dist/cli/watch/detail-rows.js +5 -25
  11. package/dist/cli/watch/detail-status.js +38 -0
  12. package/dist/cli/watch/layout-measure.js +7 -0
  13. package/dist/cli/watch/pr-status.js +2 -1
  14. package/dist/cli/watch/prompt-layout.js +14 -0
  15. package/dist/cli/watch/state-visualization.js +5 -1
  16. package/dist/cli/watch/timeline-builder.js +169 -18
  17. package/dist/cli/watch/timeline-presentation.js +21 -1
  18. package/dist/cli/watch/transient-status.js +28 -0
  19. package/dist/cli/watch/watch-actions.js +76 -0
  20. package/dist/cli/watch/watch-state.js +2 -12
  21. package/dist/factory-state.js +1 -1
  22. package/dist/github-webhook-handler.js +26 -4
  23. package/dist/idle-reconciliation.js +19 -2
  24. package/dist/implementation-outcome-policy.js +3 -1
  25. package/dist/issue-overview-query.js +5 -0
  26. package/dist/linear-session-reporting.js +15 -6
  27. package/dist/linear-status-comment-sync.js +13 -1
  28. package/dist/pr-state.js +49 -0
  29. package/dist/service-issue-actions.js +5 -4
  30. package/dist/tracked-issue-list-query.js +3 -1
  31. package/dist/tracked-issue-projector.js +5 -0
  32. package/dist/waiting-reason.js +3 -2
  33. package/package.json +1 -1
  34. package/dist/cli/watch/ItemLine.js +0 -80
  35. package/dist/cli/watch/Timeline.js +0 -22
  36. package/dist/cli/watch/TimelineRow.js +0 -77
@@ -1,4 +1,4 @@
1
- import { resolveFactoryStateFromGitHub, TERMINAL_STATES } from "./factory-state.js";
1
+ import { resolveFactoryStateFromGitHub } from "./factory-state.js";
2
2
  import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver, summarizeGitHubFailureContext, } from "./github-failure-context.js";
3
3
  import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
4
4
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
@@ -7,6 +7,7 @@ import { buildGitHubStateActivity } from "./linear-session-reporting.js";
7
7
  import { resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
8
8
  import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
9
9
  import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
10
+ import { buildClosedPrCleanupFields, isIssueTerminal, resolveClosedPrFactoryState, resolveClosedPrDisposition, } from "./pr-state.js";
10
11
  import { resolveSecret } from "./resolve-secret.js";
11
12
  import { safeJsonParse } from "./utils.js";
12
13
  /**
@@ -153,6 +154,9 @@ export class GitHubWebhookHandler {
153
154
  : event.reviewState === "approved"
154
155
  ? { lastBlockingReviewHeadSha: null }
155
156
  : {}),
157
+ ...(event.triggerEvent === "pr_closed"
158
+ ? buildClosedPrCleanupFields()
159
+ : {}),
156
160
  });
157
161
  await this.updateCiSnapshot(issue, event, project);
158
162
  await this.updateFailureProvenance(issue, event, project);
@@ -227,11 +231,14 @@ export class GitHubWebhookHandler {
227
231
  }
228
232
  }
229
233
  resolveFactoryStateForEvent(issue, event, project) {
234
+ if (event.triggerEvent === "pr_closed") {
235
+ return undefined;
236
+ }
230
237
  if (event.triggerEvent === "check_failed"
231
238
  && this.isQueueEvictionFailure(issue, event, project)
232
239
  && issue.prState === "open"
233
240
  && issue.activeRunId === undefined
234
- && !TERMINAL_STATES.has(issue.factoryState)) {
241
+ && !isIssueTerminal(issue)) {
235
242
  return "repairing_queue";
236
243
  }
237
244
  return resolveFactoryStateFromGitHub(event.triggerEvent, issue.factoryState, {
@@ -318,7 +325,7 @@ export class GitHubWebhookHandler {
318
325
  return;
319
326
  // Don't trigger on terminal issues — late-arriving webhooks (e.g.
320
327
  // merge_group_failed after pr_merged) must not resurrect done issues.
321
- if (TERMINAL_STATES.has(issue.factoryState))
328
+ if (isIssueTerminal(issue))
322
329
  return;
323
330
  if (!this.isPatchRelayOwnedPr(issue)) {
324
331
  this.feed?.publish({
@@ -521,11 +528,14 @@ export class GitHubWebhookHandler {
521
528
  : "Pull request closed during active run",
522
529
  });
523
530
  }
531
+ const terminalFactoryState = event.triggerEvent === "pr_merged"
532
+ ? "done"
533
+ : resolveClosedPrFactoryState(issue);
524
534
  this.db.issues.upsertIssue({
525
535
  projectId: issue.projectId,
526
536
  linearIssueId: issue.linearIssueId,
527
537
  activeRunId: null,
528
- factoryState: event.triggerEvent === "pr_merged" ? "done" : "failed",
538
+ factoryState: terminalFactoryState,
529
539
  });
530
540
  };
531
541
  const activeLease = this.db.issueSessions.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
@@ -537,6 +547,18 @@ export class GitHubWebhookHandler {
537
547
  }
538
548
  this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
539
549
  const updatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
550
+ if (event.triggerEvent === "pr_closed" && resolveClosedPrDisposition(issue) === "redelegate") {
551
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
552
+ projectId: issue.projectId,
553
+ linearIssueId: issue.linearIssueId,
554
+ eventType: "delegated",
555
+ dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
556
+ });
557
+ this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
558
+ if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
559
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
560
+ }
561
+ }
540
562
  if (event.triggerEvent === "pr_merged") {
541
563
  await this.completeLinearIssueAfterMerge(updatedIssue);
542
564
  }
@@ -1,8 +1,10 @@
1
+ import {} from "./factory-state.js";
1
2
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
3
  import { parseGitHubFailureContext } from "./github-failure-context.js";
3
4
  import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
4
5
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
5
6
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
7
+ import { buildClosedPrCleanupFields, resolveClosedPrDisposition } from "./pr-state.js";
6
8
  import { execCommand } from "./utils.js";
7
9
  const DEFAULT_REVIEW_FIX_BUDGET = 12;
8
10
  function isFailingCheckStatus(status) {
@@ -439,8 +441,23 @@ export class IdleIssueReconciler {
439
441
  return;
440
442
  }
441
443
  if (pr.state === "CLOSED") {
442
- this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed, re-delegating for implementation");
443
- this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "closed" });
444
+ const closedPrDisposition = resolveClosedPrDisposition(issue);
445
+ this.db.issues.upsertIssue({
446
+ projectId: issue.projectId,
447
+ linearIssueId: issue.linearIssueId,
448
+ prState: "closed",
449
+ ...buildClosedPrCleanupFields(),
450
+ });
451
+ if (closedPrDisposition === "done") {
452
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed for an already completed issue; preserving done state");
453
+ this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
454
+ return;
455
+ }
456
+ if (closedPrDisposition === "terminal") {
457
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, factoryState: issue.factoryState }, "Reconciliation: PR was closed on a terminal issue; preserving terminal state");
458
+ return;
459
+ }
460
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed on unfinished work, re-delegating for implementation");
444
461
  this.advanceIdleIssue(issue, "delegated", {
445
462
  pendingRunType: "implementation",
446
463
  clearFailureProvenance: true,
@@ -46,7 +46,9 @@ export class ImplementationOutcomePolicy {
46
46
  ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
47
47
  ...(pr.author?.login ? { prAuthorLogin: pr.author.login } : {}),
48
48
  }, "published PR verification refresh");
49
- return undefined;
49
+ if (pr.state?.toLowerCase() !== "closed") {
50
+ return undefined;
51
+ }
50
52
  }
51
53
  }
52
54
  }
@@ -143,6 +143,7 @@ export class IssueOverviewQuery {
143
143
  factoryState: issueRecord?.factoryState ?? "delegated",
144
144
  pendingRunType: issueRecord?.pendingRunType,
145
145
  prNumber: session.prNumber,
146
+ prState: issueRecord?.prState,
146
147
  prHeadSha: issueRecord?.prHeadSha ?? session.prHeadSha,
147
148
  prReviewState: issueRecord?.prReviewState,
148
149
  prCheckStatus: issueRecord?.prCheckStatus,
@@ -159,6 +160,10 @@ export class IssueOverviewQuery {
159
160
  ...(issueRecord?.currentLinearState ? { currentLinearState: issueRecord.currentLinearState } : {}),
160
161
  sessionState: session.sessionState,
161
162
  factoryState: issueRecord?.factoryState ?? "delegated",
163
+ ...(session.prNumber !== undefined ? { prNumber: session.prNumber } : {}),
164
+ ...(issueRecord?.prState ? { prState: issueRecord.prState } : {}),
165
+ ...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
166
+ ...(issueRecord?.prCheckStatus ? { prCheckStatus: issueRecord.prCheckStatus } : {}),
162
167
  blockedByCount: unresolvedBlockedBy.length,
163
168
  blockedByKeys,
164
169
  readyForExecution: isIssueSessionReadyForExecution({
@@ -1,5 +1,6 @@
1
1
  import { formatRunTypeLabel } from "./agent-session-plan.js";
2
2
  import { sanitizeOperatorFacingText } from "./presentation-text.js";
3
+ import { isClosedPrState } from "./pr-state.js";
3
4
  function lowerRunTypeLabel(runType) {
4
5
  return formatRunTypeLabel(runType).toLowerCase();
5
6
  }
@@ -188,15 +189,19 @@ export function buildMergePrepEscalationActivity(attempts) {
188
189
  export function summarizeIssueStateForLinear(issue) {
189
190
  switch (issue.sessionState) {
190
191
  case "waiting_input":
191
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is waiting for input.` : "Waiting for input.");
192
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is waiting for input.` : "Waiting for input.");
192
193
  case "running":
193
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is actively running.` : "Actively running.");
194
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is actively running.` : "Actively running.");
194
195
  case "idle":
195
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is idle.` : "Idle.");
196
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is idle.` : "Idle.");
196
197
  case "done":
197
- return issue.prNumber ? `PR #${issue.prNumber} has merged.` : "Change merged.";
198
+ if (issue.prNumber && issue.prState === "merged")
199
+ return `PR #${issue.prNumber} has merged.`;
200
+ if (issue.prNumber && isClosedPrState(issue.prState))
201
+ return `Completed without merging PR #${issue.prNumber}.`;
202
+ return issue.prNumber ? `Completed with PR #${issue.prNumber}.` : "Completed.";
198
203
  case "failed":
199
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} needs help to recover.` : "Needs help to recover.");
204
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} needs help to recover.` : "Needs help to recover.");
200
205
  }
201
206
  switch (issue.factoryState) {
202
207
  case "pr_open":
@@ -204,7 +209,11 @@ export function summarizeIssueStateForLinear(issue) {
204
209
  case "awaiting_queue":
205
210
  return issue.prNumber ? `PR #${issue.prNumber} is approved and awaiting merge.` : "Approved and awaiting merge.";
206
211
  case "done":
207
- return issue.prNumber ? `PR #${issue.prNumber} has merged.` : "Change merged.";
212
+ if (issue.prNumber && issue.prState === "merged")
213
+ return `PR #${issue.prNumber} has merged.`;
214
+ if (issue.prNumber && isClosedPrState(issue.prState))
215
+ return `Completed without merging PR #${issue.prNumber}.`;
216
+ return issue.prNumber ? `Completed with PR #${issue.prNumber}.` : "Completed.";
208
217
  default:
209
218
  return undefined;
210
219
  }
@@ -1,6 +1,7 @@
1
1
  import { extractCompletionCheck } from "./completion-check.js";
2
2
  import { deriveIssueStatusNote } from "./status-note.js";
3
3
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
4
+ import { isClosedPrState } from "./pr-state.js";
4
5
  export async function syncVisibleStatusComment(params) {
5
6
  const { db, issue, linear, logger, trackedIssue, options } = params;
6
7
  try {
@@ -31,7 +32,9 @@ export function shouldSyncVisibleIssueComment(issue, hasAgentSession) {
31
32
  || issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
32
33
  return true;
33
34
  }
34
- if ((issue.sessionState === "done" || issue.factoryState === "done") && issue.prNumber === undefined && !issue.prUrl) {
35
+ if ((issue.sessionState === "done" || issue.factoryState === "done")
36
+ && ((issue.prNumber === undefined && !issue.prUrl)
37
+ || isClosedPrState(issue.prState))) {
35
38
  return true;
36
39
  }
37
40
  return false;
@@ -49,6 +52,7 @@ function renderStatusComment(db, issue, trackedIssue, options) {
49
52
  factoryState: issue.factoryState,
50
53
  pendingRunType: issue.pendingRunType,
51
54
  ...(issue.prNumber !== undefined ? { prNumber: issue.prNumber } : {}),
55
+ ...(issue.prState ? { prState: issue.prState } : {}),
52
56
  prHeadSha: issue.prHeadSha,
53
57
  prReviewState: issue.prReviewState,
54
58
  prCheckStatus: issue.prCheckStatus,
@@ -110,6 +114,10 @@ function statusHeadline(issue, activeRunType) {
110
114
  case "running":
111
115
  return issue.prNumber !== undefined ? `PR #${issue.prNumber} is actively running` : "Actively running";
112
116
  case "done":
117
+ if (issue.prNumber !== undefined && issue.prState === "merged")
118
+ return `Completed with merged PR #${issue.prNumber}`;
119
+ if (issue.prNumber !== undefined && isClosedPrState(issue.prState))
120
+ return `Completed without merging PR #${issue.prNumber}`;
113
121
  return issue.prNumber !== undefined ? `Completed with PR #${issue.prNumber}` : "Completed";
114
122
  case "failed":
115
123
  return "Needs operator intervention";
@@ -138,6 +146,10 @@ function statusHeadline(issue, activeRunType) {
138
146
  case "escalated":
139
147
  return "Needs operator intervention";
140
148
  case "done":
149
+ if (issue.prNumber !== undefined && issue.prState === "merged")
150
+ return `Completed with merged PR #${issue.prNumber}`;
151
+ if (issue.prNumber !== undefined && isClosedPrState(issue.prState))
152
+ return `Completed without merging PR #${issue.prNumber}`;
141
153
  return issue.prNumber !== undefined ? `Completed with PR #${issue.prNumber}` : "Completed";
142
154
  default:
143
155
  return humanize(issue.factoryState);
@@ -0,0 +1,49 @@
1
+ import { TERMINAL_STATES } from "./factory-state.js";
2
+ export function isOpenPrState(prState) {
3
+ return prState === undefined || prState === "open";
4
+ }
5
+ export function hasOpenPr(prNumber, prState) {
6
+ // Transitional compatibility: older rows may still have a tracked PR number
7
+ // before webhook/reconciliation has populated pr_state.
8
+ return prNumber !== undefined && isOpenPrState(prState);
9
+ }
10
+ export function isClosedPrState(prState) {
11
+ return prState === "closed";
12
+ }
13
+ export function isCompletedLinearState(currentLinearStateType, currentLinearState) {
14
+ return currentLinearStateType === "completed"
15
+ || currentLinearState?.trim().toLowerCase() === "done";
16
+ }
17
+ export function isIssueCompleted(issue) {
18
+ return issue.factoryState === "done" || isCompletedLinearState(issue.currentLinearStateType, issue.currentLinearState);
19
+ }
20
+ export function isIssueTerminal(issue) {
21
+ return issue.factoryState !== undefined && TERMINAL_STATES.has(issue.factoryState);
22
+ }
23
+ export function resolveClosedPrDisposition(issue) {
24
+ if (isIssueCompleted(issue))
25
+ return "done";
26
+ if (isIssueTerminal(issue))
27
+ return "terminal";
28
+ return "redelegate";
29
+ }
30
+ export function resolveClosedPrFactoryState(issue) {
31
+ const disposition = resolveClosedPrDisposition(issue);
32
+ if (disposition === "done")
33
+ return "done";
34
+ if (disposition === "terminal")
35
+ return issue.factoryState;
36
+ return "delegated";
37
+ }
38
+ export function buildClosedPrCleanupFields() {
39
+ return {
40
+ prReviewState: null,
41
+ prCheckStatus: null,
42
+ lastBlockingReviewHeadSha: null,
43
+ lastGitHubCiSnapshotHeadSha: null,
44
+ lastGitHubCiSnapshotGateCheckName: null,
45
+ lastGitHubCiSnapshotGateCheckStatus: null,
46
+ lastGitHubCiSnapshotJson: null,
47
+ lastGitHubCiSnapshotSettledAt: null,
48
+ };
49
+ }
@@ -1,4 +1,5 @@
1
1
  import { buildOperatorRetryEvent } from "./operator-retry-event.js";
2
+ import { hasOpenPr } from "./pr-state.js";
2
3
  export class ServiceIssueActions {
3
4
  db;
4
5
  codex;
@@ -107,21 +108,21 @@ export class ServiceIssueActions {
107
108
  }
108
109
  let runType = "implementation";
109
110
  let factoryState = "delegated";
110
- if (issue.prNumber && issue.lastGitHubFailureSource === "queue_eviction") {
111
+ if (hasOpenPr(issue.prNumber, issue.prState) && issue.lastGitHubFailureSource === "queue_eviction") {
111
112
  runType = "queue_repair";
112
113
  factoryState = "repairing_queue";
113
114
  }
114
- else if (issue.prNumber && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
115
+ else if (hasOpenPr(issue.prNumber, issue.prState) && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
115
116
  runType = "ci_repair";
116
117
  factoryState = "repairing_ci";
117
118
  }
118
- else if (issue.prNumber && issue.prReviewState === "changes_requested") {
119
+ else if (hasOpenPr(issue.prNumber, issue.prState) && issue.prReviewState === "changes_requested") {
119
120
  runType = issue.pendingRunType === "branch_upkeep" || issueSession?.lastRunType === "branch_upkeep"
120
121
  ? "branch_upkeep"
121
122
  : "review_fix";
122
123
  factoryState = "changes_requested";
123
124
  }
124
- else if (issue.prNumber) {
125
+ else if (hasOpenPr(issue.prNumber, issue.prState)) {
125
126
  runType = "implementation";
126
127
  factoryState = "implementing";
127
128
  }
@@ -82,7 +82,7 @@ export class TrackedIssueListQuery {
82
82
  s.project_id, s.linear_issue_id, s.issue_key, i.title,
83
83
  i.current_linear_state, i.factory_state, s.session_state, s.waiting_reason, s.summary_text, s.updated_at,
84
84
  i.pending_run_type,
85
- i.pr_number, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
85
+ i.pr_number, i.pr_state, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
86
86
  i.last_github_ci_snapshot_json,
87
87
  i.last_github_failure_source,
88
88
  i.last_github_failure_head_sha,
@@ -180,6 +180,7 @@ export class TrackedIssueListQuery {
180
180
  factoryState: String(row.factory_state ?? "delegated"),
181
181
  ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
182
182
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
183
+ ...(row.pr_state !== null ? { prState: String(row.pr_state) } : {}),
183
184
  ...(row.pr_head_sha !== null ? { prHeadSha: String(row.pr_head_sha) } : {}),
184
185
  ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
185
186
  ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
@@ -242,6 +243,7 @@ export class TrackedIssueListQuery {
242
243
  ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
243
244
  ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
244
245
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
246
+ ...(row.pr_state !== null ? { prState: String(row.pr_state) } : {}),
245
247
  ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
246
248
  ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
247
249
  ...(prChecksSummary ? { prChecksSummary } : {}),
@@ -15,6 +15,7 @@ export function buildTrackedIssueRecord(params) {
15
15
  factoryState: params.issue.factoryState,
16
16
  pendingRunType: params.issue.pendingRunType,
17
17
  prNumber: params.issue.prNumber,
18
+ prState: params.issue.prState,
18
19
  prHeadSha: params.issue.prHeadSha,
19
20
  prReviewState: params.issue.prReviewState,
20
21
  prCheckStatus: params.issue.prCheckStatus,
@@ -46,6 +47,10 @@ export function buildTrackedIssueRecord(params) {
46
47
  ...(params.issue.currentLinearState ? { currentLinearState: params.issue.currentLinearState } : {}),
47
48
  ...(params.session?.sessionState ? { sessionState: params.session.sessionState } : {}),
48
49
  factoryState: params.issue.factoryState,
50
+ ...(params.issue.prNumber !== undefined ? { prNumber: params.issue.prNumber } : {}),
51
+ ...(params.issue.prState ? { prState: params.issue.prState } : {}),
52
+ ...(params.issue.prReviewState ? { prReviewState: params.issue.prReviewState } : {}),
53
+ ...(params.issue.prCheckStatus ? { prCheckStatus: params.issue.prCheckStatus } : {}),
49
54
  blockedByCount: unresolvedBlockedBy.length,
50
55
  blockedByKeys,
51
56
  readyForExecution: isIssueSessionReadyForExecution({
@@ -1,3 +1,4 @@
1
+ import { hasOpenPr } from "./pr-state.js";
1
2
  export const PATCHRELAY_WAITING_REASONS = {
2
3
  activeWork: "PatchRelay is actively working",
3
4
  finalizingPublishedPr: "PatchRelay is finalizing a published PR",
@@ -14,7 +15,7 @@ export const PATCHRELAY_WAITING_REASONS = {
14
15
  };
15
16
  export function derivePatchRelayWaitingReason(params) {
16
17
  if (params.activeRunType) {
17
- if (params.prNumber !== undefined && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
18
+ if (hasOpenPr(params.prNumber, params.prState) && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
18
19
  return PATCHRELAY_WAITING_REASONS.finalizingPublishedPr;
19
20
  }
20
21
  if (params.factoryState === "done") {
@@ -66,7 +67,7 @@ export function derivePatchRelayWaitingReason(params) {
66
67
  if (params.prReviewState === "approved") {
67
68
  return PATCHRELAY_WAITING_REASONS.waitingForDownstreamAutomation;
68
69
  }
69
- if (params.prNumber !== undefined) {
70
+ if (hasOpenPr(params.prNumber, params.prState)) {
70
71
  return PATCHRELAY_WAITING_REASONS.waitingForExternalReview;
71
72
  }
72
73
  if (params.pendingRunType) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.36.19",
3
+ "version": "0.37.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,80 +0,0 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- function cleanCommand(raw) {
4
- const bashMatch = raw.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+['"](.+?)['"]$/s);
5
- if (bashMatch?.[1])
6
- return bashMatch[1];
7
- const bashMatch2 = raw.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+"(.+?)"$/s);
8
- if (bashMatch2?.[1])
9
- return bashMatch2[1];
10
- return raw;
11
- }
12
- function summarizeFileChange(item) {
13
- const count = item.changes?.length ?? 0;
14
- return `updated ${count} file${count === 1 ? "" : "s"}`;
15
- }
16
- function summarizeToolCall(item) {
17
- return `used ${item.toolName ?? item.type}`;
18
- }
19
- function summarizeText(item) {
20
- return (item.text ?? "").replace(/\s+/g, " ").trim();
21
- }
22
- function itemPrefix(item) {
23
- if (item.type === "commandExecution")
24
- return "$ ";
25
- return "";
26
- }
27
- function formatItemDuration(ms) {
28
- if (ms === undefined || ms === null)
29
- return "";
30
- const seconds = Math.floor(ms / 1000);
31
- if (seconds < 1)
32
- return "";
33
- if (seconds < 60)
34
- return ` ${seconds}s`;
35
- const minutes = Math.floor(seconds / 60);
36
- return ` ${minutes}m`;
37
- }
38
- function itemText(item) {
39
- switch (item.type) {
40
- case "agentMessage":
41
- case "plan":
42
- case "reasoning":
43
- return summarizeText(item);
44
- case "commandExecution": {
45
- const cmd = cleanCommand(item.command ?? "?");
46
- const exit = item.exitCode !== undefined && item.exitCode !== null && item.exitCode !== 0
47
- ? ` exit ${item.exitCode}` : "";
48
- const dur = formatItemDuration(item.durationMs);
49
- return `${cmd}${exit}${dur}`;
50
- }
51
- case "fileChange":
52
- return summarizeFileChange(item);
53
- case "mcpToolCall":
54
- case "dynamicToolCall": {
55
- const dur = formatItemDuration(item.durationMs);
56
- return `${summarizeToolCall(item)}${dur}`;
57
- }
58
- case "userMessage":
59
- return `you: ${summarizeText(item)}`;
60
- default:
61
- return item.text ? summarizeText(item) : item.type;
62
- }
63
- }
64
- function itemColor(item) {
65
- if (item.status === "failed" || item.status === "declined")
66
- return "red";
67
- if (item.status === "inProgress")
68
- return "yellow";
69
- if (item.type === "userMessage")
70
- return "yellow";
71
- return undefined;
72
- }
73
- export function ItemLine({ item }) {
74
- const text = itemText(item);
75
- if (!text) {
76
- return _jsx(_Fragment, {});
77
- }
78
- const color = itemColor(item);
79
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { wrap: "wrap", bold: item.type === "agentMessage", ...(color ? { color } : {}), children: [itemPrefix(item), text] }), item.output && item.status === "inProgress" && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: item.output.split("\n").filter(Boolean).at(-1) ?? "" }) }))] }));
80
- }
@@ -1,22 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo } from "react";
3
- import { Box, Static, Text, useStdout } from "ink";
4
- import { buildTimelineRows } from "./timeline-presentation.js";
5
- import { TimelineRow } from "./TimelineRow.js";
6
- const ACTIVE_TAIL = 8;
7
- export function Timeline({ entries, follow }) {
8
- const { stdout } = useStdout();
9
- const rows = stdout?.rows ?? 24;
10
- const maxActive = Math.max(ACTIVE_TAIL, rows - 12);
11
- const displayRows = useMemo(() => buildTimelineRows(entries), [entries]);
12
- // Always cap the rendered entries to prevent OOM/WASM crashes.
13
- // In follow mode: older entries go to Static (terminal scrollback).
14
- // Without follow: show last maxActive entries only.
15
- const splitIndex = Math.max(0, displayRows.length - maxActive);
16
- const finalized = follow ? displayRows.slice(0, splitIndex) : [];
17
- const active = displayRows.slice(splitIndex);
18
- if (displayRows.length === 0) {
19
- return _jsx(Text, { dimColor: true, children: "No timeline events yet." });
20
- }
21
- return (_jsxs(Box, { flexDirection: "column", children: [finalized.length > 0 && (_jsx(Static, { items: finalized, children: (entry) => _jsx(TimelineRow, { entry: entry }, entry.id) })), active.map((entry) => (_jsx(TimelineRow, { entry: entry }, entry.id)))] }));
22
- }
@@ -1,77 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- import { ItemLine } from "./ItemLine.js";
4
- function formatDuration(startedAt, endedAt) {
5
- const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
6
- const seconds = Math.floor(ms / 1000);
7
- if (seconds < 60)
8
- return `${seconds}s`;
9
- const minutes = Math.floor(seconds / 60);
10
- const s = seconds % 60;
11
- return `${minutes}m ${String(s).padStart(2, "0")}s`;
12
- }
13
- const CHECK_SYMBOLS = { passed: "\u2713", failed: "\u2717", pending: "\u25cf" };
14
- const CHECK_COLORS = { passed: "green", failed: "red", pending: "yellow" };
15
- const RUN_LABELS = {
16
- implementation: "implement",
17
- ci_repair: "ci fix",
18
- review_fix: "review fix",
19
- branch_upkeep: "branch upkeep",
20
- queue_repair: "merge fix",
21
- };
22
- function runDotColor(status) {
23
- if (status === "completed")
24
- return "green";
25
- if (status === "failed")
26
- return "red";
27
- if (status === "released")
28
- return "magenta";
29
- if (status === "running")
30
- return "yellow";
31
- return "white";
32
- }
33
- function detailColor(detail) {
34
- if (detail.tone === "command")
35
- return "white";
36
- if (detail.tone === "user")
37
- return "yellow";
38
- return undefined;
39
- }
40
- function detailPrefix(detail) {
41
- if (detail.tone === "command")
42
- return "$ ";
43
- return "";
44
- }
45
- function FeedRow({ entry }) {
46
- const label = entry.feed.status ?? entry.feed.feedKind;
47
- const repeatSuffix = entry.repeatCount && entry.repeatCount > 1 ? ` \u00d7${entry.repeatCount}` : "";
48
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u25cf" }), _jsx(Text, { color: "cyan", children: ` ${label}` }), _jsx(Text, { dimColor: true, children: ` ${entry.feed.summary}${repeatSuffix}` })] }));
49
- }
50
- function RunRow({ entry, }) {
51
- const run = entry.run;
52
- const dotColor = runDotColor(run.status);
53
- const duration = run.endedAt ? formatDuration(run.startedAt, run.endedAt) : undefined;
54
- const showItems = entry.items.length > 0;
55
- const showDetails = !showItems && entry.details.length > 0;
56
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: dotColor, children: "\u25cf" }), _jsx(Text, { bold: true, color: "yellow", children: ` ${RUN_LABELS[run.runType] ?? run.runType}` }), _jsx(Text, { bold: true, color: dotColor, children: ` ${run.status}` }), duration ? _jsx(Text, { dimColor: true, children: ` ${duration}` }) : null] }), showItems && entry.items.map((itemEntry, index) => (_jsx(Box, { paddingLeft: 2, children: _jsx(ItemLine, { item: itemEntry.item }) }, `${entry.id}-item-${index}`))), showDetails && entry.details.map((detail, index) => (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { wrap: "wrap", ...(detailColor(detail) ? { color: detailColor(detail) } : { dimColor: true }), bold: detail.tone === "message", children: [detailPrefix(detail), detail.text] }) }, `${entry.id}-detail-${index}`)))] }));
57
- }
58
- function ItemRow({ entry, }) {
59
- return (_jsx(Box, { paddingLeft: 2, children: _jsx(ItemLine, { item: entry.item }) }));
60
- }
61
- function CIChecksRow({ entry }) {
62
- const ci = entry.ciChecks;
63
- const dotColor = CHECK_COLORS[ci.overall] ?? "white";
64
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: dotColor, children: "\u25cf" }), _jsx(Text, { color: dotColor, bold: true, children: ` checks` }), _jsx(Text, { children: ` ` }), ci.checks.map((check, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { children: ` ` }) : null, _jsx(Text, { color: CHECK_COLORS[check.status] ?? "white", children: CHECK_SYMBOLS[check.status] ?? " " }), _jsx(Text, { dimColor: true, children: ` ${check.name}` })] }, `c-${i}`)))] }));
65
- }
66
- export function TimelineRow({ entry }) {
67
- switch (entry.kind) {
68
- case "feed":
69
- return _jsx(FeedRow, { entry: entry });
70
- case "run":
71
- return _jsx(RunRow, { entry: entry });
72
- case "item":
73
- return _jsx(ItemRow, { entry: entry });
74
- case "ci-checks":
75
- return _jsx(CIChecksRow, { entry: entry });
76
- }
77
- }