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.
Files changed (50) hide show
  1. package/.devcontainer/devcontainer.json +16 -0
  2. package/.github/workflows/ci.yml +67 -0
  3. package/.releaserc.cjs +28 -0
  4. package/AGENTS.md +71 -0
  5. package/CONTRIBUTING.md +102 -0
  6. package/LICENSE +21 -0
  7. package/README.md +72 -0
  8. package/bin/opencode-pilot +809 -0
  9. package/dist/opencode-ntfy.tar.gz +0 -0
  10. package/examples/config.yaml +73 -0
  11. package/examples/templates/default.md +7 -0
  12. package/examples/templates/devcontainer.md +7 -0
  13. package/examples/templates/review-feedback.md +7 -0
  14. package/examples/templates/review.md +15 -0
  15. package/install.sh +246 -0
  16. package/package.json +40 -0
  17. package/plugin/config.js +76 -0
  18. package/plugin/index.js +260 -0
  19. package/plugin/logger.js +125 -0
  20. package/plugin/notifier.js +110 -0
  21. package/service/actions.js +334 -0
  22. package/service/io.opencode.ntfy.plist +29 -0
  23. package/service/logger.js +82 -0
  24. package/service/poll-service.js +246 -0
  25. package/service/poller.js +339 -0
  26. package/service/readiness.js +234 -0
  27. package/service/repo-config.js +222 -0
  28. package/service/server.js +1523 -0
  29. package/service/utils.js +21 -0
  30. package/test/run_tests.bash +34 -0
  31. package/test/test_actions.bash +263 -0
  32. package/test/test_cli.bash +161 -0
  33. package/test/test_config.bash +438 -0
  34. package/test/test_helper.bash +140 -0
  35. package/test/test_logger.bash +401 -0
  36. package/test/test_notifier.bash +310 -0
  37. package/test/test_plist.bash +125 -0
  38. package/test/test_plugin.bash +952 -0
  39. package/test/test_poll_service.bash +179 -0
  40. package/test/test_poller.bash +120 -0
  41. package/test/test_readiness.bash +313 -0
  42. package/test/test_repo_config.bash +406 -0
  43. package/test/test_service.bash +1342 -0
  44. package/test/unit/actions.test.js +235 -0
  45. package/test/unit/config.test.js +86 -0
  46. package/test/unit/paths.test.js +77 -0
  47. package/test/unit/poll-service.test.js +142 -0
  48. package/test/unit/poller.test.js +347 -0
  49. package/test/unit/repo-config.test.js +441 -0
  50. 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
+ }