patchrelay 0.7.9 → 0.8.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/commands/feed.js +17 -10
- package/dist/cli/formatters/text.js +16 -3
- package/dist/cli/help.js +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/operator-client.js +16 -0
- package/dist/config.js +169 -56
- package/dist/db/authoritative-ledger-store.js +6 -2
- package/dist/db/issue-workflow-coordinator.js +11 -0
- package/dist/db/issue-workflow-store.js +1 -0
- package/dist/db/migrations.js +22 -1
- package/dist/db/operator-feed-store.js +21 -3
- package/dist/db/webhook-event-store.js +13 -0
- package/dist/http.js +20 -10
- package/dist/install.js +18 -3
- package/dist/linear-workflow.js +20 -5
- package/dist/operator-feed.js +30 -12
- package/dist/preflight.js +5 -2
- package/dist/reconciliation-snapshot-builder.js +2 -1
- package/dist/service-stage-finalizer.js +243 -2
- package/dist/service-stage-runner.js +60 -42
- package/dist/service-webhook-processor.js +87 -11
- package/dist/service.js +1 -0
- package/dist/stage-failure.js +3 -3
- package/dist/stage-handoff.js +107 -0
- package/dist/stage-launch.js +38 -8
- package/dist/stage-lifecycle-publisher.js +37 -12
- package/dist/webhook-agent-session-handler.js +11 -3
- package/dist/webhook-desired-stage-recorder.js +24 -4
- package/dist/workflow-policy.js +115 -8
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -15,23 +15,30 @@ function parseLimit(value) {
|
|
|
15
15
|
}
|
|
16
16
|
return parsed;
|
|
17
17
|
}
|
|
18
|
+
function readOptionalStringFlag(parsed, name) {
|
|
19
|
+
const value = parsed.flags.get(name);
|
|
20
|
+
if (value === true) {
|
|
21
|
+
throw new Error(`--${name} requires a value.`);
|
|
22
|
+
}
|
|
23
|
+
return typeof value === "string" ? value.trim() || undefined : undefined;
|
|
24
|
+
}
|
|
18
25
|
export async function handleFeedCommand(params) {
|
|
19
26
|
const limit = parseLimit(params.parsed.flags.get("limit"));
|
|
20
27
|
const follow = params.parsed.flags.get("follow") === true;
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
throw new Error("--project requires a value.");
|
|
28
|
-
}
|
|
29
|
-
const issueKey = typeof issueFlag === "string" ? issueFlag.trim() || undefined : undefined;
|
|
30
|
-
const projectId = typeof projectFlag === "string" ? projectFlag.trim() || undefined : undefined;
|
|
28
|
+
const issueKey = readOptionalStringFlag(params.parsed, "issue");
|
|
29
|
+
const projectId = readOptionalStringFlag(params.parsed, "project");
|
|
30
|
+
const kind = readOptionalStringFlag(params.parsed, "kind");
|
|
31
|
+
const stage = readOptionalStringFlag(params.parsed, "stage");
|
|
32
|
+
const status = readOptionalStringFlag(params.parsed, "status");
|
|
33
|
+
const workflowId = readOptionalStringFlag(params.parsed, "workflow");
|
|
31
34
|
const query = {
|
|
32
35
|
limit,
|
|
33
36
|
...(issueKey ? { issueKey } : {}),
|
|
34
37
|
...(projectId ? { projectId } : {}),
|
|
38
|
+
...(kind ? { kind } : {}),
|
|
39
|
+
...(stage ? { stage } : {}),
|
|
40
|
+
...(status ? { status } : {}),
|
|
41
|
+
...(workflowId ? { workflowId } : {}),
|
|
35
42
|
};
|
|
36
43
|
if (!follow) {
|
|
37
44
|
const result = await params.data.listOperatorFeed(query);
|
|
@@ -134,26 +134,39 @@ function formatFeedStatus(event, color) {
|
|
|
134
134
|
if (event.level === "error" || raw === "failed" || raw === "delivery_failed") {
|
|
135
135
|
return colorize(color, "31", padded);
|
|
136
136
|
}
|
|
137
|
-
if (event.level === "warn" || raw === "ignored" || raw === "fallback" || raw === "handoff") {
|
|
137
|
+
if (event.level === "warn" || raw === "ignored" || raw === "fallback" || raw === "handoff" || raw === "transition_suppressed") {
|
|
138
138
|
return colorize(color, "33", padded);
|
|
139
139
|
}
|
|
140
|
-
if (raw === "running" || raw === "started" || raw === "delegated") {
|
|
140
|
+
if (raw === "running" || raw === "started" || raw === "delegated" || raw === "transition_chosen" || raw === "completed") {
|
|
141
141
|
return colorize(color, "32", padded);
|
|
142
142
|
}
|
|
143
|
-
if (raw === "queued") {
|
|
143
|
+
if (raw === "queued" || raw === "selected") {
|
|
144
144
|
return colorize(color, "36", padded);
|
|
145
145
|
}
|
|
146
146
|
return colorize(color, "2", padded);
|
|
147
147
|
}
|
|
148
|
+
function formatFeedMeta(event, color) {
|
|
149
|
+
const parts = [
|
|
150
|
+
event.workflowId ? `workflow:${event.workflowId}` : undefined,
|
|
151
|
+
event.stage ? `stage:${event.stage}` : undefined,
|
|
152
|
+
event.nextStage ? `next:${event.nextStage}` : undefined,
|
|
153
|
+
].filter(Boolean);
|
|
154
|
+
if (parts.length === 0) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
return colorize(color, "2", `[${parts.join(" ")}]`);
|
|
158
|
+
}
|
|
148
159
|
export function formatOperatorFeedEvent(event, options) {
|
|
149
160
|
const color = options?.color === true;
|
|
150
161
|
const timestamp = new Date(event.at).toLocaleTimeString("en-GB", { hour12: false });
|
|
151
162
|
const issue = event.issueKey ?? event.projectId ?? "-";
|
|
163
|
+
const meta = formatFeedMeta(event, color);
|
|
152
164
|
const line = [
|
|
153
165
|
colorize(color, "2", timestamp),
|
|
154
166
|
colorize(color, "1", issue.padEnd(10)),
|
|
155
167
|
formatFeedStatus(event, color),
|
|
156
168
|
event.summary,
|
|
169
|
+
...(meta ? [meta] : []),
|
|
157
170
|
].join(" ");
|
|
158
171
|
if (!event.detail) {
|
|
159
172
|
return `${line}\n`;
|
package/dist/cli/help.js
CHANGED
|
@@ -37,7 +37,7 @@ export function rootHelpText() {
|
|
|
37
37
|
" connect [--project <projectId>] [--no-open] [--timeout <seconds>] [--json]",
|
|
38
38
|
" Advanced: start or reuse a Linear installation directly",
|
|
39
39
|
" installations [--json] Show connected Linear installations",
|
|
40
|
-
" feed [--follow] [--limit <count>] [--issue <issueKey>] [--project <projectId>] [--json]",
|
|
40
|
+
" feed [--follow] [--limit <count>] [--issue <issueKey>] [--project <projectId>] [--kind <kind>] [--stage <stage>] [--status <status>] [--workflow <id>] [--json]",
|
|
41
41
|
" Show a live operator feed from the daemon",
|
|
42
42
|
" serve Run the local PatchRelay service",
|
|
43
43
|
" inspect <issueKey> Show the latest known issue state",
|
package/dist/cli/index.js
CHANGED
|
@@ -88,7 +88,7 @@ function validateFlags(command, commandArgs, parsed) {
|
|
|
88
88
|
assertKnownFlags(parsed, command, ["json"]);
|
|
89
89
|
return;
|
|
90
90
|
case "feed":
|
|
91
|
-
assertKnownFlags(parsed, command, ["follow", "limit", "issue", "project", "json"]);
|
|
91
|
+
assertKnownFlags(parsed, command, ["follow", "limit", "issue", "project", "kind", "stage", "status", "workflow", "json"]);
|
|
92
92
|
return;
|
|
93
93
|
case "install-service":
|
|
94
94
|
assertKnownFlags(parsed, command, ["force", "write-only", "json"]);
|
|
@@ -23,6 +23,10 @@ export class CliOperatorApiClient {
|
|
|
23
23
|
...(options?.limit && options.limit > 0 ? { limit: String(options.limit) } : {}),
|
|
24
24
|
...(options?.issueKey ? { issue: options.issueKey } : {}),
|
|
25
25
|
...(options?.projectId ? { project: options.projectId } : {}),
|
|
26
|
+
...(options?.kind ? { kind: options.kind } : {}),
|
|
27
|
+
...(options?.stage ? { stage: options.stage } : {}),
|
|
28
|
+
...(options?.status ? { status: options.status } : {}),
|
|
29
|
+
...(options?.workflowId ? { workflow: options.workflowId } : {}),
|
|
26
30
|
});
|
|
27
31
|
}
|
|
28
32
|
async followOperatorFeed(onEvent, options) {
|
|
@@ -37,6 +41,18 @@ export class CliOperatorApiClient {
|
|
|
37
41
|
if (options?.projectId) {
|
|
38
42
|
url.searchParams.set("project", options.projectId);
|
|
39
43
|
}
|
|
44
|
+
if (options?.kind) {
|
|
45
|
+
url.searchParams.set("kind", options.kind);
|
|
46
|
+
}
|
|
47
|
+
if (options?.stage) {
|
|
48
|
+
url.searchParams.set("stage", options.stage);
|
|
49
|
+
}
|
|
50
|
+
if (options?.status) {
|
|
51
|
+
url.searchParams.set("status", options.status);
|
|
52
|
+
}
|
|
53
|
+
if (options?.workflowId) {
|
|
54
|
+
url.searchParams.set("workflow", options.workflowId);
|
|
55
|
+
}
|
|
40
56
|
const response = await fetch(url, {
|
|
41
57
|
method: "GET",
|
|
42
58
|
headers: {
|
package/dist/config.js
CHANGED
|
@@ -5,6 +5,8 @@ import { z } from "zod";
|
|
|
5
5
|
import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultLogPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getPatchRelayDataDir, } from "./runtime-paths.js";
|
|
6
6
|
import { ensureAbsolutePath } from "./utils.js";
|
|
7
7
|
const LINEAR_OAUTH_CALLBACK_PATH = "/oauth/linear/callback";
|
|
8
|
+
const REPO_SETTINGS_DIRNAME = ".patchrelay";
|
|
9
|
+
const REPO_SETTINGS_FILENAME = "project.json";
|
|
8
10
|
const workflowSchema = z.object({
|
|
9
11
|
id: z.string().min(1),
|
|
10
12
|
when_state: z.string().min(1),
|
|
@@ -12,25 +14,51 @@ const workflowSchema = z.object({
|
|
|
12
14
|
workflow_file: z.string().min(1),
|
|
13
15
|
fallback_state: z.string().min(1).nullable().optional(),
|
|
14
16
|
});
|
|
17
|
+
const workflowDefinitionSchema = z.object({
|
|
18
|
+
id: z.string().min(1),
|
|
19
|
+
stages: z.array(workflowSchema).min(1),
|
|
20
|
+
});
|
|
21
|
+
const workflowSelectionSchema = z.object({
|
|
22
|
+
default_workflow: z.string().min(1).optional(),
|
|
23
|
+
by_label: z
|
|
24
|
+
.array(z.object({
|
|
25
|
+
label: z.string().min(1),
|
|
26
|
+
workflow: z.string().min(1),
|
|
27
|
+
}))
|
|
28
|
+
.default([]),
|
|
29
|
+
});
|
|
30
|
+
const workflowLabelsSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
working: z.string().min(1).optional(),
|
|
33
|
+
awaiting_handoff: z.string().min(1).optional(),
|
|
34
|
+
})
|
|
35
|
+
.optional();
|
|
36
|
+
const trustedActorsSchema = z
|
|
37
|
+
.object({
|
|
38
|
+
ids: z.array(z.string().min(1)).default([]),
|
|
39
|
+
names: z.array(z.string().min(1)).default([]),
|
|
40
|
+
emails: z.array(z.string().email()).default([]),
|
|
41
|
+
email_domains: z.array(z.string().min(1)).default([]),
|
|
42
|
+
})
|
|
43
|
+
.optional();
|
|
44
|
+
const repoSettingsSchema = z.object({
|
|
45
|
+
workflows: z.array(workflowSchema).min(1).optional(),
|
|
46
|
+
workflow_definitions: z.array(workflowDefinitionSchema).min(1).optional(),
|
|
47
|
+
workflow_selection: workflowSelectionSchema.optional(),
|
|
48
|
+
workflow_labels: workflowLabelsSchema,
|
|
49
|
+
trusted_actors: trustedActorsSchema,
|
|
50
|
+
trigger_events: z.array(z.string().min(1)).min(1).optional(),
|
|
51
|
+
branch_prefix: z.string().min(1).optional(),
|
|
52
|
+
});
|
|
15
53
|
const projectSchema = z.object({
|
|
16
54
|
id: z.string().min(1),
|
|
17
55
|
repo_path: z.string().min(1),
|
|
18
56
|
worktree_root: z.string().min(1).optional(),
|
|
19
57
|
workflows: z.array(workflowSchema).min(1).optional(),
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
})
|
|
25
|
-
.optional(),
|
|
26
|
-
trusted_actors: z
|
|
27
|
-
.object({
|
|
28
|
-
ids: z.array(z.string().min(1)).default([]),
|
|
29
|
-
names: z.array(z.string().min(1)).default([]),
|
|
30
|
-
emails: z.array(z.string().email()).default([]),
|
|
31
|
-
email_domains: z.array(z.string().min(1)).default([]),
|
|
32
|
-
})
|
|
33
|
-
.optional(),
|
|
58
|
+
workflow_definitions: z.array(workflowDefinitionSchema).min(1).optional(),
|
|
59
|
+
workflow_selection: workflowSelectionSchema.optional(),
|
|
60
|
+
workflow_labels: workflowLabelsSchema,
|
|
61
|
+
trusted_actors: trustedActorsSchema,
|
|
34
62
|
issue_key_prefixes: z.array(z.string().min(1)).default([]),
|
|
35
63
|
linear_team_ids: z.array(z.string().min(1)).default([]),
|
|
36
64
|
allow_labels: z.array(z.string().min(1)).default([]),
|
|
@@ -134,6 +162,12 @@ const builtinWorkflows = [
|
|
|
134
162
|
fallback_state: "Human Needed",
|
|
135
163
|
},
|
|
136
164
|
];
|
|
165
|
+
const builtinWorkflowDefinitions = [
|
|
166
|
+
{
|
|
167
|
+
id: "default",
|
|
168
|
+
stages: builtinWorkflows,
|
|
169
|
+
},
|
|
170
|
+
];
|
|
137
171
|
function withSectionDefaults(input) {
|
|
138
172
|
const source = input && typeof input === "object" ? input : {};
|
|
139
173
|
const { linear: _linear, runner: _runner, ...rest } = source;
|
|
@@ -159,6 +193,9 @@ function withSectionDefaults(input) {
|
|
|
159
193
|
},
|
|
160
194
|
};
|
|
161
195
|
}
|
|
196
|
+
function resolveRepoSettingsPath(repoPath) {
|
|
197
|
+
return path.join(repoPath, REPO_SETTINGS_DIRNAME, REPO_SETTINGS_FILENAME);
|
|
198
|
+
}
|
|
162
199
|
function expandEnv(value, env) {
|
|
163
200
|
if (typeof value === "string") {
|
|
164
201
|
return value.replace(/\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, (_match, name, fallback) => {
|
|
@@ -230,6 +267,72 @@ function readEnvFilesForProfile(configPath, profile) {
|
|
|
230
267
|
function resolveWorkflowFilePath(repoPath, workflowFile) {
|
|
231
268
|
return path.isAbsolute(workflowFile) ? ensureAbsolutePath(workflowFile) : path.resolve(repoPath, workflowFile);
|
|
232
269
|
}
|
|
270
|
+
function parseJsonFile(filePath, label) {
|
|
271
|
+
const raw = readFileSync(filePath, "utf8");
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(raw);
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
+
throw new Error(`Invalid JSON ${label}: ${filePath}: ${message}`, {
|
|
278
|
+
cause: error,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function readRepoSettings(repoPath, env) {
|
|
283
|
+
const configPath = resolveRepoSettingsPath(repoPath);
|
|
284
|
+
if (!existsSync(configPath)) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
const parsed = repoSettingsSchema.parse(expandEnv(parseJsonFile(configPath, "repo settings file"), env));
|
|
288
|
+
return {
|
|
289
|
+
...parsed,
|
|
290
|
+
configPath,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function mergeWorkflowStages(repoPath, workflows) {
|
|
294
|
+
return workflows.map((workflow) => ({
|
|
295
|
+
id: workflow.id,
|
|
296
|
+
whenState: workflow.when_state,
|
|
297
|
+
activeState: workflow.active_state,
|
|
298
|
+
workflowFile: resolveWorkflowFilePath(repoPath, workflow.workflow_file),
|
|
299
|
+
...(workflow.fallback_state ? { fallbackState: workflow.fallback_state } : {}),
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
function mergeWorkflowDefinitions(repoPath, definitions) {
|
|
303
|
+
return definitions.map((definition) => ({
|
|
304
|
+
id: definition.id,
|
|
305
|
+
stages: mergeWorkflowStages(repoPath, definition.stages),
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
function resolveProjectWorkflowConfig(repoPath, project, repoSettings) {
|
|
309
|
+
const selectedDefinitions = repoSettings?.workflow_definitions ??
|
|
310
|
+
project.workflow_definitions ??
|
|
311
|
+
(repoSettings?.workflows
|
|
312
|
+
? [{ id: "default", stages: repoSettings.workflows }]
|
|
313
|
+
: project.workflows
|
|
314
|
+
? [{ id: "default", stages: project.workflows }]
|
|
315
|
+
: builtinWorkflowDefinitions.map((definition) => ({ id: definition.id, stages: [...definition.stages] })));
|
|
316
|
+
const workflowDefinitions = mergeWorkflowDefinitions(repoPath, selectedDefinitions);
|
|
317
|
+
const selectionSource = repoSettings?.workflow_selection ?? project.workflow_selection;
|
|
318
|
+
const defaultWorkflowId = selectionSource?.default_workflow ?? workflowDefinitions[0]?.id;
|
|
319
|
+
const workflows = workflowDefinitions.find((definition) => definition.id === defaultWorkflowId)?.stages ?? workflowDefinitions[0]?.stages ?? [];
|
|
320
|
+
return {
|
|
321
|
+
workflows,
|
|
322
|
+
...(workflowDefinitions.length > 0 ? { workflowDefinitions } : {}),
|
|
323
|
+
...(defaultWorkflowId || (selectionSource?.by_label?.length ?? 0) > 0
|
|
324
|
+
? {
|
|
325
|
+
workflowSelection: {
|
|
326
|
+
...(defaultWorkflowId ? { defaultWorkflowId } : {}),
|
|
327
|
+
byLabel: (selectionSource?.by_label ?? []).map((entry) => ({
|
|
328
|
+
label: entry.label,
|
|
329
|
+
workflowId: entry.workflow,
|
|
330
|
+
})),
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
: {}),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
233
336
|
function defaultWorktreeRoot(projectId) {
|
|
234
337
|
return path.join(getPatchRelayDataDir(), "worktrees", projectId);
|
|
235
338
|
}
|
|
@@ -260,15 +363,6 @@ function deriveLinearOAuthRedirectUri(server) {
|
|
|
260
363
|
const host = normalizeLocalRedirectHost(server.bind);
|
|
261
364
|
return new URL(LINEAR_OAUTH_CALLBACK_PATH, `http://${formatUrlHost(host)}:${server.port}`).toString();
|
|
262
365
|
}
|
|
263
|
-
function mergeWorkflows(repoPath, workflows) {
|
|
264
|
-
return workflows.map((workflow) => ({
|
|
265
|
-
id: workflow.id,
|
|
266
|
-
whenState: workflow.when_state,
|
|
267
|
-
activeState: workflow.active_state,
|
|
268
|
-
workflowFile: resolveWorkflowFilePath(repoPath, workflow.workflow_file),
|
|
269
|
-
...(workflow.fallback_state ? { fallbackState: workflow.fallback_state } : {}),
|
|
270
|
-
}));
|
|
271
|
-
}
|
|
272
366
|
export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefaultConfigPath(), options) {
|
|
273
367
|
const requestedPath = ensureAbsolutePath(configPath);
|
|
274
368
|
if (!existsSync(requestedPath)) {
|
|
@@ -280,17 +374,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
280
374
|
...adjacentEnv,
|
|
281
375
|
...process.env,
|
|
282
376
|
};
|
|
283
|
-
const
|
|
284
|
-
let parsedFile;
|
|
285
|
-
try {
|
|
286
|
-
parsedFile = JSON.parse(raw);
|
|
287
|
-
}
|
|
288
|
-
catch (error) {
|
|
289
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
290
|
-
throw new Error(`Invalid JSON config file: ${requestedPath}: ${message}`, {
|
|
291
|
-
cause: error,
|
|
292
|
-
});
|
|
293
|
-
}
|
|
377
|
+
const parsedFile = parseJsonFile(requestedPath, "config file");
|
|
294
378
|
const parsed = configSchema.parse(withSectionDefaults(expandEnv(parsedFile, env)));
|
|
295
379
|
const requirements = getLoadProfileRequirements(profile);
|
|
296
380
|
const webhookSecret = env[parsed.linear.webhook_secret_env];
|
|
@@ -373,35 +457,43 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
373
457
|
},
|
|
374
458
|
projects: parsed.projects.map((project) => {
|
|
375
459
|
const repoPath = ensureAbsolutePath(project.repo_path);
|
|
460
|
+
const repoSettings = readRepoSettings(repoPath, env);
|
|
461
|
+
const workflowConfig = resolveProjectWorkflowConfig(repoPath, project, repoSettings);
|
|
462
|
+
const workflowLabels = repoSettings?.workflow_labels ?? project.workflow_labels;
|
|
463
|
+
const trustedActors = repoSettings?.trusted_actors ?? project.trusted_actors;
|
|
376
464
|
return {
|
|
377
465
|
id: project.id,
|
|
378
466
|
repoPath,
|
|
379
467
|
worktreeRoot: ensureAbsolutePath(project.worktree_root ?? defaultWorktreeRoot(project.id)),
|
|
380
|
-
workflows:
|
|
381
|
-
...(
|
|
468
|
+
workflows: workflowConfig.workflows,
|
|
469
|
+
...(workflowConfig.workflowDefinitions ? { workflowDefinitions: workflowConfig.workflowDefinitions } : {}),
|
|
470
|
+
...(workflowConfig.workflowSelection ? { workflowSelection: workflowConfig.workflowSelection } : {}),
|
|
471
|
+
...(workflowLabels
|
|
382
472
|
? {
|
|
383
473
|
workflowLabels: {
|
|
384
|
-
...(
|
|
385
|
-
...(
|
|
474
|
+
...(workflowLabels.working ? { working: workflowLabels.working } : {}),
|
|
475
|
+
...(workflowLabels.awaiting_handoff ? { awaitingHandoff: workflowLabels.awaiting_handoff } : {}),
|
|
386
476
|
},
|
|
387
477
|
}
|
|
388
478
|
: {}),
|
|
389
|
-
...(
|
|
479
|
+
...(trustedActors
|
|
390
480
|
? {
|
|
391
481
|
trustedActors: {
|
|
392
|
-
ids:
|
|
393
|
-
names:
|
|
394
|
-
emails:
|
|
395
|
-
emailDomains:
|
|
482
|
+
ids: trustedActors.ids,
|
|
483
|
+
names: trustedActors.names,
|
|
484
|
+
emails: trustedActors.emails,
|
|
485
|
+
emailDomains: trustedActors.email_domains,
|
|
396
486
|
},
|
|
397
487
|
}
|
|
398
488
|
: {}),
|
|
399
489
|
issueKeyPrefixes: project.issue_key_prefixes,
|
|
400
490
|
linearTeamIds: project.linear_team_ids,
|
|
401
491
|
allowLabels: project.allow_labels,
|
|
402
|
-
triggerEvents:
|
|
492
|
+
triggerEvents: repoSettings?.trigger_events ??
|
|
493
|
+
project.trigger_events ??
|
|
403
494
|
defaultTriggerEvents(parsed.linear.oauth.actor),
|
|
404
|
-
branchPrefix: project.branch_prefix ?? defaultBranchPrefix(project.id),
|
|
495
|
+
branchPrefix: repoSettings?.branch_prefix ?? project.branch_prefix ?? defaultBranchPrefix(project.id),
|
|
496
|
+
...(repoSettings?.configPath ? { repoSettingsPath: repoSettings.configPath } : {}),
|
|
405
497
|
};
|
|
406
498
|
}),
|
|
407
499
|
};
|
|
@@ -467,19 +559,40 @@ function validateConfigSemantics(config, options) {
|
|
|
467
559
|
}
|
|
468
560
|
linearTeamIds.set(teamId, project.id);
|
|
469
561
|
}
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
for (const
|
|
473
|
-
const
|
|
474
|
-
if (
|
|
475
|
-
throw new Error(`Workflow
|
|
562
|
+
const workflowDefinitions = project.workflowDefinitions ?? [{ id: "default", stages: project.workflows }];
|
|
563
|
+
const definitionIds = new Set();
|
|
564
|
+
for (const definition of workflowDefinitions) {
|
|
565
|
+
const normalizedDefinitionId = definition.id.trim().toLowerCase();
|
|
566
|
+
if (definitionIds.has(normalizedDefinitionId)) {
|
|
567
|
+
throw new Error(`Workflow definition "${definition.id}" is configured more than once in project ${project.id}`);
|
|
568
|
+
}
|
|
569
|
+
definitionIds.add(normalizedDefinitionId);
|
|
570
|
+
const workflowIds = new Set();
|
|
571
|
+
const workflowStates = new Set();
|
|
572
|
+
for (const workflow of definition.stages) {
|
|
573
|
+
const normalizedWorkflowId = workflow.id.trim().toLowerCase();
|
|
574
|
+
if (workflowIds.has(normalizedWorkflowId)) {
|
|
575
|
+
throw new Error(`Workflow id "${workflow.id}" is configured more than once in project ${project.id}`);
|
|
576
|
+
}
|
|
577
|
+
workflowIds.add(normalizedWorkflowId);
|
|
578
|
+
const normalizedState = workflow.whenState.trim().toLowerCase();
|
|
579
|
+
if (workflowStates.has(normalizedState)) {
|
|
580
|
+
throw new Error(`Linear state "${workflow.whenState}" is configured for more than one workflow in project ${project.id}`);
|
|
581
|
+
}
|
|
582
|
+
workflowStates.add(normalizedState);
|
|
476
583
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
584
|
+
}
|
|
585
|
+
if (project.workflowSelection?.defaultWorkflowId) {
|
|
586
|
+
const normalizedDefaultWorkflowId = project.workflowSelection.defaultWorkflowId.trim().toLowerCase();
|
|
587
|
+
if (!workflowDefinitions.some((definition) => definition.id.trim().toLowerCase() === normalizedDefaultWorkflowId)) {
|
|
588
|
+
throw new Error(`Default workflow "${project.workflowSelection.defaultWorkflowId}" does not exist in project ${project.id}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
for (const rule of project.workflowSelection?.byLabel ?? []) {
|
|
592
|
+
const normalizedWorkflowId = rule.workflowId.trim().toLowerCase();
|
|
593
|
+
if (!workflowDefinitions.some((definition) => definition.id.trim().toLowerCase() === normalizedWorkflowId)) {
|
|
594
|
+
throw new Error(`Workflow selection for label "${rule.label}" points to unknown workflow "${rule.workflowId}" in project ${project.id}`);
|
|
481
595
|
}
|
|
482
|
-
workflowStates.add(normalizedState);
|
|
483
596
|
}
|
|
484
597
|
}
|
|
485
598
|
if (config.operatorApi.enabled &&
|
|
@@ -54,13 +54,14 @@ export class AuthoritativeLedgerStore {
|
|
|
54
54
|
this.connection
|
|
55
55
|
.prepare(`
|
|
56
56
|
INSERT INTO issue_control (
|
|
57
|
-
project_id, linear_issue_id, desired_stage, desired_receipt_id, active_workspace_ownership_id,
|
|
57
|
+
project_id, linear_issue_id, selected_workflow_id, desired_stage, desired_receipt_id, active_workspace_ownership_id,
|
|
58
58
|
active_run_lease_id, service_owned_comment_id, active_agent_session_id, lifecycle_status, updated_at
|
|
59
59
|
) VALUES (
|
|
60
|
-
@projectId, @linearIssueId, @desiredStage, @desiredReceiptId, @activeWorkspaceOwnershipId,
|
|
60
|
+
@projectId, @linearIssueId, @selectedWorkflowId, @desiredStage, @desiredReceiptId, @activeWorkspaceOwnershipId,
|
|
61
61
|
@activeRunLeaseId, @serviceOwnedCommentId, @activeAgentSessionId, @lifecycleStatus, @updatedAt
|
|
62
62
|
)
|
|
63
63
|
ON CONFLICT(project_id, linear_issue_id) DO UPDATE SET
|
|
64
|
+
selected_workflow_id = CASE WHEN @setSelectedWorkflowId = 1 THEN @selectedWorkflowId ELSE issue_control.selected_workflow_id END,
|
|
64
65
|
desired_stage = CASE WHEN @setDesiredStage = 1 THEN @desiredStage ELSE issue_control.desired_stage END,
|
|
65
66
|
desired_receipt_id = CASE WHEN @setDesiredReceiptId = 1 THEN @desiredReceiptId ELSE issue_control.desired_receipt_id END,
|
|
66
67
|
active_workspace_ownership_id = CASE WHEN @setActiveWorkspaceOwnershipId = 1 THEN @activeWorkspaceOwnershipId ELSE issue_control.active_workspace_ownership_id END,
|
|
@@ -73,6 +74,7 @@ export class AuthoritativeLedgerStore {
|
|
|
73
74
|
.run({
|
|
74
75
|
projectId: params.projectId,
|
|
75
76
|
linearIssueId: params.linearIssueId,
|
|
77
|
+
selectedWorkflowId: params.selectedWorkflowId ?? null,
|
|
76
78
|
desiredStage: params.desiredStage ?? null,
|
|
77
79
|
desiredReceiptId: params.desiredReceiptId ?? null,
|
|
78
80
|
activeWorkspaceOwnershipId: params.activeWorkspaceOwnershipId ?? null,
|
|
@@ -81,6 +83,7 @@ export class AuthoritativeLedgerStore {
|
|
|
81
83
|
activeAgentSessionId: params.activeAgentSessionId ?? null,
|
|
82
84
|
lifecycleStatus: params.lifecycleStatus,
|
|
83
85
|
updatedAt: now,
|
|
86
|
+
setSelectedWorkflowId: Number("selectedWorkflowId" in params),
|
|
84
87
|
setDesiredStage: Number("desiredStage" in params),
|
|
85
88
|
setDesiredReceiptId: Number("desiredReceiptId" in params),
|
|
86
89
|
setActiveWorkspaceOwnershipId: Number("activeWorkspaceOwnershipId" in params),
|
|
@@ -452,6 +455,7 @@ function mapIssueControl(row) {
|
|
|
452
455
|
id: Number(row.id),
|
|
453
456
|
projectId: String(row.project_id),
|
|
454
457
|
linearIssueId: String(row.linear_issue_id),
|
|
458
|
+
...(row.selected_workflow_id === null ? {} : { selectedWorkflowId: String(row.selected_workflow_id) }),
|
|
455
459
|
...(row.desired_stage === null ? {} : { desiredStage: row.desired_stage }),
|
|
456
460
|
...(row.desired_receipt_id === null ? {} : { desiredReceiptId: Number(row.desired_receipt_id) }),
|
|
457
461
|
...(row.active_run_lease_id === null ? {} : { activeRunLeaseId: Number(row.active_run_lease_id) }),
|
|
@@ -31,6 +31,7 @@ export class IssueWorkflowCoordinator {
|
|
|
31
31
|
this.authoritativeLedger.upsertIssueControl({
|
|
32
32
|
projectId: params.projectId,
|
|
33
33
|
linearIssueId: params.linearIssueId,
|
|
34
|
+
...(params.selectedWorkflowId !== undefined ? { selectedWorkflowId: params.selectedWorkflowId } : {}),
|
|
34
35
|
...(params.desiredStage !== undefined ? { desiredStage: params.desiredStage } : {}),
|
|
35
36
|
...(desiredReceiptId !== undefined ? { desiredReceiptId } : {}),
|
|
36
37
|
...(params.activeWorkspaceId !== undefined ? { activeWorkspaceOwnershipId: params.activeWorkspaceId } : {}),
|
|
@@ -69,6 +70,11 @@ export class IssueWorkflowCoordinator {
|
|
|
69
70
|
this.authoritativeLedger.upsertIssueControl({
|
|
70
71
|
projectId: params.projectId,
|
|
71
72
|
linearIssueId: params.linearIssueId,
|
|
73
|
+
...(params.selectedWorkflowId !== undefined
|
|
74
|
+
? { selectedWorkflowId: params.selectedWorkflowId }
|
|
75
|
+
: existing?.selectedWorkflowId
|
|
76
|
+
? { selectedWorkflowId: existing.selectedWorkflowId }
|
|
77
|
+
: {}),
|
|
72
78
|
...(params.desiredStage !== undefined ? { desiredStage: params.desiredStage } : {}),
|
|
73
79
|
...(desiredReceiptId !== undefined ? { desiredReceiptId } : {}),
|
|
74
80
|
lifecycleStatus,
|
|
@@ -123,6 +129,7 @@ export class IssueWorkflowCoordinator {
|
|
|
123
129
|
this.authoritativeLedger.upsertIssueControl({
|
|
124
130
|
projectId: params.projectId,
|
|
125
131
|
linearIssueId: params.linearIssueId,
|
|
132
|
+
...(issue.selectedWorkflowId ? { selectedWorkflowId: issue.selectedWorkflowId } : {}),
|
|
126
133
|
desiredStage: null,
|
|
127
134
|
desiredReceiptId: null,
|
|
128
135
|
activeWorkspaceOwnershipId: workspaceOwnership.id,
|
|
@@ -216,6 +223,7 @@ export class IssueWorkflowCoordinator {
|
|
|
216
223
|
this.authoritativeLedger.upsertIssueControl({
|
|
217
224
|
projectId,
|
|
218
225
|
linearIssueId,
|
|
226
|
+
...(existing?.selectedWorkflowId ? { selectedWorkflowId: existing.selectedWorkflowId } : {}),
|
|
219
227
|
...(desiredStage !== undefined ? { desiredStage } : { desiredStage: null }),
|
|
220
228
|
...(desiredReceiptId !== undefined
|
|
221
229
|
? { desiredReceiptId }
|
|
@@ -236,6 +244,7 @@ export class IssueWorkflowCoordinator {
|
|
|
236
244
|
this.authoritativeLedger.upsertIssueControl({
|
|
237
245
|
projectId,
|
|
238
246
|
linearIssueId,
|
|
247
|
+
...(existing?.selectedWorkflowId ? { selectedWorkflowId: existing.selectedWorkflowId } : {}),
|
|
239
248
|
lifecycleStatus,
|
|
240
249
|
...(existing?.desiredStage ? { desiredStage: existing.desiredStage } : {}),
|
|
241
250
|
...(existingIssueControl?.desiredReceiptId !== undefined ? { desiredReceiptId: existingIssueControl.desiredReceiptId } : {}),
|
|
@@ -251,6 +260,7 @@ export class IssueWorkflowCoordinator {
|
|
|
251
260
|
this.authoritativeLedger.upsertIssueControl({
|
|
252
261
|
projectId,
|
|
253
262
|
linearIssueId,
|
|
263
|
+
...(existing?.selectedWorkflowId ? { selectedWorkflowId: existing.selectedWorkflowId } : {}),
|
|
254
264
|
lifecycleStatus: existing?.lifecycleStatus ?? "idle",
|
|
255
265
|
...(existing?.desiredStage ? { desiredStage: existing.desiredStage } : {}),
|
|
256
266
|
...(existingIssueControl?.desiredReceiptId !== undefined ? { desiredReceiptId: existingIssueControl.desiredReceiptId } : {}),
|
|
@@ -266,6 +276,7 @@ export class IssueWorkflowCoordinator {
|
|
|
266
276
|
this.authoritativeLedger.upsertIssueControl({
|
|
267
277
|
projectId,
|
|
268
278
|
linearIssueId,
|
|
279
|
+
...(existing?.selectedWorkflowId ? { selectedWorkflowId: existing.selectedWorkflowId } : {}),
|
|
269
280
|
lifecycleStatus: existing?.lifecycleStatus ?? "idle",
|
|
270
281
|
...(existing?.desiredStage ? { desiredStage: existing.desiredStage } : {}),
|
|
271
282
|
...(existingIssueControl?.desiredReceiptId !== undefined ? { desiredReceiptId: existingIssueControl.desiredReceiptId } : {}),
|
|
@@ -107,6 +107,7 @@ export class IssueWorkflowStore {
|
|
|
107
107
|
id: issueControl?.id ?? projection?.id ?? 0,
|
|
108
108
|
projectId,
|
|
109
109
|
linearIssueId,
|
|
110
|
+
...(issueControl?.selectedWorkflowId ? { selectedWorkflowId: issueControl.selectedWorkflowId } : {}),
|
|
110
111
|
...(projection?.issueKey ? { issueKey: projection.issueKey } : {}),
|
|
111
112
|
...(projection?.title ? { title: projection.title } : {}),
|
|
112
113
|
...(projection?.issueUrl ? { issueUrl: projection.issueUrl } : {}),
|
package/dist/db/migrations.js
CHANGED
|
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS issue_control (
|
|
|
32
32
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
33
|
project_id TEXT NOT NULL,
|
|
34
34
|
linear_issue_id TEXT NOT NULL,
|
|
35
|
+
selected_workflow_id TEXT,
|
|
35
36
|
desired_stage TEXT,
|
|
36
37
|
desired_receipt_id INTEGER,
|
|
37
38
|
active_run_lease_id INTEGER,
|
|
@@ -194,7 +195,9 @@ CREATE TABLE IF NOT EXISTS operator_feed_events (
|
|
|
194
195
|
issue_key TEXT,
|
|
195
196
|
project_id TEXT,
|
|
196
197
|
stage TEXT,
|
|
197
|
-
status TEXT
|
|
198
|
+
status TEXT,
|
|
199
|
+
workflow_id TEXT,
|
|
200
|
+
next_stage TEXT
|
|
198
201
|
);
|
|
199
202
|
|
|
200
203
|
CREATE INDEX IF NOT EXISTS idx_event_receipts_project_issue ON event_receipts(project_id, linear_issue_id);
|
|
@@ -214,4 +217,22 @@ WHERE dedupe_key IS NOT NULL;
|
|
|
214
217
|
`;
|
|
215
218
|
export function runPatchRelayMigrations(connection) {
|
|
216
219
|
connection.exec(baseMigration);
|
|
220
|
+
try {
|
|
221
|
+
connection.exec("ALTER TABLE issue_control ADD COLUMN selected_workflow_id TEXT");
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Column already exists on upgraded installs.
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
connection.exec("ALTER TABLE operator_feed_events ADD COLUMN workflow_id TEXT");
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Column already exists on upgraded installs.
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
connection.exec("ALTER TABLE operator_feed_events ADD COLUMN next_stage TEXT");
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// Column already exists on upgraded installs.
|
|
237
|
+
}
|
|
217
238
|
}
|
|
@@ -9,9 +9,9 @@ export class OperatorFeedStore {
|
|
|
9
9
|
save(event) {
|
|
10
10
|
const at = event.at ?? isoNow();
|
|
11
11
|
const result = this.connection.prepare(`
|
|
12
|
-
INSERT INTO operator_feed_events (at, level, kind, summary, detail, issue_key, project_id, stage, status)
|
|
13
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
14
|
-
`).run(at, event.level, event.kind, event.summary, event.detail ?? null, event.issueKey ?? null, event.projectId ?? null, event.stage ?? null, event.status ?? null);
|
|
12
|
+
INSERT INTO operator_feed_events (at, level, kind, summary, detail, issue_key, project_id, stage, status, workflow_id, next_stage)
|
|
13
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
14
|
+
`).run(at, event.level, event.kind, event.summary, event.detail ?? null, event.issueKey ?? null, event.projectId ?? null, event.stage ?? null, event.status ?? null, event.workflowId ?? null, event.nextStage ?? null);
|
|
15
15
|
this.prune();
|
|
16
16
|
const stored = this.connection.prepare("SELECT * FROM operator_feed_events WHERE id = ?").get(Number(result.lastInsertRowid));
|
|
17
17
|
return mapOperatorFeedEvent(stored);
|
|
@@ -31,6 +31,22 @@ export class OperatorFeedStore {
|
|
|
31
31
|
clauses.push("project_id = ?");
|
|
32
32
|
params.push(options.projectId);
|
|
33
33
|
}
|
|
34
|
+
if (options?.kind) {
|
|
35
|
+
clauses.push("kind = ?");
|
|
36
|
+
params.push(options.kind);
|
|
37
|
+
}
|
|
38
|
+
if (options?.stage) {
|
|
39
|
+
clauses.push("stage = ?");
|
|
40
|
+
params.push(options.stage);
|
|
41
|
+
}
|
|
42
|
+
if (options?.status) {
|
|
43
|
+
clauses.push("status = ?");
|
|
44
|
+
params.push(options.status);
|
|
45
|
+
}
|
|
46
|
+
if (options?.workflowId) {
|
|
47
|
+
clauses.push("workflow_id = ?");
|
|
48
|
+
params.push(options.workflowId);
|
|
49
|
+
}
|
|
34
50
|
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
35
51
|
const limit = options?.limit ?? 50;
|
|
36
52
|
const rows = this.connection.prepare(`
|
|
@@ -68,5 +84,7 @@ function mapOperatorFeedEvent(row) {
|
|
|
68
84
|
...(row.project_id === null ? {} : { projectId: String(row.project_id) }),
|
|
69
85
|
...(row.stage === null ? {} : { stage: row.stage }),
|
|
70
86
|
...(row.status === null ? {} : { status: String(row.status) }),
|
|
87
|
+
...(row.workflow_id === null ? {} : { workflowId: String(row.workflow_id) }),
|
|
88
|
+
...(row.next_stage === null ? {} : { nextStage: row.next_stage }),
|
|
71
89
|
};
|
|
72
90
|
}
|