flowcat 1.3.0 → 1.4.4
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/core/addTask.ts +121 -0
- package/core/aliasReconcile.ts +41 -0
- package/core/aliasRepo.ts +92 -0
- package/core/autoCommit.ts +84 -0
- package/core/commitMessage.ts +95 -0
- package/core/config.ts +110 -0
- package/core/constants.ts +8 -0
- package/core/dayAssignments.ts +198 -0
- package/core/edit.ts +59 -0
- package/core/format.ts +63 -0
- package/core/fsm.ts +28 -0
- package/core/git.ts +117 -0
- package/core/gitignore.ts +30 -0
- package/core/id.ts +41 -0
- package/core/json.ts +13 -0
- package/core/lock.ts +31 -0
- package/core/plugin.ts +99 -0
- package/core/pluginErrors.ts +26 -0
- package/core/pluginLoader.ts +222 -0
- package/core/pluginManager.ts +75 -0
- package/core/pluginRunner.ts +217 -0
- package/core/pr.ts +89 -0
- package/core/prWorkflow.ts +185 -0
- package/core/schemas/dayAssignment.ts +11 -0
- package/core/schemas/logEntry.ts +11 -0
- package/core/schemas/pr.ts +29 -0
- package/core/schemas/task.ts +41 -0
- package/core/search.ts +25 -0
- package/core/taskFactory.ts +29 -0
- package/core/taskOperations.ts +104 -0
- package/core/taskRepo.ts +109 -0
- package/core/taskResolver.ts +44 -0
- package/core/taskStore.ts +14 -0
- package/core/time.ts +63 -0
- package/core/types.ts +7 -0
- package/core/workspace.ts +133 -0
- package/dist/index.mjs +5219 -2722
- package/package.json +23 -7
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type PluginErrorCode =
|
|
2
|
+
| "PLUGIN_LOAD_FAILED"
|
|
3
|
+
| "PLUGIN_INIT_FAILED"
|
|
4
|
+
| "HOOK_EXECUTION_FAILED"
|
|
5
|
+
| "HOOK_TIMEOUT"
|
|
6
|
+
| "INVALID_PLUGIN";
|
|
7
|
+
|
|
8
|
+
export class PluginError extends Error {
|
|
9
|
+
code: PluginErrorCode;
|
|
10
|
+
pluginName?: string;
|
|
11
|
+
hookName?: string;
|
|
12
|
+
cause?: Error;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
code: PluginErrorCode,
|
|
16
|
+
message: string,
|
|
17
|
+
options?: { pluginName?: string; hookName?: string; cause?: Error },
|
|
18
|
+
) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "PluginError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.pluginName = options?.pluginName;
|
|
23
|
+
this.hookName = options?.hookName;
|
|
24
|
+
this.cause = options?.cause;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { createJiti } from "jiti";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
readConfigFile,
|
|
9
|
+
resolveGlobalConfigPath,
|
|
10
|
+
resolveWorkspaceConfigPath,
|
|
11
|
+
type PluginEntry,
|
|
12
|
+
} from "./config";
|
|
13
|
+
import { APP_NAME, PLUGINS_DIR } from "./constants";
|
|
14
|
+
import { PluginError } from "./pluginErrors";
|
|
15
|
+
import type { FlowcatPlugin } from "./plugin";
|
|
16
|
+
|
|
17
|
+
export type LoadedPlugin = {
|
|
18
|
+
plugin: FlowcatPlugin;
|
|
19
|
+
config: Record<string, unknown>;
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type PluginLoadError = {
|
|
24
|
+
path: string;
|
|
25
|
+
error: Error;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type PluginLoadResult = {
|
|
29
|
+
plugins: LoadedPlugin[];
|
|
30
|
+
errors: PluginLoadError[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const exists = async (filePath: string): Promise<boolean> => {
|
|
34
|
+
try {
|
|
35
|
+
await access(filePath);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the global plugins directory
|
|
44
|
+
*/
|
|
45
|
+
export const resolveGlobalPluginsDirectory = (): string => {
|
|
46
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
|
|
47
|
+
return path.join(configHome, APP_NAME, PLUGINS_DIR);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the plugins directory for a workspace
|
|
52
|
+
*/
|
|
53
|
+
export const resolvePluginsDirectory = (
|
|
54
|
+
workspaceRoot: string,
|
|
55
|
+
configOverride?: string,
|
|
56
|
+
): string => {
|
|
57
|
+
if (configOverride) {
|
|
58
|
+
return path.isAbsolute(configOverride)
|
|
59
|
+
? configOverride
|
|
60
|
+
: path.join(workspaceRoot, configOverride);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return path.join(workspaceRoot, PLUGINS_DIR);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a plugin path to an actual file
|
|
68
|
+
*/
|
|
69
|
+
const resolvePluginPath = async (
|
|
70
|
+
pluginPath: string,
|
|
71
|
+
pluginsDir: string,
|
|
72
|
+
): Promise<string | null> => {
|
|
73
|
+
// Absolute path
|
|
74
|
+
if (path.isAbsolute(pluginPath)) {
|
|
75
|
+
if (await exists(pluginPath)) {
|
|
76
|
+
return pluginPath;
|
|
77
|
+
}
|
|
78
|
+
// Try adding extensions
|
|
79
|
+
for (const ext of ["/index.ts", "/index.js", ".ts", ".js"]) {
|
|
80
|
+
const withExt = pluginPath + ext;
|
|
81
|
+
if (await exists(withExt)) {
|
|
82
|
+
return withExt;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Relative path (./plugin or ../plugin)
|
|
89
|
+
if (pluginPath.startsWith("./") || pluginPath.startsWith("../")) {
|
|
90
|
+
const resolved = path.resolve(pluginsDir, pluginPath);
|
|
91
|
+
return resolvePluginPath(resolved, pluginsDir);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Plugin name - look in plugins directory
|
|
95
|
+
const basePath = path.join(pluginsDir, pluginPath);
|
|
96
|
+
|
|
97
|
+
// Try different extensions in order
|
|
98
|
+
const candidates = [
|
|
99
|
+
path.join(basePath, "index.ts"),
|
|
100
|
+
`${basePath}.ts`,
|
|
101
|
+
path.join(basePath, "index.js"),
|
|
102
|
+
`${basePath}.js`,
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
for (const candidate of candidates) {
|
|
106
|
+
if (await exists(candidate)) {
|
|
107
|
+
return candidate;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate that an object is a valid FlowcatPlugin
|
|
116
|
+
*/
|
|
117
|
+
const validatePlugin = (obj: unknown, pluginPath: string): FlowcatPlugin => {
|
|
118
|
+
if (!obj || typeof obj !== "object") {
|
|
119
|
+
throw new PluginError(
|
|
120
|
+
"INVALID_PLUGIN",
|
|
121
|
+
`Plugin at ${pluginPath} must export a default FlowcatPlugin object`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const plugin = obj as Record<string, unknown>;
|
|
126
|
+
|
|
127
|
+
if (!plugin.name || typeof plugin.name !== "string") {
|
|
128
|
+
throw new PluginError("INVALID_PLUGIN", `Plugin at ${pluginPath} must have a name`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!plugin.version || typeof plugin.version !== "string") {
|
|
132
|
+
throw new PluginError("INVALID_PLUGIN", `Plugin at ${pluginPath} must have a version`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return plugin as unknown as FlowcatPlugin;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Load a single plugin from a path using jiti
|
|
140
|
+
*/
|
|
141
|
+
export const loadPluginFromPath = async (
|
|
142
|
+
pluginPath: string,
|
|
143
|
+
pluginsDir: string,
|
|
144
|
+
): Promise<FlowcatPlugin> => {
|
|
145
|
+
const resolvedPath = await resolvePluginPath(pluginPath, pluginsDir);
|
|
146
|
+
|
|
147
|
+
if (!resolvedPath) {
|
|
148
|
+
throw new PluginError(
|
|
149
|
+
"PLUGIN_LOAD_FAILED",
|
|
150
|
+
`Could not find plugin at ${pluginPath}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const jiti = createJiti(import.meta.url);
|
|
156
|
+
const module = await jiti.import(resolvedPath);
|
|
157
|
+
const defaultExport = (module as { default?: unknown }).default;
|
|
158
|
+
|
|
159
|
+
if (!defaultExport) {
|
|
160
|
+
throw new PluginError(
|
|
161
|
+
"INVALID_PLUGIN",
|
|
162
|
+
`Plugin at ${resolvedPath} must have a default export`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return validatePlugin(defaultExport, resolvedPath);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof PluginError) {
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
throw new PluginError(
|
|
173
|
+
"PLUGIN_LOAD_FAILED",
|
|
174
|
+
`Failed to load plugin at ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
175
|
+
{ cause: error instanceof Error ? error : undefined },
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Load all configured plugins for a workspace
|
|
182
|
+
*/
|
|
183
|
+
export const loadPlugins = async (workspaceRoot: string): Promise<PluginLoadResult> => {
|
|
184
|
+
const workspaceConfig = await readConfigFile(resolveWorkspaceConfigPath(workspaceRoot));
|
|
185
|
+
const globalConfig = await readConfigFile(resolveGlobalConfigPath());
|
|
186
|
+
|
|
187
|
+
// Get plugin entries (workspace config takes precedence)
|
|
188
|
+
const entries: PluginEntry[] =
|
|
189
|
+
workspaceConfig.plugins?.entries ?? globalConfig.plugins?.entries ?? [];
|
|
190
|
+
|
|
191
|
+
// Get plugins directory
|
|
192
|
+
const pluginsDir = resolvePluginsDirectory(
|
|
193
|
+
workspaceRoot,
|
|
194
|
+
workspaceConfig.plugins?.directory ?? globalConfig.plugins?.directory,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const plugins: LoadedPlugin[] = [];
|
|
198
|
+
const errors: PluginLoadError[] = [];
|
|
199
|
+
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
// Skip disabled plugins
|
|
202
|
+
if (entry.enabled === false) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const plugin = await loadPluginFromPath(entry.path, pluginsDir);
|
|
208
|
+
plugins.push({
|
|
209
|
+
plugin,
|
|
210
|
+
config: entry.config ?? {},
|
|
211
|
+
enabled: true,
|
|
212
|
+
});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
errors.push({
|
|
215
|
+
path: entry.path,
|
|
216
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { plugins, errors };
|
|
222
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readConfigFile, resolveGlobalConfigPath, resolveWorkspaceConfigPath } from "./config";
|
|
2
|
+
import type { HookEvents, HookResult } from "./plugin";
|
|
3
|
+
import { loadPlugins } from "./pluginLoader";
|
|
4
|
+
import { PluginRunner } from "./pluginRunner";
|
|
5
|
+
|
|
6
|
+
// Cache of plugin runners per workspace
|
|
7
|
+
const runners = new Map<string, PluginRunner>();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get or create the plugin runner for a workspace
|
|
11
|
+
* Lazy initialization - plugins are loaded on first access
|
|
12
|
+
*/
|
|
13
|
+
export const getPluginRunner = async (workspaceRoot: string): Promise<PluginRunner> => {
|
|
14
|
+
const existing = runners.get(workspaceRoot);
|
|
15
|
+
if (existing) {
|
|
16
|
+
return existing;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { plugins, errors } = await loadPlugins(workspaceRoot);
|
|
20
|
+
|
|
21
|
+
// Log loading errors
|
|
22
|
+
for (const { path, error } of errors) {
|
|
23
|
+
console.warn(`[plugins] Failed to load ${path}: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const workspaceConfig = await readConfigFile(resolveWorkspaceConfigPath(workspaceRoot));
|
|
27
|
+
const globalConfig = await readConfigFile(resolveGlobalConfigPath());
|
|
28
|
+
const settings = workspaceConfig.plugins?.settings ?? globalConfig.plugins?.settings;
|
|
29
|
+
|
|
30
|
+
const runner = new PluginRunner({
|
|
31
|
+
plugins,
|
|
32
|
+
workspaceRoot,
|
|
33
|
+
settings,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await runner.initialize();
|
|
37
|
+
|
|
38
|
+
runners.set(workspaceRoot, runner);
|
|
39
|
+
|
|
40
|
+
return runner;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Emit a hook event to all plugins in a workspace
|
|
45
|
+
* Convenience function that handles runner initialization
|
|
46
|
+
*/
|
|
47
|
+
export const emitHook = async <K extends keyof HookEvents>(
|
|
48
|
+
workspaceRoot: string,
|
|
49
|
+
hookName: K,
|
|
50
|
+
event: HookEvents[K],
|
|
51
|
+
): Promise<HookResult> => {
|
|
52
|
+
const runner = await getPluginRunner(workspaceRoot);
|
|
53
|
+
return runner.emit(hookName, event);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Clear the plugin runner cache for a workspace
|
|
58
|
+
* Useful for testing or when plugins config changes
|
|
59
|
+
*/
|
|
60
|
+
export const clearPluginRunner = async (workspaceRoot: string): Promise<void> => {
|
|
61
|
+
const runner = runners.get(workspaceRoot);
|
|
62
|
+
if (runner) {
|
|
63
|
+
await runner.shutdown();
|
|
64
|
+
runners.delete(workspaceRoot);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Clear all plugin runner caches
|
|
70
|
+
*/
|
|
71
|
+
export const clearAllPluginRunners = async (): Promise<void> => {
|
|
72
|
+
for (const [workspaceRoot] of runners) {
|
|
73
|
+
await clearPluginRunner(workspaceRoot);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { PluginSettings } from "./config";
|
|
2
|
+
import type { HookEvents, HookResult, PluginContext, PluginLogger } from "./plugin";
|
|
3
|
+
import { PluginError } from "./pluginErrors";
|
|
4
|
+
import type { LoadedPlugin } from "./pluginLoader";
|
|
5
|
+
|
|
6
|
+
export type PluginRunnerOptions = {
|
|
7
|
+
plugins: LoadedPlugin[];
|
|
8
|
+
workspaceRoot: string;
|
|
9
|
+
settings?: PluginSettings;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Expand environment variables in config values
|
|
14
|
+
* Supports ${VAR_NAME} syntax
|
|
15
|
+
*/
|
|
16
|
+
const expandEnvVars = (value: unknown): unknown => {
|
|
17
|
+
if (typeof value === "string") {
|
|
18
|
+
return value.replace(/\$\{(\w+)\}/g, (_, name: string) => process.env[name] ?? "");
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
return value.map(expandEnvVars);
|
|
22
|
+
}
|
|
23
|
+
if (value && typeof value === "object") {
|
|
24
|
+
const result: Record<string, unknown> = {};
|
|
25
|
+
for (const [k, v] of Object.entries(value)) {
|
|
26
|
+
result[k] = expandEnvVars(v);
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a logger for a plugin
|
|
35
|
+
*/
|
|
36
|
+
const createPluginLogger = (
|
|
37
|
+
pluginName: string,
|
|
38
|
+
logLevel: PluginSettings["logLevel"] = "info",
|
|
39
|
+
): PluginLogger => {
|
|
40
|
+
const levels = ["debug", "info", "warn", "error"] as const;
|
|
41
|
+
const minLevel = levels.indexOf(logLevel);
|
|
42
|
+
|
|
43
|
+
const shouldLog = (level: (typeof levels)[number]): boolean => {
|
|
44
|
+
return levels.indexOf(level) >= minLevel;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const format = (level: string, message: string): string => {
|
|
48
|
+
return `[plugin:${pluginName}] [${level.toUpperCase()}] ${message}`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
debug: (message) => {
|
|
53
|
+
if (shouldLog("debug")) {
|
|
54
|
+
console.debug(format("debug", message));
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
info: (message) => {
|
|
58
|
+
if (shouldLog("info")) {
|
|
59
|
+
console.info(format("info", message));
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
warn: (message) => {
|
|
63
|
+
if (shouldLog("warn")) {
|
|
64
|
+
console.warn(format("warn", message));
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
error: (message) => {
|
|
68
|
+
if (shouldLog("error")) {
|
|
69
|
+
console.error(format("error", message));
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run a function with a timeout
|
|
77
|
+
*/
|
|
78
|
+
const runWithTimeout = async <T>(
|
|
79
|
+
fn: () => Promise<T>,
|
|
80
|
+
timeoutMs: number,
|
|
81
|
+
description: string,
|
|
82
|
+
): Promise<T> => {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
reject(new PluginError("HOOK_TIMEOUT", `${description} timed out after ${timeoutMs}ms`));
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
|
|
88
|
+
fn()
|
|
89
|
+
.then((result) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
resolve(result);
|
|
92
|
+
})
|
|
93
|
+
.catch((error) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
reject(error);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export class PluginRunner {
|
|
101
|
+
private plugins: LoadedPlugin[];
|
|
102
|
+
private workspaceRoot: string;
|
|
103
|
+
private settings: PluginSettings;
|
|
104
|
+
private initialized = false;
|
|
105
|
+
|
|
106
|
+
constructor(options: PluginRunnerOptions) {
|
|
107
|
+
this.plugins = options.plugins;
|
|
108
|
+
this.workspaceRoot = options.workspaceRoot;
|
|
109
|
+
this.settings = options.settings ?? {};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Initialize all plugins (call onLoad)
|
|
114
|
+
*/
|
|
115
|
+
async initialize(): Promise<void> {
|
|
116
|
+
if (this.initialized) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const { plugin, config } of this.plugins) {
|
|
121
|
+
if (plugin.onLoad) {
|
|
122
|
+
const context = this.createContext(plugin.name, config);
|
|
123
|
+
try {
|
|
124
|
+
await plugin.onLoad(context);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
context.logger.error(`Failed to initialize: ${error}`);
|
|
127
|
+
if (this.settings.continueOnError === false) {
|
|
128
|
+
throw new PluginError(
|
|
129
|
+
"PLUGIN_INIT_FAILED",
|
|
130
|
+
`Plugin ${plugin.name} failed to initialize: ${error}`,
|
|
131
|
+
{ pluginName: plugin.name, cause: error instanceof Error ? error : undefined },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.initialized = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Emit a hook event to all plugins
|
|
143
|
+
* Returns the final modified task (if any plugin modified it)
|
|
144
|
+
*/
|
|
145
|
+
async emit<K extends keyof HookEvents>(hookName: K, event: HookEvents[K]): Promise<HookResult> {
|
|
146
|
+
const timeout = this.settings.hookTimeout ?? 30000;
|
|
147
|
+
let currentEvent = { ...event };
|
|
148
|
+
let finalResult: HookResult = {};
|
|
149
|
+
|
|
150
|
+
// Run plugins sequentially in config order
|
|
151
|
+
for (const { plugin, config } of this.plugins) {
|
|
152
|
+
const handler = plugin.hooks?.[hookName];
|
|
153
|
+
if (!handler) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const context = this.createContext(plugin.name, config);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const result = await runWithTimeout(
|
|
161
|
+
() => handler(currentEvent, context),
|
|
162
|
+
timeout,
|
|
163
|
+
`${plugin.name}:${hookName}`,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// If plugin returned a modified task, use it for subsequent hooks
|
|
167
|
+
if (result?.task) {
|
|
168
|
+
currentEvent = { ...currentEvent, task: result.task };
|
|
169
|
+
finalResult = { task: result.task };
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
const errorMessage =
|
|
173
|
+
error instanceof Error ? error.message : String(error);
|
|
174
|
+
context.logger.error(`Hook ${hookName} failed: ${errorMessage}`);
|
|
175
|
+
|
|
176
|
+
if (this.settings.continueOnError === false) {
|
|
177
|
+
throw new PluginError(
|
|
178
|
+
"HOOK_EXECUTION_FAILED",
|
|
179
|
+
`Plugin ${plugin.name} hook ${hookName} failed: ${errorMessage}`,
|
|
180
|
+
{
|
|
181
|
+
pluginName: plugin.name,
|
|
182
|
+
hookName,
|
|
183
|
+
cause: error instanceof Error ? error : undefined,
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return finalResult;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Shutdown all plugins (call onUnload)
|
|
195
|
+
*/
|
|
196
|
+
async shutdown(): Promise<void> {
|
|
197
|
+
for (const { plugin, config } of this.plugins) {
|
|
198
|
+
if (plugin.onUnload) {
|
|
199
|
+
const context = this.createContext(plugin.name, config);
|
|
200
|
+
try {
|
|
201
|
+
await plugin.onUnload(context);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
context.logger.error(`Failed to shutdown: ${error}`);
|
|
204
|
+
// Always continue during shutdown, even if continueOnError is false
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private createContext(pluginName: string, config: Record<string, unknown>): PluginContext {
|
|
211
|
+
return {
|
|
212
|
+
workspaceRoot: this.workspaceRoot,
|
|
213
|
+
config: expandEnvVars(config) as Record<string, unknown>,
|
|
214
|
+
logger: createPluginLogger(pluginName, this.settings.logLevel),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
package/core/pr.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Octokit } from "octokit";
|
|
2
|
+
|
|
3
|
+
import type { PrAttachment, PrFetched } from "./schemas/pr";
|
|
4
|
+
import { nowIso } from "./time";
|
|
5
|
+
|
|
6
|
+
export type ParsedPr = {
|
|
7
|
+
url: string;
|
|
8
|
+
provider: "github";
|
|
9
|
+
repo: {
|
|
10
|
+
host: string;
|
|
11
|
+
owner: string;
|
|
12
|
+
name: string;
|
|
13
|
+
};
|
|
14
|
+
number: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const parseGitHubPrUrl = (value: string): ParsedPr | null => {
|
|
18
|
+
let url: URL;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(value);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
26
|
+
if (parts.length < 4 || parts[2] !== "pull") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const number = Number(parts[3]);
|
|
31
|
+
if (!Number.isInteger(number)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
url: value,
|
|
37
|
+
provider: "github",
|
|
38
|
+
repo: {
|
|
39
|
+
host: url.host,
|
|
40
|
+
owner: parts[0],
|
|
41
|
+
name: parts[1],
|
|
42
|
+
},
|
|
43
|
+
number,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const buildPrAttachment = (parsed: ParsedPr, fetched?: PrFetched): PrAttachment => {
|
|
48
|
+
return {
|
|
49
|
+
url: parsed.url,
|
|
50
|
+
provider: "github",
|
|
51
|
+
repo: parsed.repo,
|
|
52
|
+
number: parsed.number,
|
|
53
|
+
fetched,
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const fetchGitHubPr = async (
|
|
58
|
+
parsed: ParsedPr,
|
|
59
|
+
token?: string | null,
|
|
60
|
+
): Promise<PrFetched | null> => {
|
|
61
|
+
if (!parsed.repo || !parsed.number) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!token) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const octokit = new Octokit({ auth: token });
|
|
70
|
+
const response = await octokit.rest.pulls.get({
|
|
71
|
+
owner: parsed.repo.owner,
|
|
72
|
+
repo: parsed.repo.name,
|
|
73
|
+
pull_number: parsed.number,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const data = response.data;
|
|
77
|
+
const state = data.merged ? "merged" : data.state === "open" ? "open" : "closed";
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
at: nowIso(),
|
|
81
|
+
title: data.title,
|
|
82
|
+
author: {
|
|
83
|
+
login: data.user?.login ?? "unknown",
|
|
84
|
+
},
|
|
85
|
+
state,
|
|
86
|
+
draft: data.draft ?? false,
|
|
87
|
+
updated_at: data.updated_at,
|
|
88
|
+
};
|
|
89
|
+
};
|