patchrelay 0.38.1 → 0.39.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.
Files changed (40) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/args.js +4 -0
  3. package/dist/cli/commands/issues.js +20 -1
  4. package/dist/cli/data.js +54 -7
  5. package/dist/cli/formatters/text.js +10 -0
  6. package/dist/cli/help.js +4 -0
  7. package/dist/cli/index.js +3 -0
  8. package/dist/config.js +26 -0
  9. package/dist/db/issue-store.js +10 -2
  10. package/dist/db/migrations.js +5 -0
  11. package/dist/factory-state.js +1 -0
  12. package/dist/github-webhook-handler.js +12 -0
  13. package/dist/github-webhook-late-publication-guard.js +94 -0
  14. package/dist/github-webhook-state-projector.js +15 -1
  15. package/dist/github-webhooks.js +39 -4
  16. package/dist/github-worktree-auth.js +18 -0
  17. package/dist/http.js +17 -0
  18. package/dist/idle-reconciliation.js +4 -2
  19. package/dist/issue-session-events.js +1 -0
  20. package/dist/linear-activity-key.js +11 -0
  21. package/dist/linear-agent-session-client.js +14 -1
  22. package/dist/linear-progress-facts.js +170 -0
  23. package/dist/linear-progress-reporter.js +21 -168
  24. package/dist/linear-status-comment-sync.js +3 -19
  25. package/dist/linear-workflow-state-sync.js +37 -18
  26. package/dist/manual-issue-actions.js +37 -0
  27. package/dist/merged-linear-completion-reconciler.js +102 -22
  28. package/dist/no-pr-completion-check.js +52 -0
  29. package/dist/presentation-text.js +11 -1
  30. package/dist/prompting/patchrelay.js +8 -6
  31. package/dist/run-budgets.js +12 -0
  32. package/dist/run-launcher.js +6 -6
  33. package/dist/run-notification-handler.js +4 -0
  34. package/dist/run-orchestrator.js +7 -1
  35. package/dist/run-wake-planner.js +11 -10
  36. package/dist/service-issue-actions.js +80 -27
  37. package/dist/service.js +3 -0
  38. package/dist/trusted-no-pr-completion.js +7 -0
  39. package/dist/webhooks/desired-stage-recorder.js +34 -10
  40. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.38.1",
4
- "commit": "f8444979b2ac",
5
- "builtAt": "2026-04-10T18:39:19.100Z"
3
+ "version": "0.39.0",
4
+ "commit": "80d285a7e9bd",
5
+ "builtAt": "2026-04-11T20:04:29.313Z"
6
6
  }
package/dist/cli/args.js CHANGED
@@ -3,6 +3,7 @@ export const KNOWN_COMMANDS = new Set([
3
3
  "version",
4
4
  "serve",
5
5
  "issue",
6
+ "close",
6
7
  "cluster",
7
8
  "doctor",
8
9
  "init",
@@ -56,6 +57,9 @@ export function resolveCommand(parsed) {
56
57
  return { command: "help", commandArgs: [] };
57
58
  }
58
59
  if (KNOWN_COMMANDS.has(requestedCommand)) {
60
+ if (requestedCommand === "close") {
61
+ return { command: "issue", commandArgs: ["close", ...parsed.positionals.slice(1)] };
62
+ }
59
63
  if (requestedCommand === "attach") {
60
64
  return { command: "repo", commandArgs: ["link", ...parsed.positionals.slice(1)] };
61
65
  }
@@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
2
2
  import { getRunTypeFlag } from "../args.js";
3
3
  import { CliUsageError } from "../errors.js";
4
4
  import { formatJson } from "../formatters/json.js";
5
- import { formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatWorktree } from "../formatters/text.js";
5
+ import { formatClose, formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatWorktree } from "../formatters/text.js";
6
6
  import { buildOpenCommand } from "../interactive.js";
7
7
  import { writeOutput } from "../output.js";
8
8
  export async function handleIssueCommand(params) {
@@ -38,6 +38,8 @@ export async function handleIssueCommand(params) {
38
38
  return await handleSessionsCommand(nested);
39
39
  case "retry":
40
40
  return await handleRetryCommand(nested);
41
+ case "close":
42
+ return await handleCloseCommand(nested);
41
43
  default:
42
44
  throw new CliUsageError(`Unknown issue command: ${subcommand}`, "issue");
43
45
  }
@@ -148,6 +150,23 @@ export async function handleRetryCommand(params) {
148
150
  writeOutput(params.stdout, params.json ? formatJson(result) : formatRetry(result));
149
151
  return 0;
150
152
  }
153
+ export async function handleCloseCommand(params) {
154
+ const issueKey = params.commandArgs[0];
155
+ if (!issueKey) {
156
+ throw new Error("close requires <issueKey>.");
157
+ }
158
+ const result = params.data.closeIssue(issueKey, {
159
+ failed: params.parsed.flags.get("failed") === true,
160
+ ...(typeof params.parsed.flags.get("reason") === "string"
161
+ ? { reason: String(params.parsed.flags.get("reason")) }
162
+ : {}),
163
+ });
164
+ if (!result) {
165
+ throw new Error(`Issue not found: ${issueKey}`);
166
+ }
167
+ writeOutput(params.stdout, params.json ? formatJson(result) : formatClose(result));
168
+ return 0;
169
+ }
151
170
  export async function handleListCommand(params) {
152
171
  const result = params.data.list({
153
172
  active: params.parsed.flags.get("active") === true,
package/dist/cli/data.js CHANGED
@@ -4,6 +4,7 @@ import { CodexAppServerClient } from "../codex-app-server.js";
4
4
  import { extractCompletionCheck } from "../completion-check.js";
5
5
  import { getThreadTurns } from "../codex-thread-utils.js";
6
6
  import { PatchRelayDatabase } from "../db.js";
7
+ import { buildManualRetryAttemptReset, resolveRetryTarget } from "../manual-issue-actions.js";
7
8
  import { WorktreeManager } from "../worktree-manager.js";
8
9
  import { CliOperatorApiClient } from "./operator-client.js";
9
10
  function safeJsonParse(value) {
@@ -200,13 +201,15 @@ export class CliDataAccess extends CliOperatorApiClient {
200
201
  throw new Error(`Issue ${issueKey} already has an active run.`);
201
202
  }
202
203
  const runType = (options?.runType
203
- ?? (issue.latestFailureSource === "queue_eviction" || issue.factoryState === "repairing_queue"
204
- ? "queue_repair"
205
- : dbIssue.prCheckStatus === "failed" || dbIssue.prCheckStatus === "failure" || issue.latestFailureSource === "branch_ci" || issue.factoryState === "repairing_ci"
206
- ? "ci_repair"
207
- : dbIssue.prReviewState === "changes_requested" || issue.factoryState === "changes_requested"
208
- ? (dbIssue.pendingRunType === "branch_upkeep" || issueSession?.lastRunType === "branch_upkeep" ? "branch_upkeep" : "review_fix")
209
- : "implementation"));
204
+ ?? resolveRetryTarget({
205
+ prNumber: dbIssue.prNumber,
206
+ prState: dbIssue.prState,
207
+ prReviewState: dbIssue.prReviewState,
208
+ prCheckStatus: dbIssue.prCheckStatus,
209
+ pendingRunType: dbIssue.pendingRunType,
210
+ lastRunType: issueSession?.lastRunType,
211
+ lastGitHubFailureSource: issue.latestFailureSource,
212
+ }).runType);
210
213
  const factoryState = runType === "queue_repair"
211
214
  ? "repairing_queue"
212
215
  : runType === "ci_repair"
@@ -221,10 +224,54 @@ export class CliDataAccess extends CliOperatorApiClient {
221
224
  pendingRunType: null,
222
225
  pendingRunContextJson: null,
223
226
  factoryState,
227
+ ...buildManualRetryAttemptReset(runType),
224
228
  });
225
229
  const updated = this.db.getTrackedIssue(issue.projectId, issue.linearIssueId);
226
230
  return { issue: updated, runType, ...(options?.reason ? { reason: options.reason } : {}) };
227
231
  }
232
+ closeIssue(issueKey, options) {
233
+ const issue = this.db.getTrackedIssueByKey(issueKey);
234
+ if (!issue)
235
+ return undefined;
236
+ const dbIssue = this.db.issues.getIssueByKey(issueKey);
237
+ const terminalState = options?.failed ? "failed" : "done";
238
+ const run = dbIssue.activeRunId ? this.db.runs.getRunById(dbIssue.activeRunId) : undefined;
239
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
240
+ projectId: issue.projectId,
241
+ linearIssueId: issue.linearIssueId,
242
+ eventType: "operator_closed",
243
+ eventJson: JSON.stringify({
244
+ terminalState,
245
+ ...(options?.reason ? { reason: options.reason } : {}),
246
+ }),
247
+ dedupeKey: `operator_closed:${issue.linearIssueId}:${terminalState}:${dbIssue.activeRunId ?? "no-run"}`,
248
+ });
249
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
250
+ if (run) {
251
+ this.db.issueSessions.finishRunRespectingActiveLease(issue.projectId, issue.linearIssueId, run.id, {
252
+ status: "released",
253
+ failureReason: options?.reason
254
+ ? `Operator closed issue as ${terminalState}: ${options.reason}`
255
+ : `Operator closed issue as ${terminalState}`,
256
+ });
257
+ }
258
+ this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
259
+ projectId: issue.projectId,
260
+ linearIssueId: issue.linearIssueId,
261
+ factoryState: terminalState,
262
+ activeRunId: null,
263
+ pendingRunType: null,
264
+ pendingRunContextJson: null,
265
+ });
266
+ this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
267
+ const updated = this.db.getTrackedIssue(issue.projectId, issue.linearIssueId);
268
+ return {
269
+ issue: updated,
270
+ factoryState: terminalState,
271
+ ...(options?.reason ? { reason: options.reason } : {}),
272
+ ...(run ? { releasedRunId: run.id } : {}),
273
+ };
274
+ }
228
275
  sessions(issueKey) {
229
276
  const issue = this.db.getTrackedIssueByKey(issueKey);
230
277
  if (!issue)
@@ -84,6 +84,16 @@ export function formatRetry(result) {
84
84
  .filter(Boolean)
85
85
  .join("\n")}\n`;
86
86
  }
87
+ export function formatClose(result) {
88
+ return `${[
89
+ value("Issue", result.issue.issueKey ?? result.issue.linearIssueId),
90
+ value("Closed as", result.factoryState),
91
+ result.releasedRunId ? value("Released run", result.releasedRunId) : undefined,
92
+ result.reason ? value("Reason", result.reason) : undefined,
93
+ ]
94
+ .filter(Boolean)
95
+ .join("\n")}\n`;
96
+ }
87
97
  function formatTimestampRange(startedAt, endedAt) {
88
98
  return endedAt ? `${startedAt} -> ${endedAt}` : `${startedAt} -> running`;
89
99
  }
package/dist/cli/help.js CHANGED
@@ -37,6 +37,8 @@ export function rootHelpText() {
37
37
  " issue watch <issueKey> [--json] Follow the active run until it settles",
38
38
  " issue open <issueKey> [--print] [--json] Open Codex in the issue worktree",
39
39
  " issue sessions <issueKey> [--json] Show recorded Codex app-server sessions for one issue",
40
+ " issue close <issueKey> [--failed] [--reason <text>] [--json]",
41
+ " Force-close one issue and release any active run",
40
42
  " service status [--json] Show systemd state and local health",
41
43
  " cluster [--json] Check service + workflow health across all tracked issues",
42
44
  " service logs [--lines <count>] [--json] Show recent service logs",
@@ -149,12 +151,14 @@ export function issueHelpText() {
149
151
  " open <issueKey> Open Codex in the issue worktree",
150
152
  " sessions <issueKey> Show recorded Codex app-server sessions",
151
153
  " retry <issueKey> Requeue a run",
154
+ " close <issueKey> Force-close a stuck issue",
152
155
  "",
153
156
  "Examples:",
154
157
  " patchrelay issue list --active",
155
158
  " patchrelay issue show USE-54",
156
159
  " patchrelay issue watch USE-54",
157
160
  " patchrelay issue sessions USE-54",
161
+ " patchrelay close USE-54 --reason \"already handled manually\"",
158
162
  ].join("\n");
159
163
  }
160
164
  export function serviceHelpText() {
package/dist/cli/index.js CHANGED
@@ -67,6 +67,9 @@ function validateFlags(command, commandArgs, parsed) {
67
67
  case "retry":
68
68
  assertKnownFlags(parsed, "issue", ["run-type", "reason", "json"]);
69
69
  return;
70
+ case "close":
71
+ assertKnownFlags(parsed, "issue", ["failed", "reason", "json"]);
72
+ return;
70
73
  default:
71
74
  assertKnownFlags(parsed, "issue", []);
72
75
  return;
package/dist/config.js CHANGED
@@ -21,6 +21,11 @@ const repoSettingsSchema = z.object({
21
21
  trigger_events: z.array(z.string().min(1)).min(1).optional(),
22
22
  branch_prefix: z.string().min(1).optional(),
23
23
  });
24
+ const repairBudgetsSchema = z.object({
25
+ ci_repair: z.number().int().positive().default(3),
26
+ queue_repair: z.number().int().positive().default(3),
27
+ review_fix: z.number().int().positive().default(3),
28
+ });
24
29
  const projectSchema = z.object({
25
30
  id: z.string().min(1),
26
31
  repo_path: z.string().min(1),
@@ -31,6 +36,11 @@ const projectSchema = z.object({
31
36
  allow_labels: z.array(z.string().min(1)).default([]),
32
37
  trigger_events: z.array(z.string().min(1)).min(1).optional(),
33
38
  branch_prefix: z.string().min(1).optional(),
39
+ repair_budgets: repairBudgetsSchema.default({
40
+ ci_repair: 3,
41
+ queue_repair: 3,
42
+ review_fix: 3,
43
+ }),
34
44
  /** Check names that are review gates (AI Review, quality analysis). Default: code class. */
35
45
  review_checks: z.array(z.string().min(1)).default([]),
36
46
  /** Check names that are policy gates (conventional title, release policy). Default: code class. */
@@ -52,6 +62,11 @@ const repositorySchema = z.object({
52
62
  gate_checks: z.array(z.string().min(1)).default([]),
53
63
  trigger_events: z.array(z.string().min(1)).min(1).optional(),
54
64
  branch_prefix: z.string().min(1).optional(),
65
+ repair_budgets: repairBudgetsSchema.default({
66
+ ci_repair: 3,
67
+ queue_repair: 3,
68
+ review_fix: 3,
69
+ }),
55
70
  github: z.object({
56
71
  webhook_secret: z.string().min(1).optional(),
57
72
  base_branch: z.string().min(1).optional(),
@@ -405,6 +420,11 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
405
420
  gateChecks: repository.gate_checks,
406
421
  triggerEvents: repository.trigger_events,
407
422
  branchPrefix: repository.branch_prefix,
423
+ repairBudgets: {
424
+ ciRepair: repository.repair_budgets.ci_repair,
425
+ queueRepair: repository.repair_budgets.queue_repair,
426
+ reviewFix: repository.repair_budgets.review_fix,
427
+ },
408
428
  github: repository.github,
409
429
  };
410
430
  });
@@ -421,6 +441,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
421
441
  gateChecks: repository.gateChecks,
422
442
  triggerEvents: normalizeTriggerEvents(parsed.linear.oauth.actor, repoSettings?.trigger_events ?? repository.triggerEvents),
423
443
  branchPrefix: repoSettings?.branch_prefix ?? repository.branchPrefix ?? defaultBranchPrefix(repository.githubRepo),
444
+ repairBudgets: repository.repairBudgets,
424
445
  ...(repoSettings?.configPath ? { repoSettingsPath: repoSettings.configPath } : {}),
425
446
  github: {
426
447
  repoFullName: repository.githubRepo,
@@ -457,6 +478,11 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
457
478
  triggerEvents: normalizeTriggerEvents(parsed.linear.oauth.actor, repoSettings?.trigger_events ??
458
479
  project.trigger_events),
459
480
  branchPrefix: repoSettings?.branch_prefix ?? project.branch_prefix ?? defaultBranchPrefix(project.id),
481
+ repairBudgets: {
482
+ ciRepair: project.repair_budgets.ci_repair,
483
+ queueRepair: project.repair_budgets.queue_repair,
484
+ reviewFix: project.repair_budgets.review_fix,
485
+ },
460
486
  ...(repoSettings?.configPath ? { repoSettingsPath: repoSettings.configPath } : {}),
461
487
  ...(project.github ? {
462
488
  github: {
@@ -88,6 +88,10 @@ export class IssueStore {
88
88
  sets.push("agent_session_id = @agentSessionId");
89
89
  values.agentSessionId = params.agentSessionId;
90
90
  }
91
+ if (params.lastLinearActivityKey !== undefined) {
92
+ sets.push("last_linear_activity_key = @lastLinearActivityKey");
93
+ values.lastLinearActivityKey = params.lastLinearActivityKey;
94
+ }
91
95
  if (params.prNumber !== undefined) {
92
96
  sets.push("pr_number = @prNumber");
93
97
  values.prNumber = params.prNumber;
@@ -213,7 +217,7 @@ export class IssueStore {
213
217
  priority, estimate,
214
218
  current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
215
219
  branch_name, worktree_path, thread_id, active_run_id, status_comment_id,
216
- agent_session_id,
220
+ agent_session_id, last_linear_activity_key,
217
221
  pr_number, pr_url, pr_state, pr_head_sha, pr_author_login, pr_review_state, pr_check_status, last_blocking_review_head_sha,
218
222
  last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at,
219
223
  last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
@@ -226,7 +230,7 @@ export class IssueStore {
226
230
  @priority, @estimate,
227
231
  @currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
228
232
  @branchName, @worktreePath, @threadId, @activeRunId, @statusCommentId,
229
- @agentSessionId,
233
+ @agentSessionId, @lastLinearActivityKey,
230
234
  @prNumber, @prUrl, @prState, @prHeadSha, @prAuthorLogin, @prReviewState, @prCheckStatus, @lastBlockingReviewHeadSha,
231
235
  @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
232
236
  @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
@@ -256,6 +260,7 @@ export class IssueStore {
256
260
  activeRunId: params.activeRunId ?? null,
257
261
  statusCommentId: params.statusCommentId ?? null,
258
262
  agentSessionId: params.agentSessionId ?? null,
263
+ lastLinearActivityKey: params.lastLinearActivityKey ?? null,
259
264
  prNumber: params.prNumber ?? null,
260
265
  prUrl: params.prUrl ?? null,
261
266
  prState: params.prState ?? null,
@@ -483,6 +488,9 @@ export function mapIssueRow(row) {
483
488
  ...(row.active_run_id !== null ? { activeRunId: Number(row.active_run_id) } : {}),
484
489
  ...(row.status_comment_id !== null && row.status_comment_id !== undefined ? { statusCommentId: String(row.status_comment_id) } : {}),
485
490
  ...(row.agent_session_id !== null ? { agentSessionId: String(row.agent_session_id) } : {}),
491
+ ...(row.last_linear_activity_key !== null && row.last_linear_activity_key !== undefined
492
+ ? { lastLinearActivityKey: String(row.last_linear_activity_key) }
493
+ : {}),
486
494
  updatedAt: String(row.updated_at),
487
495
  ...(row.pr_number !== null && row.pr_number !== undefined ? { prNumber: Number(row.pr_number) } : {}),
488
496
  ...(row.pr_url !== null && row.pr_url !== undefined ? { prUrl: String(row.pr_url) } : {}),
@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS issues (
18
18
  active_run_id INTEGER,
19
19
  status_comment_id TEXT,
20
20
  agent_session_id TEXT,
21
+ last_linear_activity_key TEXT,
21
22
  pr_number INTEGER,
22
23
  pr_url TEXT,
23
24
  pr_state TEXT,
@@ -264,6 +265,7 @@ export function runPatchRelayMigrations(connection) {
264
265
  addColumnIfMissing(connection, "issues", "priority", "INTEGER");
265
266
  addColumnIfMissing(connection, "issues", "estimate", "REAL");
266
267
  addColumnIfMissing(connection, "issues", "status_comment_id", "TEXT");
268
+ addColumnIfMissing(connection, "issues", "last_linear_activity_key", "TEXT");
267
269
  addColumnIfMissing(connection, "issues", "current_linear_state_type", "TEXT");
268
270
  addColumnIfMissing(connection, "issue_dependencies", "blocker_current_linear_state_type", "TEXT");
269
271
  // Zombie/stale recovery backoff
@@ -329,6 +331,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
329
331
  active_run_id INTEGER,
330
332
  status_comment_id TEXT,
331
333
  agent_session_id TEXT,
334
+ last_linear_activity_key TEXT,
332
335
  pr_number INTEGER,
333
336
  pr_url TEXT,
334
337
  pr_state TEXT,
@@ -384,6 +387,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
384
387
  active_run_id,
385
388
  status_comment_id,
386
389
  agent_session_id,
390
+ last_linear_activity_key,
387
391
  pr_number,
388
392
  pr_url,
389
393
  pr_state,
@@ -437,6 +441,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
437
441
  active_run_id,
438
442
  status_comment_id,
439
443
  agent_session_id,
444
+ last_linear_activity_key,
440
445
  pr_number,
441
446
  pr_url,
442
447
  pr_state,
@@ -48,6 +48,7 @@ const TRANSITION_RULES = [
48
48
  to: "changes_requested" },
49
49
  // review_commented: no rule → no transition (informational only)
50
50
  // ── CI check events ────────────────────────────────────────────
51
+ // check_pending: no rule → no transition (metadata / progress only)
51
52
  // After queue repair, return to the merge queue.
52
53
  { event: "check_passed",
53
54
  guard: (s) => s === "repairing_queue",
@@ -4,6 +4,7 @@ import { safeJsonParse } from "./utils.js";
4
4
  import { GitHubPrCommentHandler } from "./github-pr-comment-handler.js";
5
5
  import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver, } from "./github-failure-context.js";
6
6
  import { resolveGitHubWebhookIssue } from "./github-webhook-issue-resolution.js";
7
+ import { maybeCloseLatePublishedImplementationPr } from "./github-webhook-late-publication-guard.js";
7
8
  import { projectGitHubWebhookState } from "./github-webhook-state-projector.js";
8
9
  import { maybeEnqueueGitHubReactiveRun } from "./github-webhook-reactive-run.js";
9
10
  import { handleGitHubTerminalPrEvent } from "./github-webhook-terminal-handler.js";
@@ -98,6 +99,17 @@ export class GitHubWebhookHandler {
98
99
  this.logger.debug({ repoFullName: event.repoFullName, branchName: event.branchName, prNumber: event.prNumber, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching tracked issue");
99
100
  return;
100
101
  }
102
+ const suppressedLatePublication = await maybeCloseLatePublishedImplementationPr({
103
+ db: this.db,
104
+ logger: this.logger,
105
+ feed: this.feed,
106
+ issue,
107
+ event,
108
+ fetchImpl: this.fetchImpl,
109
+ });
110
+ if (suppressedLatePublication) {
111
+ return;
112
+ }
101
113
  const freshIssue = await projectGitHubWebhookState({
102
114
  config: this.config,
103
115
  db: this.db,
@@ -0,0 +1,94 @@
1
+ function isPatchRelayBot(login) {
2
+ return login === "patchrelay[bot]" || login === "app/patchrelay";
3
+ }
4
+ function parseRepo(repoFullName) {
5
+ const [owner, repo] = repoFullName.split("/", 2);
6
+ if (!owner || !repo)
7
+ return undefined;
8
+ return { owner, repo };
9
+ }
10
+ export async function maybeCloseLatePublishedImplementationPr(params) {
11
+ const { db, logger, feed, issue, event, fetchImpl } = params;
12
+ if (event.triggerEvent !== "pr_opened")
13
+ return false;
14
+ if (event.prNumber === undefined)
15
+ return false;
16
+ if (issue.prNumber !== undefined)
17
+ return false;
18
+ if (!isPatchRelayBot(event.prAuthorLogin))
19
+ return false;
20
+ const latestRun = db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
21
+ if (!latestRun || latestRun.runType !== "implementation")
22
+ return false;
23
+ if (latestRun.status === "running" || latestRun.status === "completed")
24
+ return false;
25
+ const repo = parseRepo(event.repoFullName);
26
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
27
+ if (!repo || !token) {
28
+ logger.warn({
29
+ issueKey: issue.issueKey,
30
+ prNumber: event.prNumber,
31
+ latestRunId: latestRun.id,
32
+ latestRunStatus: latestRun.status,
33
+ }, "Late PatchRelay PR was detected after the implementation run had already stopped, but PatchRelay could not auto-close it");
34
+ feed?.publish({
35
+ level: "warn",
36
+ kind: "github",
37
+ issueKey: issue.issueKey,
38
+ projectId: issue.projectId,
39
+ stage: issue.factoryState,
40
+ status: "late_pr_detected",
41
+ summary: `Detected late PR #${event.prNumber} from an inactive implementation run`,
42
+ detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
43
+ });
44
+ return false;
45
+ }
46
+ const response = await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/pulls/${event.prNumber}`, {
47
+ method: "PATCH",
48
+ headers: {
49
+ Authorization: `Bearer ${token}`,
50
+ Accept: "application/vnd.github+json",
51
+ "Content-Type": "application/json",
52
+ "User-Agent": "patchrelay",
53
+ "X-GitHub-Api-Version": "2022-11-28",
54
+ },
55
+ body: JSON.stringify({ state: "closed" }),
56
+ });
57
+ if (!response.ok) {
58
+ logger.warn({
59
+ issueKey: issue.issueKey,
60
+ prNumber: event.prNumber,
61
+ status: response.status,
62
+ latestRunId: latestRun.id,
63
+ latestRunStatus: latestRun.status,
64
+ }, "Failed to auto-close late PatchRelay PR from an inactive implementation run");
65
+ feed?.publish({
66
+ level: "warn",
67
+ kind: "github",
68
+ issueKey: issue.issueKey,
69
+ projectId: issue.projectId,
70
+ stage: issue.factoryState,
71
+ status: "late_pr_close_failed",
72
+ summary: `Could not auto-close late PR #${event.prNumber}`,
73
+ detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
74
+ });
75
+ return false;
76
+ }
77
+ logger.warn({
78
+ issueKey: issue.issueKey,
79
+ prNumber: event.prNumber,
80
+ latestRunId: latestRun.id,
81
+ latestRunStatus: latestRun.status,
82
+ }, "Auto-closed late PatchRelay PR from an inactive implementation run");
83
+ feed?.publish({
84
+ level: "warn",
85
+ kind: "github",
86
+ issueKey: issue.issueKey,
87
+ projectId: issue.projectId,
88
+ stage: issue.factoryState,
89
+ status: "late_pr_closed",
90
+ summary: `Auto-closed late PR #${event.prNumber} from an inactive implementation run`,
91
+ detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
92
+ });
93
+ return true;
94
+ }
@@ -112,6 +112,7 @@ async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotRes
112
112
  deps.db.issues.upsertIssue({
113
113
  projectId: issue.projectId,
114
114
  linearIssueId: issue.linearIssueId,
115
+ prCheckStatus: "pending",
115
116
  lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
116
117
  lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
117
118
  lastGitHubCiSnapshotGateCheckStatus: "pending",
@@ -122,7 +123,7 @@ async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotRes
122
123
  }
123
124
  if (issue.prState !== "open")
124
125
  return;
125
- if (event.eventSource !== "check_run")
126
+ if (event.eventSource !== "check_run" && event.eventSource !== "check_suite")
126
127
  return;
127
128
  if (isQueueEvictionFailure(issue, event, project))
128
129
  return;
@@ -130,6 +131,19 @@ async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotRes
130
131
  return;
131
132
  if (isStaleGateEvent(issue, event))
132
133
  return;
134
+ if (event.triggerEvent === "check_pending") {
135
+ deps.db.issues.upsertIssue({
136
+ projectId: issue.projectId,
137
+ linearIssueId: issue.linearIssueId,
138
+ prCheckStatus: "pending",
139
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
140
+ lastGitHubCiSnapshotGateCheckName: event.checkName ?? getPrimaryGateCheckName(project),
141
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
142
+ lastGitHubCiSnapshotJson: null,
143
+ lastGitHubCiSnapshotSettledAt: null,
144
+ });
145
+ return;
146
+ }
133
147
  const snapshot = await ciSnapshotResolver.resolve({
134
148
  repoFullName: project?.github?.repoFullName ?? event.repoFullName,
135
149
  event,
@@ -114,9 +114,25 @@ function normalizePullRequestReviewEvent(payload, repoFullName) {
114
114
  };
115
115
  }
116
116
  function normalizeCheckSuiteEvent(payload, repoFullName) {
117
- if (payload.action !== "completed")
118
- return undefined;
119
117
  const suite = payload.check_suite;
118
+ if (payload.action !== "completed") {
119
+ if (payload.action !== "requested" && payload.action !== "rerequested" && payload.action !== "in_progress") {
120
+ return undefined;
121
+ }
122
+ const pr = suite.pull_requests?.[0];
123
+ const branchName = pr?.head.ref ?? suite.head_branch ?? "";
124
+ if (!branchName)
125
+ return undefined;
126
+ return {
127
+ triggerEvent: "check_pending",
128
+ repoFullName,
129
+ branchName,
130
+ headSha: suite.head_sha,
131
+ prNumber: pr?.number,
132
+ checkStatus: "pending",
133
+ eventSource: "check_suite",
134
+ };
135
+ }
120
136
  const conclusion = suite.conclusion?.toLowerCase();
121
137
  const pr = suite.pull_requests?.[0];
122
138
  const branchName = pr?.head.ref ?? suite.head_branch ?? "";
@@ -134,9 +150,28 @@ function normalizeCheckSuiteEvent(payload, repoFullName) {
134
150
  };
135
151
  }
136
152
  function normalizeCheckRunEvent(payload, repoFullName) {
137
- if (payload.action !== "completed")
138
- return undefined;
139
153
  const run = payload.check_run;
154
+ if (payload.action !== "completed") {
155
+ if (payload.action !== "requested" && payload.action !== "rerequested" && payload.action !== "in_progress") {
156
+ return undefined;
157
+ }
158
+ const pr = run.check_suite?.pull_requests?.[0];
159
+ const branchName = pr?.head.ref ?? run.check_suite?.head_branch ?? "";
160
+ if (!branchName)
161
+ return undefined;
162
+ return {
163
+ triggerEvent: "check_pending",
164
+ repoFullName,
165
+ branchName,
166
+ headSha: run.head_sha,
167
+ prNumber: pr?.number,
168
+ checkStatus: "pending",
169
+ checkName: run.name,
170
+ checkUrl: run.html_url,
171
+ checkDetailsUrl: run.details_url,
172
+ eventSource: "check_run",
173
+ };
174
+ }
140
175
  const conclusion = run.conclusion?.toLowerCase();
141
176
  const pr = run.check_suite?.pull_requests?.[0];
142
177
  const branchName = pr?.head.ref ?? run.check_suite?.head_branch ?? "";
@@ -0,0 +1,18 @@
1
+ import { execCommand } from "./utils.js";
2
+ function shellSingleQuote(value) {
3
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
4
+ }
5
+ export function buildGitHubBotCredentialHelper(tokenFile) {
6
+ const quotedTokenFile = shellSingleQuote(tokenFile);
7
+ return `!f() { [ "$1" = get ] || exit 0; echo "username=x-access-token"; echo "password=$(cat ${quotedTokenFile})"; }; f`;
8
+ }
9
+ export async function configureGitHubBotAuthForWorktree(params) {
10
+ const helper = buildGitHubBotCredentialHelper(params.botIdentity.tokenFile);
11
+ const gitArgs = ["-C", params.worktreePath, "config"];
12
+ await execCommand(params.gitBin, [...gitArgs, "user.name", params.botIdentity.name], { timeoutMs: 5_000 });
13
+ await execCommand(params.gitBin, [...gitArgs, "user.email", params.botIdentity.email], { timeoutMs: 5_000 });
14
+ // Clear inherited GitHub-specific helpers such as `gh auth git-credential`
15
+ // so git HTTPS operations use the same bot token as the wrapped `gh` CLI.
16
+ await execCommand(params.gitBin, [...gitArgs, "--replace-all", "credential.https://github.com.helper", ""], { timeoutMs: 5_000 });
17
+ await execCommand(params.gitBin, [...gitArgs, "--add", "credential.https://github.com.helper", helper], { timeoutMs: 5_000 });
18
+ }
package/dist/http.js CHANGED
@@ -314,6 +314,23 @@ export async function buildHttpServer(config, service, logger) {
314
314
  }
315
315
  return reply.send({ ok: true, ...result });
316
316
  });
317
+ app.post("/api/issues/:issueKey/close", async (request, reply) => {
318
+ const issueKey = request.params.issueKey;
319
+ const body = request.body;
320
+ const result = await service.closeIssue(issueKey, {
321
+ failed: body?.failed === true,
322
+ ...(typeof body?.reason === "string" && body.reason.trim()
323
+ ? { reason: body.reason.trim() }
324
+ : {}),
325
+ });
326
+ if (!result) {
327
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
328
+ }
329
+ if ("error" in result) {
330
+ return reply.code(409).send({ ok: false, reason: result.error });
331
+ }
332
+ return reply.send({ ok: true, ...result });
333
+ });
317
334
  app.get("/api/installations", async (_request, reply) => {
318
335
  return reply.send({ ok: true, installations: service.listLinearInstallations() });
319
336
  });