patchrelay 0.18.0 → 0.20.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/agent-session-plan.js +17 -17
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/App.js +43 -4
- package/dist/cli/watch/HelpBar.js +1 -1
- package/dist/cli/watch/TimelineRow.js +11 -2
- package/dist/config.js +6 -0
- package/dist/db.js +10 -2
- package/dist/github-webhook-handler.js +70 -3
- package/dist/http.js +16 -0
- package/dist/run-orchestrator.js +45 -28
- package/dist/service.js +19 -1
- package/package.json +1 -1
|
@@ -14,33 +14,33 @@ export function formatRunTypeLabel(runType) {
|
|
|
14
14
|
function implementationPlan() {
|
|
15
15
|
return [
|
|
16
16
|
{ content: "Prepare workspace", status: "pending" },
|
|
17
|
-
{ content: "
|
|
18
|
-
{ content: "
|
|
19
|
-
{ content: "
|
|
17
|
+
{ content: "Implementing", status: "pending" },
|
|
18
|
+
{ content: "Awaiting verification", status: "pending" },
|
|
19
|
+
{ content: "Merge", status: "pending" },
|
|
20
20
|
];
|
|
21
21
|
}
|
|
22
22
|
function reviewFixPlan() {
|
|
23
23
|
return [
|
|
24
24
|
{ content: "Prepare workspace", status: "completed" },
|
|
25
|
-
{ content: "
|
|
26
|
-
{ content: "
|
|
27
|
-
{ content: "
|
|
25
|
+
{ content: "Addressing review feedback", status: "pending" },
|
|
26
|
+
{ content: "Awaiting re-verification", status: "pending" },
|
|
27
|
+
{ content: "Merge", status: "pending" },
|
|
28
28
|
];
|
|
29
29
|
}
|
|
30
30
|
function ciRepairPlan(attempt) {
|
|
31
31
|
return [
|
|
32
32
|
{ content: "Prepare workspace", status: "completed" },
|
|
33
|
-
{ content: "
|
|
34
|
-
{ content: `
|
|
35
|
-
{ content: "
|
|
33
|
+
{ content: "Implementing", status: "completed" },
|
|
34
|
+
{ content: `Repairing checks (${attemptLabel(attempt)})`, status: "pending" },
|
|
35
|
+
{ content: "Merge", status: "pending" },
|
|
36
36
|
];
|
|
37
37
|
}
|
|
38
38
|
function queueRepairPlan(attempt) {
|
|
39
39
|
return [
|
|
40
40
|
{ content: "Prepare workspace", status: "completed" },
|
|
41
|
-
{ content: "
|
|
42
|
-
{ content: "
|
|
43
|
-
{ content: `
|
|
41
|
+
{ content: "Implementing", status: "completed" },
|
|
42
|
+
{ content: "Verification passed", status: "completed" },
|
|
43
|
+
{ content: `Repairing merge (${attemptLabel(attempt)})`, status: "pending" },
|
|
44
44
|
];
|
|
45
45
|
}
|
|
46
46
|
function awaitingInputPlan() {
|
|
@@ -101,9 +101,9 @@ export function buildAgentSessionPlan(params) {
|
|
|
101
101
|
case "awaiting_queue":
|
|
102
102
|
return setStatuses([
|
|
103
103
|
{ content: "Prepare workspace", status: "completed" },
|
|
104
|
-
{ content: "
|
|
105
|
-
{ content: "
|
|
106
|
-
{ content: "
|
|
104
|
+
{ content: "Implementing", status: "completed" },
|
|
105
|
+
{ content: "Verification passed", status: "completed" },
|
|
106
|
+
{ content: "Awaiting merge", status: "inProgress" },
|
|
107
107
|
], ["completed", "completed", "completed", "inProgress"]);
|
|
108
108
|
case "repairing_queue":
|
|
109
109
|
return setStatuses(queueRepairPlan(params.queueRepairAttempts ?? 1), ["completed", "completed", "completed", "inProgress"]);
|
|
@@ -116,8 +116,8 @@ export function buildAgentSessionPlan(params) {
|
|
|
116
116
|
case "done":
|
|
117
117
|
return setStatuses([
|
|
118
118
|
{ content: "Prepare workspace", status: "completed" },
|
|
119
|
-
{ content: "
|
|
120
|
-
{ content: "
|
|
119
|
+
{ content: "Implementing", status: "completed" },
|
|
120
|
+
{ content: "Verification passed", status: "completed" },
|
|
121
121
|
{ content: "Merged", status: "completed" },
|
|
122
122
|
], ["completed", "completed", "completed", "completed"]);
|
|
123
123
|
}
|
package/dist/build-info.json
CHANGED
package/dist/cli/watch/App.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useReducer, useMemo, useCallback } from "react";
|
|
3
|
-
import { Box, useApp, useInput } from "ink";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useReducer, useMemo, useCallback, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
4
|
import { watchReducer, initialWatchState, filterIssues } from "./watch-state.js";
|
|
5
5
|
import { useWatchStream } from "./use-watch-stream.js";
|
|
6
6
|
import { useDetailStream } from "./use-detail-stream.js";
|
|
@@ -8,6 +8,17 @@ import { useFeedStream } from "./use-feed-stream.js";
|
|
|
8
8
|
import { IssueListView } from "./IssueListView.js";
|
|
9
9
|
import { IssueDetailView } from "./IssueDetailView.js";
|
|
10
10
|
import { FeedView } from "./FeedView.js";
|
|
11
|
+
async function postPrompt(baseUrl, issueKey, text, bearerToken) {
|
|
12
|
+
const headers = { "content-type": "application/json" };
|
|
13
|
+
if (bearerToken)
|
|
14
|
+
headers.authorization = `Bearer ${bearerToken}`;
|
|
15
|
+
await fetch(new URL(`/api/issues/${encodeURIComponent(issueKey)}/prompt`, baseUrl), {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers,
|
|
18
|
+
body: JSON.stringify({ text }),
|
|
19
|
+
signal: AbortSignal.timeout(5000),
|
|
20
|
+
}).catch(() => { });
|
|
21
|
+
}
|
|
11
22
|
async function postRetry(baseUrl, issueKey, bearerToken) {
|
|
12
23
|
const headers = { "content-type": "application/json" };
|
|
13
24
|
if (bearerToken)
|
|
@@ -28,12 +39,37 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
28
39
|
useWatchStream({ baseUrl, bearerToken, dispatch });
|
|
29
40
|
useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
|
|
30
41
|
useFeedStream({ baseUrl, bearerToken, active: state.view === "feed", dispatch });
|
|
42
|
+
const [promptMode, setPromptMode] = useState(false);
|
|
43
|
+
const [promptBuffer, setPromptBuffer] = useState("");
|
|
31
44
|
const handleRetry = useCallback(() => {
|
|
32
45
|
if (state.activeDetailKey) {
|
|
33
46
|
void postRetry(baseUrl, state.activeDetailKey, bearerToken);
|
|
34
47
|
}
|
|
35
48
|
}, [baseUrl, bearerToken, state.activeDetailKey]);
|
|
49
|
+
const handlePromptSubmit = useCallback(() => {
|
|
50
|
+
if (state.activeDetailKey && promptBuffer.trim()) {
|
|
51
|
+
void postPrompt(baseUrl, state.activeDetailKey, promptBuffer.trim(), bearerToken);
|
|
52
|
+
}
|
|
53
|
+
setPromptMode(false);
|
|
54
|
+
setPromptBuffer("");
|
|
55
|
+
}, [baseUrl, bearerToken, state.activeDetailKey, promptBuffer]);
|
|
36
56
|
useInput((input, key) => {
|
|
57
|
+
if (promptMode) {
|
|
58
|
+
if (key.escape) {
|
|
59
|
+
setPromptMode(false);
|
|
60
|
+
setPromptBuffer("");
|
|
61
|
+
}
|
|
62
|
+
else if (key.return) {
|
|
63
|
+
handlePromptSubmit();
|
|
64
|
+
}
|
|
65
|
+
else if (key.backspace || key.delete) {
|
|
66
|
+
setPromptBuffer((b) => b.slice(0, -1));
|
|
67
|
+
}
|
|
68
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
69
|
+
setPromptBuffer((b) => b + input);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
37
73
|
if (input === "q") {
|
|
38
74
|
exit();
|
|
39
75
|
return;
|
|
@@ -68,6 +104,9 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
68
104
|
else if (input === "r") {
|
|
69
105
|
handleRetry();
|
|
70
106
|
}
|
|
107
|
+
else if (input === "p") {
|
|
108
|
+
setPromptMode(true);
|
|
109
|
+
}
|
|
71
110
|
else if (input === "j" || key.downArrow) {
|
|
72
111
|
dispatch({ type: "detail-navigate", direction: "next", filtered });
|
|
73
112
|
}
|
|
@@ -81,5 +120,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
81
120
|
}
|
|
82
121
|
}
|
|
83
122
|
});
|
|
84
|
-
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, allIssues: filtered, activeDetailKey: state.activeDetailKey })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
|
|
123
|
+
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, allIssues: filtered, activeDetailKey: state.activeDetailKey }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] }))] })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
|
|
85
124
|
}
|
|
@@ -7,7 +7,7 @@ const HELP_TEXT = {
|
|
|
7
7
|
};
|
|
8
8
|
export function HelpBar({ view, follow }) {
|
|
9
9
|
const text = view === "detail"
|
|
10
|
-
? `j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} r: retry q: quit`
|
|
10
|
+
? `j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} p: prompt r: retry q: quit`
|
|
11
11
|
: HELP_TEXT[view];
|
|
12
12
|
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
|
|
13
13
|
}
|
|
@@ -28,15 +28,24 @@ function FeedRow({ entry }) {
|
|
|
28
28
|
const statusLabel = feed.status ?? feed.feedKind;
|
|
29
29
|
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: "cyan", children: statusLabel.padEnd(16) }), _jsx(Text, { children: feed.summary })] }));
|
|
30
30
|
}
|
|
31
|
+
const RUN_TYPE_LABELS = {
|
|
32
|
+
implementation: "implementing",
|
|
33
|
+
ci_repair: "repairing checks",
|
|
34
|
+
review_fix: "addressing feedback",
|
|
35
|
+
queue_repair: "repairing merge",
|
|
36
|
+
};
|
|
37
|
+
function runLabel(runType) {
|
|
38
|
+
return RUN_TYPE_LABELS[runType] ?? runType;
|
|
39
|
+
}
|
|
31
40
|
function RunStartRow({ entry }) {
|
|
32
41
|
const run = entry.run;
|
|
33
|
-
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: "yellow", children: run.runType.padEnd(
|
|
42
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: "yellow", children: runLabel(run.runType).padEnd(20) }), _jsx(Text, { bold: true, children: "started" })] }));
|
|
34
43
|
}
|
|
35
44
|
function RunEndRow({ entry }) {
|
|
36
45
|
const run = entry.run;
|
|
37
46
|
const color = run.status === "completed" ? "green" : "red";
|
|
38
47
|
const duration = run.endedAt ? formatDuration(run.startedAt, run.endedAt) : "";
|
|
39
|
-
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: color, children: run.runType.padEnd(
|
|
48
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: color, children: runLabel(run.runType).padEnd(20) }), _jsx(Text, { bold: true, color: color, children: run.status }), duration ? _jsxs(Text, { dimColor: true, children: ["(", duration, ")"] }) : null] }));
|
|
40
49
|
}
|
|
41
50
|
function ItemRow({ entry }) {
|
|
42
51
|
const item = entry.item;
|
package/dist/config.js
CHANGED
|
@@ -30,6 +30,10 @@ const projectSchema = z.object({
|
|
|
30
30
|
allow_labels: z.array(z.string().min(1)).default([]),
|
|
31
31
|
trigger_events: z.array(z.string().min(1)).min(1).optional(),
|
|
32
32
|
branch_prefix: z.string().min(1).optional(),
|
|
33
|
+
/** Check names that are review gates (AI Review, quality analysis). Default: code class. */
|
|
34
|
+
review_checks: z.array(z.string().min(1)).default([]),
|
|
35
|
+
/** Check names that are policy gates (conventional title, release policy). Default: code class. */
|
|
36
|
+
gate_checks: z.array(z.string().min(1)).default([]),
|
|
33
37
|
github: z.object({
|
|
34
38
|
webhook_secret: z.string().min(1).optional(),
|
|
35
39
|
repo_full_name: z.string().min(1).optional(),
|
|
@@ -394,6 +398,8 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
394
398
|
issueKeyPrefixes: project.issue_key_prefixes,
|
|
395
399
|
linearTeamIds: project.linear_team_ids,
|
|
396
400
|
allowLabels: project.allow_labels,
|
|
401
|
+
reviewChecks: project.review_checks,
|
|
402
|
+
gateChecks: project.gate_checks,
|
|
397
403
|
triggerEvents: normalizeTriggerEvents(parsed.linear.oauth.actor, repoSettings?.trigger_events ??
|
|
398
404
|
project.trigger_events),
|
|
399
405
|
branchPrefix: repoSettings?.branch_prefix ?? project.branch_prefix ?? defaultBranchPrefix(project.id),
|
package/dist/db.js
CHANGED
|
@@ -233,6 +233,10 @@ export class PatchRelayDatabase {
|
|
|
233
233
|
const row = this.connection.prepare("SELECT * FROM issues WHERE branch_name = ?").get(branchName);
|
|
234
234
|
return row ? mapIssueRow(row) : undefined;
|
|
235
235
|
}
|
|
236
|
+
getIssueByPrNumber(prNumber) {
|
|
237
|
+
const row = this.connection.prepare("SELECT * FROM issues WHERE pr_number = ?").get(prNumber);
|
|
238
|
+
return row ? mapIssueRow(row) : undefined;
|
|
239
|
+
}
|
|
236
240
|
listIssuesReadyForExecution() {
|
|
237
241
|
const rows = this.connection
|
|
238
242
|
.prepare("SELECT project_id, linear_issue_id FROM issues WHERE (pending_run_type IS NOT NULL OR pending_merge_prep = 1) AND active_run_id IS NULL")
|
|
@@ -246,9 +250,13 @@ export class PatchRelayDatabase {
|
|
|
246
250
|
* Issues idle in pr_open with no active run — candidates for state
|
|
247
251
|
* advancement based on stored PR metadata (missed GitHub webhooks).
|
|
248
252
|
*/
|
|
249
|
-
|
|
253
|
+
listIdleNonTerminalIssues() {
|
|
250
254
|
const rows = this.connection
|
|
251
|
-
.prepare(
|
|
255
|
+
.prepare(`SELECT * FROM issues
|
|
256
|
+
WHERE factory_state NOT IN ('done', 'escalated', 'failed', 'awaiting_input')
|
|
257
|
+
AND active_run_id IS NULL
|
|
258
|
+
AND pending_run_type IS NULL
|
|
259
|
+
AND pr_number IS NOT NULL`)
|
|
252
260
|
.all();
|
|
253
261
|
return rows.map(mapIssueRow);
|
|
254
262
|
}
|
|
@@ -25,14 +25,16 @@ export class GitHubWebhookHandler {
|
|
|
25
25
|
enqueueIssue;
|
|
26
26
|
mergeQueue;
|
|
27
27
|
logger;
|
|
28
|
+
codex;
|
|
28
29
|
feed;
|
|
29
|
-
constructor(config, db, linearProvider, enqueueIssue, mergeQueue, logger, feed) {
|
|
30
|
+
constructor(config, db, linearProvider, enqueueIssue, mergeQueue, logger, codex, feed) {
|
|
30
31
|
this.config = config;
|
|
31
32
|
this.db = db;
|
|
32
33
|
this.linearProvider = linearProvider;
|
|
33
34
|
this.enqueueIssue = enqueueIssue;
|
|
34
35
|
this.mergeQueue = mergeQueue;
|
|
35
36
|
this.logger = logger;
|
|
37
|
+
this.codex = codex;
|
|
36
38
|
this.feed = feed;
|
|
37
39
|
}
|
|
38
40
|
async acceptGitHubWebhook(params) {
|
|
@@ -100,6 +102,10 @@ export class GitHubWebhookHandler {
|
|
|
100
102
|
}
|
|
101
103
|
return;
|
|
102
104
|
}
|
|
105
|
+
if (params.eventType === "issue_comment") {
|
|
106
|
+
await this.handlePrComment(payload);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
103
109
|
const event = normalizeGitHubWebhook({
|
|
104
110
|
eventType: params.eventType,
|
|
105
111
|
payload: payload,
|
|
@@ -182,10 +188,11 @@ export class GitHubWebhookHandler {
|
|
|
182
188
|
detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
|
|
183
189
|
});
|
|
184
190
|
if (!isMetadataOnlyCheckEvent(event)) {
|
|
185
|
-
this.
|
|
191
|
+
const project = this.config.projects.find((p) => p.id === freshIssue.projectId);
|
|
192
|
+
this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
186
193
|
}
|
|
187
194
|
}
|
|
188
|
-
maybeEnqueueReactiveRun(issue, event) {
|
|
195
|
+
maybeEnqueueReactiveRun(issue, event, project) {
|
|
189
196
|
// Don't trigger if there's already an active run
|
|
190
197
|
if (issue.activeRunId !== undefined)
|
|
191
198
|
return;
|
|
@@ -197,6 +204,7 @@ export class GitHubWebhookHandler {
|
|
|
197
204
|
pendingRunContextJson: JSON.stringify({
|
|
198
205
|
checkName: event.checkName,
|
|
199
206
|
checkUrl: event.checkUrl,
|
|
207
|
+
checkClass: resolveCheckClass(event.checkName, project),
|
|
200
208
|
}),
|
|
201
209
|
});
|
|
202
210
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
@@ -280,4 +288,63 @@ export class GitHubWebhookHandler {
|
|
|
280
288
|
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear session from GitHub webhook");
|
|
281
289
|
}
|
|
282
290
|
}
|
|
291
|
+
async handlePrComment(payload) {
|
|
292
|
+
if (payload.action !== "created")
|
|
293
|
+
return;
|
|
294
|
+
const issuePayload = payload.issue;
|
|
295
|
+
const comment = payload.comment;
|
|
296
|
+
if (!issuePayload || !comment)
|
|
297
|
+
return;
|
|
298
|
+
if (!issuePayload.pull_request)
|
|
299
|
+
return; // only PR comments
|
|
300
|
+
const body = typeof comment.body === "string" ? comment.body : "";
|
|
301
|
+
if (!body.trim())
|
|
302
|
+
return;
|
|
303
|
+
const user = comment.user;
|
|
304
|
+
const author = typeof user?.login === "string" ? user.login : "unknown";
|
|
305
|
+
if (typeof user?.type === "string" && user.type === "Bot")
|
|
306
|
+
return;
|
|
307
|
+
const prNumber = typeof issuePayload.number === "number" ? issuePayload.number : undefined;
|
|
308
|
+
if (!prNumber)
|
|
309
|
+
return;
|
|
310
|
+
const issue = this.db.getIssueByPrNumber(prNumber);
|
|
311
|
+
if (!issue)
|
|
312
|
+
return;
|
|
313
|
+
this.feed?.publish({
|
|
314
|
+
level: "info",
|
|
315
|
+
kind: "comment",
|
|
316
|
+
issueKey: issue.issueKey,
|
|
317
|
+
projectId: issue.projectId,
|
|
318
|
+
stage: issue.factoryState,
|
|
319
|
+
status: "pr_comment",
|
|
320
|
+
summary: `GitHub PR comment from ${author}`,
|
|
321
|
+
detail: body.slice(0, 200),
|
|
322
|
+
});
|
|
323
|
+
if (issue.activeRunId) {
|
|
324
|
+
const run = this.db.getRun(issue.activeRunId);
|
|
325
|
+
if (run?.threadId && run.turnId) {
|
|
326
|
+
try {
|
|
327
|
+
await this.codex.steerTurn({
|
|
328
|
+
threadId: run.threadId,
|
|
329
|
+
turnId: run.turnId,
|
|
330
|
+
input: `GitHub PR comment from ${author}:\n\n${body}`,
|
|
331
|
+
});
|
|
332
|
+
this.logger.info({ issueKey: issue.issueKey, author }, "Forwarded GitHub PR comment to active run");
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
336
|
+
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to forward GitHub PR comment");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function resolveCheckClass(checkName, project) {
|
|
343
|
+
if (!checkName || !project)
|
|
344
|
+
return "code";
|
|
345
|
+
if (project.reviewChecks.some((name) => checkName.includes(name)))
|
|
346
|
+
return "review";
|
|
347
|
+
if (project.gateChecks.some((name) => checkName.includes(name)))
|
|
348
|
+
return "gate";
|
|
349
|
+
return "code";
|
|
283
350
|
}
|
package/dist/http.js
CHANGED
|
@@ -309,6 +309,22 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
309
309
|
}
|
|
310
310
|
return reply.send({ ok: true, ...result });
|
|
311
311
|
});
|
|
312
|
+
app.post("/api/issues/:issueKey/prompt", async (request, reply) => {
|
|
313
|
+
const issueKey = request.params.issueKey;
|
|
314
|
+
const body = request.body;
|
|
315
|
+
const text = body?.text;
|
|
316
|
+
if (!text || typeof text !== "string") {
|
|
317
|
+
return reply.code(400).send({ ok: false, reason: "missing text field" });
|
|
318
|
+
}
|
|
319
|
+
const result = await service.promptIssue(issueKey, text);
|
|
320
|
+
if (!result) {
|
|
321
|
+
return reply.code(404).send({ ok: false, reason: "issue_not_found" });
|
|
322
|
+
}
|
|
323
|
+
if ("error" in result) {
|
|
324
|
+
return reply.code(409).send({ ok: false, reason: result.error });
|
|
325
|
+
}
|
|
326
|
+
return reply.send({ ok: true, delivered: true });
|
|
327
|
+
});
|
|
312
328
|
app.get("/api/feed", async (request, reply) => {
|
|
313
329
|
const feedQuery = {
|
|
314
330
|
limit: getPositiveIntegerQueryParam(request, "limit") ?? 50,
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -410,53 +410,70 @@ export class RunOrchestrator {
|
|
|
410
410
|
}
|
|
411
411
|
// Advance issues stuck in pr_open whose stored PR metadata already
|
|
412
412
|
// shows they should transition (e.g. approved PR, missed webhook).
|
|
413
|
-
await this.
|
|
413
|
+
await this.reconcileIdleIssues();
|
|
414
414
|
}
|
|
415
|
-
async
|
|
416
|
-
for (const issue of this.db.
|
|
415
|
+
async reconcileIdleIssues() {
|
|
416
|
+
for (const issue of this.db.listIdleNonTerminalIssues()) {
|
|
417
|
+
// PR already merged — advance to done regardless of current state
|
|
417
418
|
if (issue.prState === "merged") {
|
|
418
419
|
this.advanceIdleIssue(issue, "done");
|
|
419
420
|
continue;
|
|
420
421
|
}
|
|
421
|
-
|
|
422
|
+
// Review approved + checks not failed — advance to awaiting_queue
|
|
423
|
+
if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
|
|
422
424
|
this.advanceIdleIssue(issue, "awaiting_queue");
|
|
423
425
|
continue;
|
|
424
426
|
}
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
427
|
+
// Checks failed + idle (not already in a repair state) — enqueue ci_repair
|
|
428
|
+
if (issue.prCheckStatus === "failed" && issue.factoryState !== "repairing_ci") {
|
|
429
|
+
this.advanceIdleIssue(issue, "repairing_ci", "ci_repair");
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
// Awaiting queue with stale pending merge prep — re-enqueue
|
|
433
|
+
if (issue.factoryState === "awaiting_queue" && issue.pendingMergePrep) {
|
|
434
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
429
435
|
continue;
|
|
430
|
-
try {
|
|
431
|
-
const { stdout } = await execCommand("gh", [
|
|
432
|
-
"pr", "view", String(issue.prNumber),
|
|
433
|
-
"--repo", project.github.repoFullName,
|
|
434
|
-
"--json", "state,reviewDecision",
|
|
435
|
-
], { timeoutMs: 10_000 });
|
|
436
|
-
const pr = JSON.parse(stdout);
|
|
437
|
-
if (pr.state === "MERGED") {
|
|
438
|
-
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
439
|
-
this.advanceIdleIssue(issue, "done");
|
|
440
|
-
}
|
|
441
|
-
else if (pr.reviewDecision === "APPROVED") {
|
|
442
|
-
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
|
|
443
|
-
this.advanceIdleIssue(issue, "awaiting_queue");
|
|
444
|
-
}
|
|
445
436
|
}
|
|
446
|
-
|
|
447
|
-
|
|
437
|
+
// For pr_open issues with no review decision, check GitHub for stale metadata
|
|
438
|
+
if (issue.factoryState === "pr_open" && !issue.prReviewState) {
|
|
439
|
+
await this.reconcileFromGitHub(issue);
|
|
448
440
|
}
|
|
449
441
|
}
|
|
450
442
|
}
|
|
451
|
-
|
|
452
|
-
this.
|
|
443
|
+
async reconcileFromGitHub(issue) {
|
|
444
|
+
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
445
|
+
if (!project?.github?.repoFullName || !issue.prNumber)
|
|
446
|
+
return;
|
|
447
|
+
try {
|
|
448
|
+
const { stdout } = await execCommand("gh", [
|
|
449
|
+
"pr", "view", String(issue.prNumber),
|
|
450
|
+
"--repo", project.github.repoFullName,
|
|
451
|
+
"--json", "state,reviewDecision",
|
|
452
|
+
], { timeoutMs: 10_000 });
|
|
453
|
+
const pr = JSON.parse(stdout);
|
|
454
|
+
if (pr.state === "MERGED") {
|
|
455
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
456
|
+
this.advanceIdleIssue(issue, "done");
|
|
457
|
+
}
|
|
458
|
+
else if (pr.reviewDecision === "APPROVED") {
|
|
459
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
|
|
460
|
+
this.advanceIdleIssue(issue, "awaiting_queue");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
advanceIdleIssue(issue, newState, pendingRunType) {
|
|
468
|
+
this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType }, "Reconciliation: advancing idle issue");
|
|
453
469
|
this.db.upsertIssue({
|
|
454
470
|
projectId: issue.projectId,
|
|
455
471
|
linearIssueId: issue.linearIssueId,
|
|
456
472
|
factoryState: newState,
|
|
457
473
|
...(newState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
|
|
474
|
+
...(pendingRunType ? { pendingRunType: pendingRunType } : {}),
|
|
458
475
|
});
|
|
459
|
-
if (newState === "awaiting_queue") {
|
|
476
|
+
if (newState === "awaiting_queue" || pendingRunType) {
|
|
460
477
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
461
478
|
}
|
|
462
479
|
}
|
package/dist/service.js
CHANGED
|
@@ -57,7 +57,7 @@ export class PatchRelayService {
|
|
|
57
57
|
})();
|
|
58
58
|
});
|
|
59
59
|
this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
|
|
60
|
-
this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, this.feed);
|
|
60
|
+
this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, codex, this.feed);
|
|
61
61
|
const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
|
|
62
62
|
processIssue: async (item) => {
|
|
63
63
|
const issue = db.getIssue(item.projectId, item.issueId);
|
|
@@ -218,6 +218,24 @@ export class PatchRelayService {
|
|
|
218
218
|
this.codex.on("notification", handler);
|
|
219
219
|
return () => { this.codex.off("notification", handler); };
|
|
220
220
|
}
|
|
221
|
+
async promptIssue(issueKey, text) {
|
|
222
|
+
const issue = this.db.getIssueByKey(issueKey);
|
|
223
|
+
if (!issue)
|
|
224
|
+
return undefined;
|
|
225
|
+
if (!issue.activeRunId)
|
|
226
|
+
return { error: "No active run" };
|
|
227
|
+
const run = this.db.getRun(issue.activeRunId);
|
|
228
|
+
if (!run?.threadId || !run.turnId)
|
|
229
|
+
return { error: "No active thread or turn" };
|
|
230
|
+
try {
|
|
231
|
+
await this.codex.steerTurn({ threadId: run.threadId, turnId: run.turnId, input: `Operator prompt from watch TUI:\n\n${text}` });
|
|
232
|
+
return { delivered: true };
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
236
|
+
return { error: `Failed to deliver prompt: ${msg}` };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
221
239
|
retryIssue(issueKey) {
|
|
222
240
|
const issue = this.db.getIssueByKey(issueKey);
|
|
223
241
|
if (!issue)
|