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.
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +4 -0
- package/dist/cli/commands/issues.js +20 -1
- package/dist/cli/data.js +54 -7
- package/dist/cli/formatters/text.js +10 -0
- package/dist/cli/help.js +4 -0
- package/dist/cli/index.js +3 -0
- package/dist/config.js +26 -0
- package/dist/db/issue-store.js +10 -2
- package/dist/db/migrations.js +5 -0
- package/dist/factory-state.js +1 -0
- package/dist/github-webhook-handler.js +12 -0
- package/dist/github-webhook-late-publication-guard.js +94 -0
- package/dist/github-webhook-state-projector.js +15 -1
- package/dist/github-webhooks.js +39 -4
- package/dist/github-worktree-auth.js +18 -0
- package/dist/http.js +17 -0
- package/dist/idle-reconciliation.js +4 -2
- package/dist/issue-session-events.js +1 -0
- package/dist/linear-activity-key.js +11 -0
- package/dist/linear-agent-session-client.js +14 -1
- package/dist/linear-progress-facts.js +170 -0
- package/dist/linear-progress-reporter.js +21 -168
- package/dist/linear-status-comment-sync.js +3 -19
- package/dist/linear-workflow-state-sync.js +37 -18
- package/dist/manual-issue-actions.js +37 -0
- package/dist/merged-linear-completion-reconciler.js +102 -22
- package/dist/no-pr-completion-check.js +52 -0
- package/dist/presentation-text.js +11 -1
- package/dist/prompting/patchrelay.js +8 -6
- package/dist/run-budgets.js +12 -0
- package/dist/run-launcher.js +6 -6
- package/dist/run-notification-handler.js +4 -0
- package/dist/run-orchestrator.js +7 -1
- package/dist/run-wake-planner.js +11 -10
- package/dist/service-issue-actions.js +80 -27
- package/dist/service.js +3 -0
- package/dist/trusted-no-pr-completion.js +7 -0
- package/dist/webhooks/desired-stage-recorder.js +34 -10
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
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
|
-
?? (
|
|
204
|
-
|
|
205
|
-
: dbIssue.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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: {
|
package/dist/db/issue-store.js
CHANGED
|
@@ -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) } : {}),
|
package/dist/db/migrations.js
CHANGED
|
@@ -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,
|
package/dist/factory-state.js
CHANGED
|
@@ -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,
|
package/dist/github-webhooks.js
CHANGED
|
@@ -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
|
});
|