opencode-pilot 0.1.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/.devcontainer/devcontainer.json +16 -0
- package/.github/workflows/ci.yml +67 -0
- package/.releaserc.cjs +28 -0
- package/AGENTS.md +71 -0
- package/CONTRIBUTING.md +102 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/opencode-pilot +809 -0
- package/dist/opencode-ntfy.tar.gz +0 -0
- package/examples/config.yaml +73 -0
- package/examples/templates/default.md +7 -0
- package/examples/templates/devcontainer.md +7 -0
- package/examples/templates/review-feedback.md +7 -0
- package/examples/templates/review.md +15 -0
- package/install.sh +246 -0
- package/package.json +40 -0
- package/plugin/config.js +76 -0
- package/plugin/index.js +260 -0
- package/plugin/logger.js +125 -0
- package/plugin/notifier.js +110 -0
- package/service/actions.js +334 -0
- package/service/io.opencode.ntfy.plist +29 -0
- package/service/logger.js +82 -0
- package/service/poll-service.js +246 -0
- package/service/poller.js +339 -0
- package/service/readiness.js +234 -0
- package/service/repo-config.js +222 -0
- package/service/server.js +1523 -0
- package/service/utils.js +21 -0
- package/test/run_tests.bash +34 -0
- package/test/test_actions.bash +263 -0
- package/test/test_cli.bash +161 -0
- package/test/test_config.bash +438 -0
- package/test/test_helper.bash +140 -0
- package/test/test_logger.bash +401 -0
- package/test/test_notifier.bash +310 -0
- package/test/test_plist.bash +125 -0
- package/test/test_plugin.bash +952 -0
- package/test/test_poll_service.bash +179 -0
- package/test/test_poller.bash +120 -0
- package/test/test_readiness.bash +313 -0
- package/test/test_repo_config.bash +406 -0
- package/test/test_service.bash +1342 -0
- package/test/unit/actions.test.js +235 -0
- package/test/unit/config.test.js +86 -0
- package/test/unit/paths.test.js +77 -0
- package/test/unit/poll-service.test.js +142 -0
- package/test/unit/poller.test.js +347 -0
- package/test/unit/repo-config.test.js +441 -0
- package/test/unit/utils.test.js +53 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* poller.js - MCP-based polling for automation sources
|
|
3
|
+
*
|
|
4
|
+
* Connects to MCP servers (GitHub, Linear) to fetch items for automation.
|
|
5
|
+
* Tracks processed items to avoid duplicate handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
10
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
11
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import os from "os";
|
|
15
|
+
import { getNestedValue } from "./utils.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Expand template string with item fields
|
|
19
|
+
* Supports {field} and {field.nested} syntax
|
|
20
|
+
* @param {string} template - Template with {placeholders}
|
|
21
|
+
* @param {object} item - Item with fields to substitute
|
|
22
|
+
* @returns {string} Expanded string
|
|
23
|
+
*/
|
|
24
|
+
export function expandItemId(template, item) {
|
|
25
|
+
return template.replace(/\{([^}]+)\}/g, (match, fieldPath) => {
|
|
26
|
+
const value = getNestedValue(item, fieldPath);
|
|
27
|
+
return value !== undefined ? String(value) : match;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Apply field mappings to an item
|
|
33
|
+
* Mappings define how to map source fields to standard fields
|
|
34
|
+
*
|
|
35
|
+
* Supports:
|
|
36
|
+
* - Simple path: "fieldName" or "nested.field.path"
|
|
37
|
+
* - Regex extraction: "url:/issue/([A-Z]+-\d+)/" extracts from url field using regex
|
|
38
|
+
*
|
|
39
|
+
* @param {object} item - Raw item from MCP tool
|
|
40
|
+
* @param {object|null} mappings - Field mappings { targetField: "source.field.path" }
|
|
41
|
+
* @returns {object} Item with mapped fields added (original fields preserved)
|
|
42
|
+
*/
|
|
43
|
+
export function applyMappings(item, mappings) {
|
|
44
|
+
if (!mappings) return item;
|
|
45
|
+
|
|
46
|
+
const result = { ...item };
|
|
47
|
+
|
|
48
|
+
for (const [targetField, sourcePath] of Object.entries(mappings)) {
|
|
49
|
+
// Check for regex extraction syntax: "field:/regex/"
|
|
50
|
+
const regexMatch = sourcePath.match(/^(\w+):\/(.+)\/$/);
|
|
51
|
+
if (regexMatch) {
|
|
52
|
+
const [, field, pattern] = regexMatch;
|
|
53
|
+
const fieldValue = getNestedValue(item, field);
|
|
54
|
+
if (fieldValue) {
|
|
55
|
+
const regex = new RegExp(pattern);
|
|
56
|
+
const match = String(fieldValue).match(regex);
|
|
57
|
+
result[targetField] = match ? (match[1] || match[0]) : undefined;
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
// Simple field path
|
|
61
|
+
result[targetField] = getNestedValue(item, sourcePath);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get tool configuration from a source
|
|
70
|
+
* @param {object} source - Source configuration from config.yaml
|
|
71
|
+
* @returns {object} Tool configuration
|
|
72
|
+
*/
|
|
73
|
+
export function getToolConfig(source) {
|
|
74
|
+
if (!source.tool || !source.tool.mcp || !source.tool.name) {
|
|
75
|
+
throw new Error(`Source '${source.name || 'unknown'}' missing tool configuration (requires tool.mcp and tool.name)`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
mcpServer: source.tool.mcp,
|
|
80
|
+
toolName: source.tool.name,
|
|
81
|
+
args: source.args || {},
|
|
82
|
+
idTemplate: source.item?.id || null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Transform items by adding IDs using template
|
|
88
|
+
* @param {Array} items - Raw items from MCP tool
|
|
89
|
+
* @param {string|null} idTemplate - Template for generating IDs
|
|
90
|
+
* @returns {Array} Items with id field added
|
|
91
|
+
*/
|
|
92
|
+
export function transformItems(items, idTemplate) {
|
|
93
|
+
let counter = 0;
|
|
94
|
+
return items.map((item) => {
|
|
95
|
+
let id;
|
|
96
|
+
if (idTemplate) {
|
|
97
|
+
id = expandItemId(idTemplate, item);
|
|
98
|
+
} else if (item.id) {
|
|
99
|
+
id = item.id;
|
|
100
|
+
} else {
|
|
101
|
+
// Generate a fallback ID
|
|
102
|
+
id = `item-${Date.now()}-${counter++}`;
|
|
103
|
+
}
|
|
104
|
+
return { ...item, id };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse JSON text as an array with error handling
|
|
110
|
+
*/
|
|
111
|
+
function parseJsonArray(text, sourceName) {
|
|
112
|
+
try {
|
|
113
|
+
const data = JSON.parse(text);
|
|
114
|
+
if (Array.isArray(data)) return data;
|
|
115
|
+
if (data.items) return data.items;
|
|
116
|
+
if (data.issues) return data.issues;
|
|
117
|
+
if (data.nodes) return data.nodes;
|
|
118
|
+
return [data];
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error(`[poller] Failed to parse ${sourceName} response:`, err.message);
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Expand environment variables in a string
|
|
127
|
+
*/
|
|
128
|
+
function expandEnvVars(str) {
|
|
129
|
+
return str.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] || "");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create appropriate transport based on MCP config
|
|
134
|
+
*/
|
|
135
|
+
async function createTransport(mcpConfig) {
|
|
136
|
+
const headers = {};
|
|
137
|
+
if (mcpConfig.headers) {
|
|
138
|
+
for (const [key, value] of Object.entries(mcpConfig.headers)) {
|
|
139
|
+
headers[key] = expandEnvVars(value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (mcpConfig.type === "remote") {
|
|
144
|
+
const url = new URL(mcpConfig.url);
|
|
145
|
+
if (mcpConfig.url.includes("linear.app/sse")) {
|
|
146
|
+
return new SSEClientTransport(url, { requestInit: { headers } });
|
|
147
|
+
} else {
|
|
148
|
+
return new StreamableHTTPClientTransport(url, { requestInit: { headers } });
|
|
149
|
+
}
|
|
150
|
+
} else if (mcpConfig.type === "local") {
|
|
151
|
+
const command = mcpConfig.command;
|
|
152
|
+
if (!command || command.length === 0) {
|
|
153
|
+
throw new Error("Local MCP config missing command");
|
|
154
|
+
}
|
|
155
|
+
const [cmd, ...args] = command;
|
|
156
|
+
return new StdioClientTransport({
|
|
157
|
+
command: cmd,
|
|
158
|
+
args,
|
|
159
|
+
env: { ...process.env },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
throw new Error(`Unknown MCP type: ${mcpConfig.type}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get MCP config from opencode.json
|
|
168
|
+
*/
|
|
169
|
+
function getMcpConfig(serverName, configPath) {
|
|
170
|
+
const actualPath = configPath || path.join(os.homedir(), ".config/opencode/opencode.json");
|
|
171
|
+
|
|
172
|
+
if (!fs.existsSync(actualPath)) {
|
|
173
|
+
throw new Error(`MCP config not found: ${actualPath}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const config = JSON.parse(fs.readFileSync(actualPath, "utf-8"));
|
|
177
|
+
const mcpConfig = config.mcp?.[serverName];
|
|
178
|
+
|
|
179
|
+
if (!mcpConfig) {
|
|
180
|
+
throw new Error(`MCP server '${serverName}' not configured`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (mcpConfig.enabled === false) {
|
|
184
|
+
throw new Error(`MCP server '${serverName}' is disabled`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return mcpConfig;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Default timeout for MCP connections (30 seconds)
|
|
191
|
+
const DEFAULT_MCP_TIMEOUT = 30000;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create a timeout promise that rejects after specified ms
|
|
195
|
+
*/
|
|
196
|
+
function createTimeout(ms, operation) {
|
|
197
|
+
return new Promise((_, reject) => {
|
|
198
|
+
setTimeout(() => reject(new Error(`${operation} timed out after ${ms}ms`)), ms);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Poll a source using MCP tools
|
|
204
|
+
*
|
|
205
|
+
* @param {object} source - Source configuration from config.yaml
|
|
206
|
+
* @param {object} [options] - Additional options
|
|
207
|
+
* @param {number} [options.timeout] - Timeout in ms (default: 30000)
|
|
208
|
+
* @param {string} [options.opencodeConfigPath] - Path to opencode.json for MCP config
|
|
209
|
+
* @param {object} [options.mappings] - Field mappings to apply to items
|
|
210
|
+
* @returns {Promise<Array>} Array of items from the source with IDs and mappings applied
|
|
211
|
+
*/
|
|
212
|
+
export async function pollGenericSource(source, options = {}) {
|
|
213
|
+
const toolConfig = getToolConfig(source);
|
|
214
|
+
const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
|
|
215
|
+
const mappings = options.mappings || null;
|
|
216
|
+
const mcpConfig = getMcpConfig(toolConfig.mcpServer, options.opencodeConfigPath);
|
|
217
|
+
const client = new Client({ name: "opencode-pilot", version: "1.0.0" });
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const transport = await createTransport(mcpConfig);
|
|
221
|
+
|
|
222
|
+
// Connect with timeout
|
|
223
|
+
await Promise.race([
|
|
224
|
+
client.connect(transport),
|
|
225
|
+
createTimeout(timeout, "MCP connection"),
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
// Call the tool directly with provided args
|
|
229
|
+
const result = await Promise.race([
|
|
230
|
+
client.callTool({ name: toolConfig.toolName, arguments: toolConfig.args }),
|
|
231
|
+
createTimeout(timeout, "callTool"),
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
// Parse the response
|
|
235
|
+
const text = result.content?.[0]?.text;
|
|
236
|
+
if (!text) return [];
|
|
237
|
+
|
|
238
|
+
const rawItems = parseJsonArray(text, source.name);
|
|
239
|
+
|
|
240
|
+
// Apply field mappings before transforming
|
|
241
|
+
const mappedItems = mappings
|
|
242
|
+
? rawItems.map(item => applyMappings(item, mappings))
|
|
243
|
+
: rawItems;
|
|
244
|
+
|
|
245
|
+
// Transform items (add IDs)
|
|
246
|
+
return transformItems(mappedItems, toolConfig.idTemplate);
|
|
247
|
+
} finally {
|
|
248
|
+
try {
|
|
249
|
+
// Close with timeout to prevent hanging on unresponsive MCP servers
|
|
250
|
+
await Promise.race([
|
|
251
|
+
client.close(),
|
|
252
|
+
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
253
|
+
]);
|
|
254
|
+
} catch {
|
|
255
|
+
// Ignore close errors
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Create a poller instance with state tracking
|
|
262
|
+
*
|
|
263
|
+
* @param {object} options - Poller options
|
|
264
|
+
* @param {string} [options.stateFile] - Path to state file for tracking processed items
|
|
265
|
+
* @param {string} [options.configPath] - Path to opencode.json
|
|
266
|
+
* @returns {object} Poller instance
|
|
267
|
+
*/
|
|
268
|
+
export function createPoller(options = {}) {
|
|
269
|
+
const stateFile = options.stateFile || path.join(os.homedir(), '.config/opencode-pilot/poll-state.json');
|
|
270
|
+
const configPath = options.configPath;
|
|
271
|
+
|
|
272
|
+
// Load existing state
|
|
273
|
+
let processedItems = new Map();
|
|
274
|
+
if (fs.existsSync(stateFile)) {
|
|
275
|
+
try {
|
|
276
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
277
|
+
if (state.processed) {
|
|
278
|
+
processedItems = new Map(Object.entries(state.processed));
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
// Start fresh if state is corrupted
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function saveState() {
|
|
286
|
+
const dir = path.dirname(stateFile);
|
|
287
|
+
if (!fs.existsSync(dir)) {
|
|
288
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
289
|
+
}
|
|
290
|
+
const state = {
|
|
291
|
+
processed: Object.fromEntries(processedItems),
|
|
292
|
+
savedAt: new Date().toISOString(),
|
|
293
|
+
};
|
|
294
|
+
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
/**
|
|
299
|
+
* Check if an item has been processed
|
|
300
|
+
*/
|
|
301
|
+
isProcessed(itemId) {
|
|
302
|
+
return processedItems.has(itemId);
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Mark an item as processed
|
|
307
|
+
*/
|
|
308
|
+
markProcessed(itemId, metadata = {}) {
|
|
309
|
+
processedItems.set(itemId, {
|
|
310
|
+
processedAt: new Date().toISOString(),
|
|
311
|
+
...metadata,
|
|
312
|
+
});
|
|
313
|
+
saveState();
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Clear a specific item from processed state
|
|
318
|
+
*/
|
|
319
|
+
clearProcessed(itemId) {
|
|
320
|
+
processedItems.delete(itemId);
|
|
321
|
+
saveState();
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Clear all processed state
|
|
326
|
+
*/
|
|
327
|
+
clearState() {
|
|
328
|
+
processedItems.clear();
|
|
329
|
+
saveState();
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get all processed item IDs
|
|
334
|
+
*/
|
|
335
|
+
getProcessedIds() {
|
|
336
|
+
return Array.from(processedItems.keys());
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* readiness.js - Issue readiness evaluation for self-iteration
|
|
3
|
+
*
|
|
4
|
+
* Evaluates whether an issue is ready to be worked on based on:
|
|
5
|
+
* - Label constraints (blocking labels, required labels)
|
|
6
|
+
* - Dependencies (blocked by references in body)
|
|
7
|
+
* - Priority scoring (label weights, age bonus)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Dependency reference patterns in issue body
|
|
12
|
+
*/
|
|
13
|
+
const DEPENDENCY_PATTERNS = [
|
|
14
|
+
/blocked by #\d+/i,
|
|
15
|
+
/blocked by [a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+#\d+/i,
|
|
16
|
+
/depends on #\d+/i,
|
|
17
|
+
/depends on [a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+#\d+/i,
|
|
18
|
+
/requires #\d+/i,
|
|
19
|
+
/waiting on #\d+/i,
|
|
20
|
+
/waiting for #\d+/i,
|
|
21
|
+
/after #\d+/i,
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if issue passes label constraints
|
|
26
|
+
* @param {object} issue - Issue with labels array
|
|
27
|
+
* @param {object} config - Repo config with readiness settings
|
|
28
|
+
* @returns {object} { ready: boolean, reason?: string }
|
|
29
|
+
*/
|
|
30
|
+
export function checkLabels(issue, config) {
|
|
31
|
+
const labels = issue.labels || [];
|
|
32
|
+
const labelNames = labels.map((l) =>
|
|
33
|
+
typeof l === "string" ? l.toLowerCase() : (l.name || "").toLowerCase()
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const readinessConfig = config.readiness || {};
|
|
37
|
+
const labelConfig = readinessConfig.labels || {};
|
|
38
|
+
|
|
39
|
+
// Check exclude labels (blocking)
|
|
40
|
+
const excludeLabels = (labelConfig.exclude || []).map((l) => l.toLowerCase());
|
|
41
|
+
const blockingLabels = (
|
|
42
|
+
readinessConfig.dependencies?.blocking_labels || []
|
|
43
|
+
).map((l) => l.toLowerCase());
|
|
44
|
+
|
|
45
|
+
const allBlocked = [...new Set([...excludeLabels, ...blockingLabels])];
|
|
46
|
+
|
|
47
|
+
for (const blockedLabel of allBlocked) {
|
|
48
|
+
if (labelNames.includes(blockedLabel)) {
|
|
49
|
+
return {
|
|
50
|
+
ready: false,
|
|
51
|
+
reason: `Has blocking label: ${blockedLabel}`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check required labels (must have all)
|
|
57
|
+
const requiredLabels = (labelConfig.required || []).map((l) =>
|
|
58
|
+
l.toLowerCase()
|
|
59
|
+
);
|
|
60
|
+
for (const required of requiredLabels) {
|
|
61
|
+
if (!labelNames.includes(required)) {
|
|
62
|
+
return {
|
|
63
|
+
ready: false,
|
|
64
|
+
reason: `Missing required label: ${required}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check any_of labels (must have at least one)
|
|
70
|
+
const anyOfLabels = (labelConfig.any_of || []).map((l) => l.toLowerCase());
|
|
71
|
+
if (anyOfLabels.length > 0) {
|
|
72
|
+
const hasAny = anyOfLabels.some((l) => labelNames.includes(l));
|
|
73
|
+
if (!hasAny) {
|
|
74
|
+
return {
|
|
75
|
+
ready: false,
|
|
76
|
+
reason: `Missing one of required labels: ${anyOfLabels.join(", ")}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { ready: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if issue has dependency references in body
|
|
86
|
+
* @param {object} issue - Issue with body string
|
|
87
|
+
* @param {object} config - Repo config with readiness settings
|
|
88
|
+
* @returns {object} { ready: boolean, reason?: string, dependencies?: string[] }
|
|
89
|
+
*/
|
|
90
|
+
export function checkDependencies(issue, config) {
|
|
91
|
+
const readinessConfig = config.readiness || {};
|
|
92
|
+
const depConfig = readinessConfig.dependencies || {};
|
|
93
|
+
|
|
94
|
+
// Check if body reference checking is enabled
|
|
95
|
+
if (depConfig.check_body_references === false) {
|
|
96
|
+
return { ready: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const body = issue.body || "";
|
|
100
|
+
const bodyLower = body.toLowerCase();
|
|
101
|
+
const foundDependencies = [];
|
|
102
|
+
|
|
103
|
+
// Check explicit dependency patterns
|
|
104
|
+
for (const pattern of DEPENDENCY_PATTERNS) {
|
|
105
|
+
const match = bodyLower.match(pattern);
|
|
106
|
+
if (match) {
|
|
107
|
+
foundDependencies.push(match[0]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (foundDependencies.length > 0) {
|
|
112
|
+
return {
|
|
113
|
+
ready: false,
|
|
114
|
+
reason: `Has dependency references: ${foundDependencies.join(", ")}`,
|
|
115
|
+
dependencies: foundDependencies,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check for unchecked task list items (GitHub checkbox syntax)
|
|
120
|
+
const uncheckedTasks = body.match(/^\s*[-*]\s*\[ \]/gm) || [];
|
|
121
|
+
const checkedTasks = body.match(/^\s*[-*]\s*\[x\]/gim) || [];
|
|
122
|
+
|
|
123
|
+
// If this looks like a tracking issue with subtasks and has unchecked items
|
|
124
|
+
if (uncheckedTasks.length > 0 && checkedTasks.length + uncheckedTasks.length > 1) {
|
|
125
|
+
return {
|
|
126
|
+
ready: false,
|
|
127
|
+
reason: `Has ${uncheckedTasks.length} unchecked subtasks`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { ready: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Calculate priority score for an issue
|
|
136
|
+
* @param {object} issue - Issue with labels and created_at
|
|
137
|
+
* @param {object} config - Repo config with readiness settings
|
|
138
|
+
* @returns {number} Priority score (higher = more urgent)
|
|
139
|
+
*/
|
|
140
|
+
export function calculatePriority(issue, config) {
|
|
141
|
+
const readinessConfig = config.readiness || {};
|
|
142
|
+
const priorityConfig = readinessConfig.priority || {};
|
|
143
|
+
|
|
144
|
+
let score = 0;
|
|
145
|
+
|
|
146
|
+
// Label-based priority
|
|
147
|
+
const labels = issue.labels || [];
|
|
148
|
+
const labelNames = labels.map((l) =>
|
|
149
|
+
typeof l === "string" ? l.toLowerCase() : (l.name || "").toLowerCase()
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const labelWeights = priorityConfig.labels || [];
|
|
153
|
+
for (const { label, weight } of labelWeights) {
|
|
154
|
+
if (labelNames.includes(label.toLowerCase())) {
|
|
155
|
+
score += weight || 0;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Age-based priority (older issues get higher priority)
|
|
160
|
+
const ageWeight = priorityConfig.age_weight || 0;
|
|
161
|
+
if (ageWeight > 0 && issue.created_at) {
|
|
162
|
+
const createdAt = new Date(issue.created_at);
|
|
163
|
+
const ageInDays = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
164
|
+
score += ageInDays * ageWeight;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return Math.round(score * 100) / 100;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Evaluate overall readiness of an issue
|
|
172
|
+
* @param {object} issue - Issue object
|
|
173
|
+
* @param {object} config - Repo config with readiness settings
|
|
174
|
+
* @returns {object} { ready: boolean, reason?: string, priority: number }
|
|
175
|
+
*/
|
|
176
|
+
export function evaluateReadiness(issue, config) {
|
|
177
|
+
// Check labels first
|
|
178
|
+
const labelResult = checkLabels(issue, config);
|
|
179
|
+
if (!labelResult.ready) {
|
|
180
|
+
return {
|
|
181
|
+
ready: false,
|
|
182
|
+
reason: labelResult.reason,
|
|
183
|
+
priority: 0,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check dependencies
|
|
188
|
+
const depResult = checkDependencies(issue, config);
|
|
189
|
+
if (!depResult.ready) {
|
|
190
|
+
return {
|
|
191
|
+
ready: false,
|
|
192
|
+
reason: depResult.reason,
|
|
193
|
+
priority: 0,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Calculate priority for ready issues
|
|
198
|
+
const priority = calculatePriority(issue, config);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
ready: true,
|
|
202
|
+
priority,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Sort issues by priority (highest first)
|
|
208
|
+
* @param {Array} issues - Array of issues
|
|
209
|
+
* @param {object} config - Repo config
|
|
210
|
+
* @returns {Array} Sorted issues with priority scores
|
|
211
|
+
*/
|
|
212
|
+
export function sortByPriority(issues, config) {
|
|
213
|
+
return issues
|
|
214
|
+
.map((issue) => ({
|
|
215
|
+
...issue,
|
|
216
|
+
_priority: calculatePriority(issue, config),
|
|
217
|
+
}))
|
|
218
|
+
.sort((a, b) => b._priority - a._priority);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Filter issues to only ready ones
|
|
223
|
+
* @param {Array} issues - Array of issues
|
|
224
|
+
* @param {object} config - Repo config
|
|
225
|
+
* @returns {Array} Ready issues with evaluation results
|
|
226
|
+
*/
|
|
227
|
+
export function filterReady(issues, config) {
|
|
228
|
+
return issues
|
|
229
|
+
.map((issue) => ({
|
|
230
|
+
...issue,
|
|
231
|
+
_readiness: evaluateReadiness(issue, config),
|
|
232
|
+
}))
|
|
233
|
+
.filter((issue) => issue._readiness.ready);
|
|
234
|
+
}
|