opencode-pilot 0.6.1 → 0.7.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/README.md +21 -0
- package/bin/opencode-pilot +66 -0
- package/examples/config.yaml +18 -61
- package/examples/templates/agent-planning.md +8 -0
- package/package.json +1 -1
- package/service/poll-service.js +48 -5
- package/service/poller.js +212 -0
- package/service/presets/github.yaml +4 -0
- package/service/presets/linear.yaml +4 -0
- package/service/repo-config.js +9 -0
- package/test/unit/poller.test.js +422 -0
- package/test/unit/repo-config.test.js +44 -0
package/README.md
CHANGED
|
@@ -75,6 +75,11 @@ Create prompt templates as markdown files in `~/.config/opencode-pilot/templates
|
|
|
75
75
|
opencode-pilot start # Start the service (foreground)
|
|
76
76
|
opencode-pilot status # Check status
|
|
77
77
|
opencode-pilot config # Validate and show config
|
|
78
|
+
opencode-pilot clear # Show state summary
|
|
79
|
+
opencode-pilot clear --all # Clear all processed state
|
|
80
|
+
opencode-pilot clear --expired # Clear expired entries (uses configured TTL)
|
|
81
|
+
opencode-pilot clear --source X # Clear entries for source X
|
|
82
|
+
opencode-pilot clear --item ID # Clear specific item
|
|
78
83
|
opencode-pilot test-source NAME # Test a source
|
|
79
84
|
opencode-pilot test-mapping MCP # Test field mappings
|
|
80
85
|
```
|
|
@@ -86,6 +91,22 @@ opencode-pilot test-mapping MCP # Test field mappings
|
|
|
86
91
|
3. **Spawn sessions** - Start `opencode run` with the appropriate prompt template
|
|
87
92
|
4. **Track state** - Remember which items have been processed
|
|
88
93
|
|
|
94
|
+
## Known Issues
|
|
95
|
+
|
|
96
|
+
### Working directory doesn't switch when templates create worktrees/devcontainers
|
|
97
|
+
|
|
98
|
+
When a template instructs the agent to create a git worktree or switch to a devcontainer, OpenCode's internal working directory context (`Instance.directory`) doesn't update. This means:
|
|
99
|
+
|
|
100
|
+
- The "Session changes" panel shows diffs from the original directory
|
|
101
|
+
- File tools may resolve paths relative to the wrong location
|
|
102
|
+
- The agent works in the new directory, but OpenCode doesn't follow
|
|
103
|
+
|
|
104
|
+
**Workaround**: Start OpenCode directly in the target directory, or use separate terminal sessions.
|
|
105
|
+
|
|
106
|
+
**Upstream issue**: [anomalyco/opencode#6697](https://github.com/anomalyco/opencode/issues/6697)
|
|
107
|
+
|
|
108
|
+
**Related**: [opencode-devcontainers#103](https://github.com/athal7/opencode-devcontainers/issues/103)
|
|
109
|
+
|
|
89
110
|
## Related
|
|
90
111
|
|
|
91
112
|
- [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) - Run multiple devcontainer instances for OpenCode
|
package/bin/opencode-pilot
CHANGED
|
@@ -116,10 +116,17 @@ Commands:
|
|
|
116
116
|
start Start the polling service (foreground)
|
|
117
117
|
status Show service status
|
|
118
118
|
config Validate and show configuration
|
|
119
|
+
clear Clear processed state entries
|
|
119
120
|
test-source NAME Test a source by fetching items and showing mappings
|
|
120
121
|
test-mapping MCP Test field mappings with sample JSON input
|
|
121
122
|
help Show this help message
|
|
122
123
|
|
|
124
|
+
Clear options:
|
|
125
|
+
--all Clear all processed entries
|
|
126
|
+
--source NAME Clear entries for a specific source
|
|
127
|
+
--item ID Clear a specific item
|
|
128
|
+
--expired Clear only expired entries (uses configured TTL)
|
|
129
|
+
|
|
123
130
|
The service handles:
|
|
124
131
|
- Polling for GitHub/Linear issues to work on
|
|
125
132
|
- Spawning OpenCode sessions for ready items
|
|
@@ -128,6 +135,8 @@ Examples:
|
|
|
128
135
|
opencode-pilot start # Start service (foreground)
|
|
129
136
|
opencode-pilot status # Check status
|
|
130
137
|
opencode-pilot config # Validate and show config
|
|
138
|
+
opencode-pilot clear --all # Clear all processed state
|
|
139
|
+
opencode-pilot clear --expired # Clear expired entries
|
|
131
140
|
opencode-pilot test-source my-issues # Test a source
|
|
132
141
|
echo '{"url":"https://linear.app/team/issue/PROJ-123/title"}' | opencode-pilot test-mapping linear
|
|
133
142
|
`);
|
|
@@ -602,6 +611,59 @@ async function testMappingCommand(mcpName) {
|
|
|
602
611
|
}
|
|
603
612
|
}
|
|
604
613
|
|
|
614
|
+
// ============================================================================
|
|
615
|
+
// Clear Command
|
|
616
|
+
// ============================================================================
|
|
617
|
+
|
|
618
|
+
async function clearCommand(flags) {
|
|
619
|
+
try {
|
|
620
|
+
const { createPoller } = await import(join(serviceDir, "poller.js"));
|
|
621
|
+
const { loadRepoConfig, getCleanupTtlDays } = await import(join(serviceDir, "repo-config.js"));
|
|
622
|
+
|
|
623
|
+
// Load config for TTL settings
|
|
624
|
+
if (existsSync(PILOT_CONFIG_FILE)) {
|
|
625
|
+
loadRepoConfig(PILOT_CONFIG_FILE);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const poller = createPoller();
|
|
629
|
+
const beforeCount = poller.getProcessedCount();
|
|
630
|
+
|
|
631
|
+
if (flags.all) {
|
|
632
|
+
poller.clearState();
|
|
633
|
+
console.log(`Cleared all ${beforeCount} processed entries`);
|
|
634
|
+
} else if (flags.source) {
|
|
635
|
+
const removed = poller.clearBySource(flags.source);
|
|
636
|
+
console.log(`Cleared ${removed} entries for source: ${flags.source}`);
|
|
637
|
+
} else if (flags.item) {
|
|
638
|
+
if (poller.isProcessed(flags.item)) {
|
|
639
|
+
poller.clearProcessed(flags.item);
|
|
640
|
+
console.log(`Cleared item: ${flags.item}`);
|
|
641
|
+
} else {
|
|
642
|
+
console.log(`Item not found in processed state: ${flags.item}`);
|
|
643
|
+
}
|
|
644
|
+
} else if (flags.expired) {
|
|
645
|
+
const ttlDays = getCleanupTtlDays();
|
|
646
|
+
const removed = poller.cleanupExpired(ttlDays);
|
|
647
|
+
console.log(`Cleared ${removed} entries older than ${ttlDays} days`);
|
|
648
|
+
} else {
|
|
649
|
+
// No flags - show current state summary
|
|
650
|
+
console.log("Poll state summary:");
|
|
651
|
+
console.log(` Total entries: ${beforeCount}`);
|
|
652
|
+
console.log(` State file: ~/.config/opencode-pilot/poll-state.json`);
|
|
653
|
+
console.log("");
|
|
654
|
+
console.log("Usage:");
|
|
655
|
+
console.log(" opencode-pilot clear --all Clear all entries");
|
|
656
|
+
console.log(" opencode-pilot clear --source NAME Clear entries for source");
|
|
657
|
+
console.log(" opencode-pilot clear --item ID Clear specific item");
|
|
658
|
+
console.log(" opencode-pilot clear --expired Clear expired entries");
|
|
659
|
+
}
|
|
660
|
+
} catch (err) {
|
|
661
|
+
console.error(`Error: ${err.message}`);
|
|
662
|
+
console.error("The state file may be corrupted. Try: opencode-pilot clear --all");
|
|
663
|
+
process.exit(1);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
605
667
|
// ============================================================================
|
|
606
668
|
// Main
|
|
607
669
|
// ============================================================================
|
|
@@ -628,6 +690,10 @@ async function main() {
|
|
|
628
690
|
await configCommand();
|
|
629
691
|
break;
|
|
630
692
|
|
|
693
|
+
case "clear":
|
|
694
|
+
await clearCommand(parseArgs(args).flags);
|
|
695
|
+
break;
|
|
696
|
+
|
|
631
697
|
case "test-source":
|
|
632
698
|
await testSourceCommand(subcommand);
|
|
633
699
|
break;
|
package/examples/config.yaml
CHANGED
|
@@ -1,61 +1,38 @@
|
|
|
1
1
|
# Example config.yaml for opencode-pilot
|
|
2
|
-
#
|
|
3
|
-
# Copy to ~/.config/opencode-pilot/config.yaml and customize
|
|
4
|
-
# Also create templates/ directory with prompt template files
|
|
2
|
+
# Copy to ~/.config/opencode-pilot/config.yaml
|
|
5
3
|
|
|
6
|
-
# =============================================================================
|
|
7
|
-
# DEFAULTS - Applied to all sources (source values override these)
|
|
8
|
-
# =============================================================================
|
|
9
4
|
defaults:
|
|
10
|
-
agent: plan
|
|
11
|
-
prompt: default
|
|
5
|
+
agent: plan
|
|
6
|
+
prompt: default
|
|
12
7
|
|
|
13
|
-
# =============================================================================
|
|
14
|
-
# SOURCES - What to poll
|
|
15
|
-
#
|
|
16
|
-
# Three syntax options:
|
|
17
|
-
# 1. Presets: Use built-in presets for common patterns
|
|
18
|
-
# 2. Shorthand: Use `github: "query"` for GitHub sources
|
|
19
|
-
# 3. Full: Specify tool, args, item for custom sources
|
|
20
|
-
# =============================================================================
|
|
21
8
|
sources:
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
# GitHub issues assigned to me
|
|
9
|
+
# Presets - common patterns with sensible defaults
|
|
25
10
|
- preset: github/my-issues
|
|
26
11
|
prompt: worktree
|
|
27
12
|
|
|
28
|
-
# GitHub PRs needing my review
|
|
29
13
|
- preset: github/review-requests
|
|
30
14
|
prompt: review
|
|
31
15
|
|
|
32
|
-
# My PRs that have change requests (filter to specific repos)
|
|
33
16
|
- preset: github/my-prs-feedback
|
|
34
17
|
prompt: review-feedback
|
|
35
|
-
repos:
|
|
18
|
+
repos:
|
|
36
19
|
- myorg/backend
|
|
37
20
|
- myorg/frontend
|
|
38
21
|
|
|
39
|
-
# Linear
|
|
40
|
-
# Find IDs via: opencode-pilot discover linear
|
|
22
|
+
# Linear (requires teamId and assigneeId)
|
|
41
23
|
- preset: linear/my-issues
|
|
42
24
|
args:
|
|
43
|
-
teamId: "your-team-uuid"
|
|
44
|
-
assigneeId: "your-user-uuid"
|
|
25
|
+
teamId: "your-team-uuid"
|
|
26
|
+
assigneeId: "your-user-uuid"
|
|
45
27
|
status: "In Progress"
|
|
46
28
|
working_dir: ~/code/myproject
|
|
47
29
|
prompt: worktree
|
|
48
30
|
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
# Custom GitHub query with shorthand
|
|
31
|
+
# GitHub shorthand syntax
|
|
52
32
|
# - name: urgent-issues
|
|
53
33
|
# github: "is:issue assignee:@me label:urgent state:open"
|
|
54
|
-
# prompt: worktree
|
|
55
34
|
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
# Apple Reminders example (requires tools config below)
|
|
35
|
+
# Full syntax for custom MCP sources (e.g., Apple Reminders)
|
|
59
36
|
# - name: agent-tasks
|
|
60
37
|
# tool:
|
|
61
38
|
# mcp: apple-reminders
|
|
@@ -65,19 +42,8 @@ sources:
|
|
|
65
42
|
# completed: false
|
|
66
43
|
# item:
|
|
67
44
|
# id: "reminder:{id}"
|
|
68
|
-
# prompt: agent-planning
|
|
69
|
-
# working_dir: ~
|
|
70
45
|
|
|
71
|
-
#
|
|
72
|
-
# TOOLS - Field mappings for custom MCP servers (OPTIONAL)
|
|
73
|
-
#
|
|
74
|
-
# Note: GitHub and Linear presets include built-in mappings.
|
|
75
|
-
# Only needed for custom sources or to override preset defaults.
|
|
76
|
-
#
|
|
77
|
-
# Each tool provider can specify:
|
|
78
|
-
# response_key: Key to extract array from response (e.g., "reminders")
|
|
79
|
-
# mappings: Field name translations (e.g., { body: "notes" })
|
|
80
|
-
# =============================================================================
|
|
46
|
+
# Tool config for custom MCP servers (GitHub/Linear have built-in config)
|
|
81
47
|
# tools:
|
|
82
48
|
# apple-reminders:
|
|
83
49
|
# response_key: reminders
|
|
@@ -85,23 +51,14 @@ sources:
|
|
|
85
51
|
# title: name
|
|
86
52
|
# body: notes
|
|
87
53
|
|
|
88
|
-
#
|
|
89
|
-
# REPOS - Map GitHub repos to local paths (OPTIONAL)
|
|
90
|
-
#
|
|
91
|
-
# GitHub presets automatically resolve the repo from each item's
|
|
92
|
-
# repository.full_name field. Add entries here to map repos to local paths.
|
|
93
|
-
# =============================================================================
|
|
54
|
+
# Map repos to local paths
|
|
94
55
|
# repos:
|
|
95
56
|
# myorg/backend:
|
|
96
57
|
# path: ~/code/backend
|
|
97
|
-
#
|
|
98
|
-
# myorg/frontend:
|
|
99
|
-
# path: ~/code/frontend
|
|
100
58
|
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
# =============================================================================
|
|
59
|
+
# Cleanup config (optional, sensible defaults)
|
|
60
|
+
# cleanup:
|
|
61
|
+
# ttl_days: 30
|
|
62
|
+
|
|
63
|
+
# Available presets: github/my-issues, github/review-requests,
|
|
64
|
+
# github/my-prs-feedback, linear/my-issues
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Help me with this task: {name}
|
|
2
|
+
|
|
3
|
+
Research and provide:
|
|
4
|
+
1. **Specific recommendations** - Product links, services, or solutions that fit my needs
|
|
5
|
+
2. **Key considerations** - What should I know before deciding or acting?
|
|
6
|
+
3. **Next steps** - What's the simplest path to completing this?
|
|
7
|
+
|
|
8
|
+
Use web search if needed. Be concise and actionable.
|
package/package.json
CHANGED
package/service/poll-service.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 5. Track processed items to avoid duplicates
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig, resolveRepoForItem } from "./repo-config.js";
|
|
12
|
+
import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig, resolveRepoForItem, getCleanupTtlDays } from "./repo-config.js";
|
|
13
13
|
import { createPoller, pollGenericSource } from "./poller.js";
|
|
14
14
|
import { evaluateReadiness, sortByPriority } from "./readiness.js";
|
|
15
15
|
import { executeAction, buildCommand } from "./actions.js";
|
|
@@ -113,11 +113,12 @@ export async function pollOnce(options = {}) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
let items = [];
|
|
116
|
+
let toolProviderConfig = null;
|
|
116
117
|
|
|
117
118
|
// Fetch items from source
|
|
118
119
|
if (!skipMcp) {
|
|
119
120
|
try {
|
|
120
|
-
|
|
121
|
+
toolProviderConfig = getToolProviderConfig(source.tool.mcp);
|
|
121
122
|
items = await pollGenericSource(source, { toolProviderConfig });
|
|
122
123
|
debug(`Fetched ${items.length} items from ${sourceName}`);
|
|
123
124
|
} catch (err) {
|
|
@@ -153,12 +154,22 @@ export async function pollOnce(options = {}) {
|
|
|
153
154
|
const sortedItems = sortByPriority(readyItems, sortConfig);
|
|
154
155
|
|
|
155
156
|
// Process ready items
|
|
157
|
+
// Get reprocess_on config from provider (e.g., ['state', 'updated_at'] for GitHub)
|
|
158
|
+
const reprocessOn = toolProviderConfig?.reprocess_on || source.reprocess_on;
|
|
159
|
+
|
|
156
160
|
debug(`Processing ${sortedItems.length} sorted items`);
|
|
157
161
|
for (const item of sortedItems) {
|
|
158
162
|
// Check if already processed
|
|
159
163
|
if (pollerInstance && pollerInstance.isProcessed(item.id)) {
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
// Check if item should be reprocessed (reopened, status changed, etc.)
|
|
165
|
+
if (pollerInstance.shouldReprocess(item, { reprocessOn })) {
|
|
166
|
+
debug(`Reprocessing ${item.id} - state changed`);
|
|
167
|
+
pollerInstance.clearProcessed(item.id);
|
|
168
|
+
console.log(`[poll] Reprocessing ${item.id} (reopened or updated)`);
|
|
169
|
+
} else {
|
|
170
|
+
debug(`Skipping ${item.id} - already processed`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
debug(`Executing action for ${item.id}`);
|
|
@@ -184,8 +195,15 @@ export async function pollOnce(options = {}) {
|
|
|
184
195
|
|
|
185
196
|
if (result.success) {
|
|
186
197
|
// Mark as processed to avoid re-triggering
|
|
198
|
+
// Store item state for detecting reopened/updated items
|
|
187
199
|
if (pollerInstance) {
|
|
188
|
-
pollerInstance.markProcessed(item.id, {
|
|
200
|
+
pollerInstance.markProcessed(item.id, {
|
|
201
|
+
repoKey: item.repo_key,
|
|
202
|
+
command: result.command,
|
|
203
|
+
source: sourceName,
|
|
204
|
+
itemState: item.state || item.status || null,
|
|
205
|
+
itemUpdatedAt: item.updated_at || null,
|
|
206
|
+
});
|
|
189
207
|
}
|
|
190
208
|
console.log(`[poll] Started session for ${item.id}`);
|
|
191
209
|
} else {
|
|
@@ -200,6 +218,21 @@ export async function pollOnce(options = {}) {
|
|
|
200
218
|
}
|
|
201
219
|
}
|
|
202
220
|
}
|
|
221
|
+
|
|
222
|
+
// Track which items are present/missing for reappearance detection
|
|
223
|
+
// Also clean up state entries for items no longer returned by this source
|
|
224
|
+
if (pollerInstance && items.length > 0) {
|
|
225
|
+
const currentItemIds = items.map(item => item.id);
|
|
226
|
+
|
|
227
|
+
// Mark items as seen/unseen for reappearance detection
|
|
228
|
+
pollerInstance.markUnseen(sourceName, currentItemIds);
|
|
229
|
+
|
|
230
|
+
// Clean up old entries (only removes entries older than 1 day)
|
|
231
|
+
const removed = pollerInstance.cleanupMissingFromSource(sourceName, currentItemIds, 1);
|
|
232
|
+
if (removed > 0) {
|
|
233
|
+
debug(`Cleaned up ${removed} stale state entries for source ${sourceName}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
203
236
|
}
|
|
204
237
|
|
|
205
238
|
return results;
|
|
@@ -215,9 +248,19 @@ export async function pollOnce(options = {}) {
|
|
|
215
248
|
export function startPolling(options = {}) {
|
|
216
249
|
const { interval = DEFAULT_POLL_INTERVAL, configPath } = options;
|
|
217
250
|
|
|
251
|
+
// Load config to access cleanup settings
|
|
252
|
+
loadRepoConfig(configPath);
|
|
253
|
+
|
|
218
254
|
// Initialize poller for state tracking
|
|
219
255
|
pollerInstance = createPoller({ configPath });
|
|
220
256
|
|
|
257
|
+
// Clean up expired entries on startup
|
|
258
|
+
const ttlDays = getCleanupTtlDays();
|
|
259
|
+
const expiredRemoved = pollerInstance.cleanupExpired(ttlDays);
|
|
260
|
+
if (expiredRemoved > 0) {
|
|
261
|
+
console.log(`[poll] Cleaned up ${expiredRemoved} expired state entries (older than ${ttlDays} days)`);
|
|
262
|
+
}
|
|
263
|
+
|
|
221
264
|
// Run first poll immediately
|
|
222
265
|
pollOnce({ configPath }).catch((err) => {
|
|
223
266
|
console.error("[poll] Error in poll cycle:", err.message);
|
package/service/poller.js
CHANGED
|
@@ -324,11 +324,84 @@ export function createPoller(options = {}) {
|
|
|
324
324
|
markProcessed(itemId, metadata = {}) {
|
|
325
325
|
processedItems.set(itemId, {
|
|
326
326
|
processedAt: new Date().toISOString(),
|
|
327
|
+
lastSeenAt: new Date().toISOString(),
|
|
327
328
|
...metadata,
|
|
328
329
|
});
|
|
329
330
|
saveState();
|
|
330
331
|
},
|
|
331
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Update lastSeenAt for items currently in poll results
|
|
335
|
+
* Call this after each poll to track which items are still present
|
|
336
|
+
* @param {string[]} itemIds - IDs of items in current poll results
|
|
337
|
+
*/
|
|
338
|
+
markSeen(itemIds) {
|
|
339
|
+
const now = new Date().toISOString();
|
|
340
|
+
let changed = false;
|
|
341
|
+
for (const id of itemIds) {
|
|
342
|
+
const meta = processedItems.get(id);
|
|
343
|
+
if (meta) {
|
|
344
|
+
meta.lastSeenAt = now;
|
|
345
|
+
changed = true;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (changed) saveState();
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Check if an item has reappeared after being missing from poll results
|
|
353
|
+
* @param {string} itemId - Item ID
|
|
354
|
+
* @returns {boolean} True if item was missing and has now reappeared
|
|
355
|
+
*/
|
|
356
|
+
hasReappeared(itemId) {
|
|
357
|
+
const meta = processedItems.get(itemId);
|
|
358
|
+
if (!meta) return false;
|
|
359
|
+
if (!meta.lastSeenAt) return false;
|
|
360
|
+
|
|
361
|
+
// If lastSeenAt is older than processedAt, the item disappeared and reappeared
|
|
362
|
+
// (lastSeenAt wasn't updated because item wasn't in poll results)
|
|
363
|
+
const lastSeen = new Date(meta.lastSeenAt).getTime();
|
|
364
|
+
const processed = new Date(meta.processedAt).getTime();
|
|
365
|
+
|
|
366
|
+
// Item reappeared if it was last seen at processing time but not since
|
|
367
|
+
// We check if there's a gap of at least one poll interval (assume 5 min)
|
|
368
|
+
// Actually, simpler: if lastSeenAt equals processedAt after multiple polls,
|
|
369
|
+
// the item was missing. But we need to track poll cycles...
|
|
370
|
+
|
|
371
|
+
// Simpler approach: track wasSeenInLastPoll flag
|
|
372
|
+
return meta.wasUnseen === true;
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Mark items that were NOT in poll results as unseen
|
|
377
|
+
* @param {string} sourceName - Source name
|
|
378
|
+
* @param {string[]} currentItemIds - IDs of items in current poll results
|
|
379
|
+
*/
|
|
380
|
+
markUnseen(sourceName, currentItemIds) {
|
|
381
|
+
const currentSet = new Set(currentItemIds);
|
|
382
|
+
let changed = false;
|
|
383
|
+
for (const [id, meta] of processedItems) {
|
|
384
|
+
if (meta.source === sourceName) {
|
|
385
|
+
if (currentSet.has(id)) {
|
|
386
|
+
// Item is present - clear unseen flag, update lastSeenAt
|
|
387
|
+
if (meta.wasUnseen) {
|
|
388
|
+
meta.wasUnseen = false;
|
|
389
|
+
changed = true;
|
|
390
|
+
}
|
|
391
|
+
meta.lastSeenAt = new Date().toISOString();
|
|
392
|
+
changed = true;
|
|
393
|
+
} else {
|
|
394
|
+
// Item is missing - mark as unseen
|
|
395
|
+
if (!meta.wasUnseen) {
|
|
396
|
+
meta.wasUnseen = true;
|
|
397
|
+
changed = true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (changed) saveState();
|
|
403
|
+
},
|
|
404
|
+
|
|
332
405
|
/**
|
|
333
406
|
* Clear a specific item from processed state
|
|
334
407
|
*/
|
|
@@ -351,5 +424,144 @@ export function createPoller(options = {}) {
|
|
|
351
424
|
getProcessedIds() {
|
|
352
425
|
return Array.from(processedItems.keys());
|
|
353
426
|
},
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Get count of processed items, optionally filtered by source
|
|
430
|
+
* @param {string} [sourceName] - Optional source filter
|
|
431
|
+
* @returns {number} Count of entries
|
|
432
|
+
*/
|
|
433
|
+
getProcessedCount(sourceName) {
|
|
434
|
+
if (!sourceName) return processedItems.size;
|
|
435
|
+
let count = 0;
|
|
436
|
+
for (const [, meta] of processedItems) {
|
|
437
|
+
if (meta.source === sourceName) count++;
|
|
438
|
+
}
|
|
439
|
+
return count;
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Clear all entries for a specific source
|
|
444
|
+
* @param {string} sourceName - Source name
|
|
445
|
+
* @returns {number} Number of entries removed
|
|
446
|
+
*/
|
|
447
|
+
clearBySource(sourceName) {
|
|
448
|
+
let removed = 0;
|
|
449
|
+
for (const [id, meta] of processedItems) {
|
|
450
|
+
if (meta.source === sourceName) {
|
|
451
|
+
processedItems.delete(id);
|
|
452
|
+
removed++;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (removed > 0) saveState();
|
|
456
|
+
return removed;
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Remove entries older than ttlDays
|
|
461
|
+
* @param {number} [ttlDays=30] - Days before expiration
|
|
462
|
+
* @returns {number} Number of entries removed
|
|
463
|
+
*/
|
|
464
|
+
cleanupExpired(ttlDays = 30) {
|
|
465
|
+
const cutoffMs = Date.now() - (ttlDays * 24 * 60 * 60 * 1000);
|
|
466
|
+
let removed = 0;
|
|
467
|
+
for (const [id, meta] of processedItems) {
|
|
468
|
+
const processedAt = new Date(meta.processedAt).getTime();
|
|
469
|
+
if (processedAt < cutoffMs) {
|
|
470
|
+
processedItems.delete(id);
|
|
471
|
+
removed++;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (removed > 0) saveState();
|
|
475
|
+
return removed;
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Remove entries for a source that are no longer in current items
|
|
480
|
+
* Only removes entries older than minAgeDays to avoid race conditions
|
|
481
|
+
* @param {string} sourceName - Source name to clean
|
|
482
|
+
* @param {string[]} currentItemIds - Current item IDs from source
|
|
483
|
+
* @param {number} [minAgeDays=1] - Minimum age before cleanup (0 = immediate)
|
|
484
|
+
* @returns {number} Number of entries removed
|
|
485
|
+
*/
|
|
486
|
+
cleanupMissingFromSource(sourceName, currentItemIds, minAgeDays = 1) {
|
|
487
|
+
const currentSet = new Set(currentItemIds);
|
|
488
|
+
// Timestamp cutoff: entries processed before this time are eligible for cleanup
|
|
489
|
+
const cutoffTimestamp = Date.now() - (minAgeDays * 24 * 60 * 60 * 1000);
|
|
490
|
+
let removed = 0;
|
|
491
|
+
for (const [id, meta] of processedItems) {
|
|
492
|
+
if (meta.source === sourceName && !currentSet.has(id)) {
|
|
493
|
+
const processedAt = new Date(meta.processedAt).getTime();
|
|
494
|
+
// Use <= to allow immediate cleanup when minAgeDays=0
|
|
495
|
+
if (processedAt <= cutoffTimestamp) {
|
|
496
|
+
processedItems.delete(id);
|
|
497
|
+
removed++;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (removed > 0) saveState();
|
|
502
|
+
return removed;
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Check if an item should be reprocessed based on state changes
|
|
507
|
+
* Uses reprocess_on config to determine which fields to check.
|
|
508
|
+
* Also reprocesses items that reappeared after being missing.
|
|
509
|
+
*
|
|
510
|
+
* @param {object} item - Current item from source
|
|
511
|
+
* @param {object} [options] - Options
|
|
512
|
+
* @param {string[]} [options.reprocessOn] - Fields to check for changes (e.g., ['state', 'updated_at'])
|
|
513
|
+
* @returns {boolean} True if item should be reprocessed
|
|
514
|
+
*/
|
|
515
|
+
shouldReprocess(item, options = {}) {
|
|
516
|
+
if (!item.id) return false;
|
|
517
|
+
|
|
518
|
+
const meta = processedItems.get(item.id);
|
|
519
|
+
if (!meta) return false; // Not processed before
|
|
520
|
+
|
|
521
|
+
// Check if item reappeared after being missing (e.g., uncompleted reminder)
|
|
522
|
+
if (meta.wasUnseen) {
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Get reprocess_on fields from options, default to state/status only
|
|
527
|
+
// Note: updated_at is NOT included by default because our own changes would trigger reprocessing
|
|
528
|
+
const reprocessOn = options.reprocessOn || ['state', 'status'];
|
|
529
|
+
|
|
530
|
+
// Check each configured field for changes
|
|
531
|
+
for (const field of reprocessOn) {
|
|
532
|
+
// Handle state/status fields (detect reopening)
|
|
533
|
+
if (field === 'state' || field === 'status') {
|
|
534
|
+
const storedState = meta.itemState;
|
|
535
|
+
const currentState = item[field];
|
|
536
|
+
|
|
537
|
+
if (storedState && currentState) {
|
|
538
|
+
const stored = storedState.toLowerCase();
|
|
539
|
+
const current = currentState.toLowerCase();
|
|
540
|
+
|
|
541
|
+
// Reopened: was closed/merged/done, now open/in-progress
|
|
542
|
+
if ((stored === 'closed' || stored === 'merged' || stored === 'done')
|
|
543
|
+
&& (current === 'open' || current === 'in progress')) {
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Handle timestamp fields (detect updates)
|
|
550
|
+
if (field === 'updated_at' || field === 'updatedAt') {
|
|
551
|
+
const storedTimestamp = meta.itemUpdatedAt;
|
|
552
|
+
const currentTimestamp = item[field] || item.updated_at || item.updatedAt;
|
|
553
|
+
|
|
554
|
+
if (storedTimestamp && currentTimestamp) {
|
|
555
|
+
const storedTime = new Date(storedTimestamp).getTime();
|
|
556
|
+
const currentTime = new Date(currentTimestamp).getTime();
|
|
557
|
+
if (currentTime > storedTime) {
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return false;
|
|
565
|
+
},
|
|
354
566
|
};
|
|
355
567
|
}
|
|
@@ -6,6 +6,10 @@ _provider:
|
|
|
6
6
|
mappings:
|
|
7
7
|
# Extract repo full name from repository_url (e.g., "https://api.github.com/repos/owner/repo")
|
|
8
8
|
repository_full_name: "repository_url:/repos\\/([^/]+\\/[^/]+)$/"
|
|
9
|
+
# Reprocess items when state changes (e.g., reopened issues)
|
|
10
|
+
# Note: updated_at is NOT included because our own changes would trigger reprocessing
|
|
11
|
+
reprocess_on:
|
|
12
|
+
- state # Detect reopened (closed -> open)
|
|
9
13
|
|
|
10
14
|
# Presets
|
|
11
15
|
my-issues:
|
|
@@ -6,6 +6,10 @@ _provider:
|
|
|
6
6
|
mappings:
|
|
7
7
|
body: title
|
|
8
8
|
number: "url:/([A-Z0-9]+-[0-9]+)/"
|
|
9
|
+
# Reprocess items when status regresses (e.g., Done -> In Progress)
|
|
10
|
+
# Note: updatedAt is NOT included because our own changes would trigger reprocessing
|
|
11
|
+
reprocess_on:
|
|
12
|
+
- status # Detect status changes (Done -> In Progress)
|
|
9
13
|
|
|
10
14
|
# Presets
|
|
11
15
|
my-issues:
|
package/service/repo-config.js
CHANGED
|
@@ -301,6 +301,15 @@ export function findRepoByPath(searchPath) {
|
|
|
301
301
|
return null;
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Get cleanup TTL days from config
|
|
306
|
+
* @returns {number} TTL in days (default: 30)
|
|
307
|
+
*/
|
|
308
|
+
export function getCleanupTtlDays() {
|
|
309
|
+
const config = getRawConfig();
|
|
310
|
+
return config?.cleanup?.ttl_days ?? 30;
|
|
311
|
+
}
|
|
312
|
+
|
|
304
313
|
/**
|
|
305
314
|
* Clear config cache (for testing)
|
|
306
315
|
*/
|
package/test/unit/poller.test.js
CHANGED
|
@@ -106,6 +106,428 @@ describe('poller.js', () => {
|
|
|
106
106
|
assert.strictEqual(poller.getProcessedIds().length, 0);
|
|
107
107
|
assert.strictEqual(poller.isProcessed('item-1'), false);
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
test('clearProcessed removes a single item', async () => {
|
|
111
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
112
|
+
|
|
113
|
+
const poller = createPoller({ stateFile });
|
|
114
|
+
poller.markProcessed('item-1', { source: 'test' });
|
|
115
|
+
poller.markProcessed('item-2', { source: 'test' });
|
|
116
|
+
|
|
117
|
+
poller.clearProcessed('item-1');
|
|
118
|
+
|
|
119
|
+
assert.strictEqual(poller.isProcessed('item-1'), false);
|
|
120
|
+
assert.strictEqual(poller.isProcessed('item-2'), true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('cleanup methods', () => {
|
|
125
|
+
test('getProcessedCount returns total count', async () => {
|
|
126
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
127
|
+
|
|
128
|
+
const poller = createPoller({ stateFile });
|
|
129
|
+
poller.markProcessed('item-1', { source: 'source-a' });
|
|
130
|
+
poller.markProcessed('item-2', { source: 'source-a' });
|
|
131
|
+
poller.markProcessed('item-3', { source: 'source-b' });
|
|
132
|
+
|
|
133
|
+
assert.strictEqual(poller.getProcessedCount(), 3);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('getProcessedCount filters by source', async () => {
|
|
137
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
138
|
+
|
|
139
|
+
const poller = createPoller({ stateFile });
|
|
140
|
+
poller.markProcessed('item-1', { source: 'source-a' });
|
|
141
|
+
poller.markProcessed('item-2', { source: 'source-a' });
|
|
142
|
+
poller.markProcessed('item-3', { source: 'source-b' });
|
|
143
|
+
|
|
144
|
+
assert.strictEqual(poller.getProcessedCount('source-a'), 2);
|
|
145
|
+
assert.strictEqual(poller.getProcessedCount('source-b'), 1);
|
|
146
|
+
assert.strictEqual(poller.getProcessedCount('source-c'), 0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('clearBySource removes all entries for a source', async () => {
|
|
150
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
151
|
+
|
|
152
|
+
const poller = createPoller({ stateFile });
|
|
153
|
+
poller.markProcessed('item-1', { source: 'source-a' });
|
|
154
|
+
poller.markProcessed('item-2', { source: 'source-a' });
|
|
155
|
+
poller.markProcessed('item-3', { source: 'source-b' });
|
|
156
|
+
|
|
157
|
+
const removed = poller.clearBySource('source-a');
|
|
158
|
+
|
|
159
|
+
assert.strictEqual(removed, 2);
|
|
160
|
+
assert.strictEqual(poller.isProcessed('item-1'), false);
|
|
161
|
+
assert.strictEqual(poller.isProcessed('item-2'), false);
|
|
162
|
+
assert.strictEqual(poller.isProcessed('item-3'), true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('clearBySource returns 0 for unknown source', async () => {
|
|
166
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
167
|
+
|
|
168
|
+
const poller = createPoller({ stateFile });
|
|
169
|
+
poller.markProcessed('item-1', { source: 'source-a' });
|
|
170
|
+
|
|
171
|
+
const removed = poller.clearBySource('unknown');
|
|
172
|
+
|
|
173
|
+
assert.strictEqual(removed, 0);
|
|
174
|
+
assert.strictEqual(poller.isProcessed('item-1'), true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('cleanupExpired removes entries older than ttlDays', async () => {
|
|
178
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
179
|
+
const { readFileSync } = await import('fs');
|
|
180
|
+
|
|
181
|
+
const poller = createPoller({ stateFile });
|
|
182
|
+
|
|
183
|
+
// Mark items as processed
|
|
184
|
+
poller.markProcessed('recent-item', { source: 'test' });
|
|
185
|
+
poller.markProcessed('old-item', { source: 'test' });
|
|
186
|
+
|
|
187
|
+
// Manually modify the state file to make one item old
|
|
188
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
189
|
+
const oldDate = new Date();
|
|
190
|
+
oldDate.setDate(oldDate.getDate() - 40); // 40 days ago
|
|
191
|
+
state.processed['old-item'].processedAt = oldDate.toISOString();
|
|
192
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
193
|
+
|
|
194
|
+
// Create new poller to reload state
|
|
195
|
+
const poller2 = createPoller({ stateFile });
|
|
196
|
+
const removed = poller2.cleanupExpired(30);
|
|
197
|
+
|
|
198
|
+
assert.strictEqual(removed, 1);
|
|
199
|
+
assert.strictEqual(poller2.isProcessed('recent-item'), true);
|
|
200
|
+
assert.strictEqual(poller2.isProcessed('old-item'), false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('cleanupExpired uses default ttlDays of 30', async () => {
|
|
204
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
205
|
+
const { readFileSync } = await import('fs');
|
|
206
|
+
|
|
207
|
+
const poller = createPoller({ stateFile });
|
|
208
|
+
poller.markProcessed('item-25-days', { source: 'test' });
|
|
209
|
+
poller.markProcessed('item-35-days', { source: 'test' });
|
|
210
|
+
|
|
211
|
+
// Modify state file
|
|
212
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
213
|
+
const date25 = new Date();
|
|
214
|
+
date25.setDate(date25.getDate() - 25);
|
|
215
|
+
const date35 = new Date();
|
|
216
|
+
date35.setDate(date35.getDate() - 35);
|
|
217
|
+
state.processed['item-25-days'].processedAt = date25.toISOString();
|
|
218
|
+
state.processed['item-35-days'].processedAt = date35.toISOString();
|
|
219
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
220
|
+
|
|
221
|
+
const poller2 = createPoller({ stateFile });
|
|
222
|
+
const removed = poller2.cleanupExpired(); // No argument = default 30
|
|
223
|
+
|
|
224
|
+
assert.strictEqual(removed, 1);
|
|
225
|
+
assert.strictEqual(poller2.isProcessed('item-25-days'), true);
|
|
226
|
+
assert.strictEqual(poller2.isProcessed('item-35-days'), false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('cleanupMissingFromSource removes stale entries for a source', async () => {
|
|
230
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
231
|
+
const { readFileSync } = await import('fs');
|
|
232
|
+
|
|
233
|
+
const poller = createPoller({ stateFile });
|
|
234
|
+
poller.markProcessed('item-1', { source: 'test-source' });
|
|
235
|
+
poller.markProcessed('item-2', { source: 'test-source' });
|
|
236
|
+
poller.markProcessed('item-3', { source: 'other-source' });
|
|
237
|
+
|
|
238
|
+
// Make items old enough to be cleaned (older than minAgeDays)
|
|
239
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
240
|
+
const oldDate = new Date();
|
|
241
|
+
oldDate.setDate(oldDate.getDate() - 2); // 2 days ago
|
|
242
|
+
for (const id of Object.keys(state.processed)) {
|
|
243
|
+
state.processed[id].processedAt = oldDate.toISOString();
|
|
244
|
+
}
|
|
245
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
246
|
+
|
|
247
|
+
const poller2 = createPoller({ stateFile });
|
|
248
|
+
// Current items only has item-1 (item-2 is missing from source)
|
|
249
|
+
const removed = poller2.cleanupMissingFromSource('test-source', ['item-1'], 1);
|
|
250
|
+
|
|
251
|
+
assert.strictEqual(removed, 1); // item-2 removed
|
|
252
|
+
assert.strictEqual(poller2.isProcessed('item-1'), true);
|
|
253
|
+
assert.strictEqual(poller2.isProcessed('item-2'), false);
|
|
254
|
+
assert.strictEqual(poller2.isProcessed('item-3'), true); // different source
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('cleanupMissingFromSource respects minAgeDays', async () => {
|
|
258
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
259
|
+
|
|
260
|
+
const poller = createPoller({ stateFile });
|
|
261
|
+
poller.markProcessed('item-1', { source: 'test-source' });
|
|
262
|
+
poller.markProcessed('item-2', { source: 'test-source' });
|
|
263
|
+
|
|
264
|
+
// Items are fresh (just processed), so minAgeDays=1 should protect them
|
|
265
|
+
const removed = poller.cleanupMissingFromSource('test-source', ['item-1'], 1);
|
|
266
|
+
|
|
267
|
+
assert.strictEqual(removed, 0); // item-2 NOT removed (too recent)
|
|
268
|
+
assert.strictEqual(poller.isProcessed('item-2'), true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('cleanupMissingFromSource with minAgeDays=0 removes immediately', async () => {
|
|
272
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
273
|
+
|
|
274
|
+
const poller = createPoller({ stateFile });
|
|
275
|
+
poller.markProcessed('item-1', { source: 'test-source' });
|
|
276
|
+
poller.markProcessed('item-2', { source: 'test-source' });
|
|
277
|
+
|
|
278
|
+
// minAgeDays=0 removes even fresh items
|
|
279
|
+
const removed = poller.cleanupMissingFromSource('test-source', ['item-1'], 0);
|
|
280
|
+
|
|
281
|
+
assert.strictEqual(removed, 1);
|
|
282
|
+
assert.strictEqual(poller.isProcessed('item-2'), false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('cleanup state persists across instances', async () => {
|
|
286
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
287
|
+
|
|
288
|
+
const poller = createPoller({ stateFile });
|
|
289
|
+
poller.markProcessed('item-1', { source: 'source-a' });
|
|
290
|
+
poller.markProcessed('item-2', { source: 'source-a' });
|
|
291
|
+
|
|
292
|
+
poller.clearBySource('source-a');
|
|
293
|
+
|
|
294
|
+
// Verify persistence
|
|
295
|
+
const poller2 = createPoller({ stateFile });
|
|
296
|
+
assert.strictEqual(poller2.getProcessedCount(), 0);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('status tracking', () => {
|
|
301
|
+
test('shouldReprocess returns false for item with same state', async () => {
|
|
302
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
303
|
+
|
|
304
|
+
const poller = createPoller({ stateFile });
|
|
305
|
+
poller.markProcessed('issue-1', { source: 'test', itemState: 'open' });
|
|
306
|
+
|
|
307
|
+
const item = { id: 'issue-1', state: 'open' };
|
|
308
|
+
assert.strictEqual(poller.shouldReprocess(item), false);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('shouldReprocess returns true for reopened issue (closed -> open)', async () => {
|
|
312
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
313
|
+
|
|
314
|
+
const poller = createPoller({ stateFile });
|
|
315
|
+
poller.markProcessed('issue-1', { source: 'test', itemState: 'closed' });
|
|
316
|
+
|
|
317
|
+
const item = { id: 'issue-1', state: 'open' };
|
|
318
|
+
assert.strictEqual(poller.shouldReprocess(item), true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('shouldReprocess returns true for merged PR reopened', async () => {
|
|
322
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
323
|
+
|
|
324
|
+
const poller = createPoller({ stateFile });
|
|
325
|
+
poller.markProcessed('pr-1', { source: 'test', itemState: 'merged' });
|
|
326
|
+
|
|
327
|
+
const item = { id: 'pr-1', state: 'open' };
|
|
328
|
+
assert.strictEqual(poller.shouldReprocess(item), true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('shouldReprocess returns false for item not in state', async () => {
|
|
332
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
333
|
+
|
|
334
|
+
const poller = createPoller({ stateFile });
|
|
335
|
+
|
|
336
|
+
const item = { id: 'new-issue', state: 'open' };
|
|
337
|
+
assert.strictEqual(poller.shouldReprocess(item), false);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('shouldReprocess returns false when no itemState was stored', async () => {
|
|
341
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
342
|
+
|
|
343
|
+
const poller = createPoller({ stateFile });
|
|
344
|
+
// Legacy entry without itemState
|
|
345
|
+
poller.markProcessed('issue-1', { source: 'test' });
|
|
346
|
+
|
|
347
|
+
const item = { id: 'issue-1', state: 'open' };
|
|
348
|
+
assert.strictEqual(poller.shouldReprocess(item), false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('shouldReprocess uses status field for Linear items', async () => {
|
|
352
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
353
|
+
|
|
354
|
+
const poller = createPoller({ stateFile });
|
|
355
|
+
poller.markProcessed('linear-1', { source: 'test', itemState: 'Done' });
|
|
356
|
+
|
|
357
|
+
// Linear uses 'status' field instead of 'state'
|
|
358
|
+
const item = { id: 'linear-1', status: 'In Progress' };
|
|
359
|
+
assert.strictEqual(poller.shouldReprocess(item), true);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('shouldReprocess does NOT check updated_at by default (avoids self-triggering)', async () => {
|
|
363
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
364
|
+
|
|
365
|
+
const poller = createPoller({ stateFile });
|
|
366
|
+
poller.markProcessed('issue-1', {
|
|
367
|
+
source: 'test',
|
|
368
|
+
itemState: 'open',
|
|
369
|
+
itemUpdatedAt: '2026-01-01T00:00:00Z'
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Item was updated after being processed, but state is the same
|
|
373
|
+
const item = {
|
|
374
|
+
id: 'issue-1',
|
|
375
|
+
state: 'open',
|
|
376
|
+
updated_at: '2026-01-05T00:00:00Z'
|
|
377
|
+
};
|
|
378
|
+
// Should NOT reprocess because updated_at is not in default reprocessOn
|
|
379
|
+
assert.strictEqual(poller.shouldReprocess(item), false);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('shouldReprocess detects updated_at when explicitly configured', async () => {
|
|
383
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
384
|
+
|
|
385
|
+
const poller = createPoller({ stateFile });
|
|
386
|
+
poller.markProcessed('issue-1', {
|
|
387
|
+
source: 'test',
|
|
388
|
+
itemState: 'open',
|
|
389
|
+
itemUpdatedAt: '2026-01-01T00:00:00Z'
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const item = {
|
|
393
|
+
id: 'issue-1',
|
|
394
|
+
state: 'open',
|
|
395
|
+
updated_at: '2026-01-05T00:00:00Z'
|
|
396
|
+
};
|
|
397
|
+
// Should reprocess when updated_at is explicitly in reprocessOn
|
|
398
|
+
assert.strictEqual(
|
|
399
|
+
poller.shouldReprocess(item, { reprocessOn: ['updated_at'] }),
|
|
400
|
+
true
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('shouldReprocess returns false if updated_at is same or older', async () => {
|
|
405
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
406
|
+
|
|
407
|
+
const poller = createPoller({ stateFile });
|
|
408
|
+
poller.markProcessed('issue-1', {
|
|
409
|
+
source: 'test',
|
|
410
|
+
itemState: 'open',
|
|
411
|
+
itemUpdatedAt: '2026-01-05T00:00:00Z'
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// Item has same updated_at
|
|
415
|
+
const item = {
|
|
416
|
+
id: 'issue-1',
|
|
417
|
+
state: 'open',
|
|
418
|
+
updated_at: '2026-01-05T00:00:00Z'
|
|
419
|
+
};
|
|
420
|
+
// Even with explicit config, same timestamp should not trigger
|
|
421
|
+
assert.strictEqual(
|
|
422
|
+
poller.shouldReprocess(item, { reprocessOn: ['updated_at'] }),
|
|
423
|
+
false
|
|
424
|
+
);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('shouldReprocess respects reprocessOn config', async () => {
|
|
428
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
429
|
+
|
|
430
|
+
const poller = createPoller({ stateFile });
|
|
431
|
+
poller.markProcessed('issue-1', {
|
|
432
|
+
source: 'test',
|
|
433
|
+
itemState: 'closed',
|
|
434
|
+
itemUpdatedAt: '2026-01-01T00:00:00Z'
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const item = {
|
|
438
|
+
id: 'issue-1',
|
|
439
|
+
state: 'open', // Reopened
|
|
440
|
+
updated_at: '2026-01-05T00:00:00Z' // Also updated
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// Only check updated_at, not state
|
|
444
|
+
assert.strictEqual(
|
|
445
|
+
poller.shouldReprocess(item, { reprocessOn: ['updated_at'] }),
|
|
446
|
+
true
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
// Only check state
|
|
450
|
+
assert.strictEqual(
|
|
451
|
+
poller.shouldReprocess(item, { reprocessOn: ['state'] }),
|
|
452
|
+
true
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// Check neither (empty array)
|
|
456
|
+
assert.strictEqual(
|
|
457
|
+
poller.shouldReprocess(item, { reprocessOn: [] }),
|
|
458
|
+
false
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('shouldReprocess handles Linear updatedAt field', async () => {
|
|
463
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
464
|
+
|
|
465
|
+
const poller = createPoller({ stateFile });
|
|
466
|
+
poller.markProcessed('linear-1', {
|
|
467
|
+
source: 'test',
|
|
468
|
+
itemState: 'In Progress',
|
|
469
|
+
itemUpdatedAt: '2026-01-01T00:00:00Z'
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Linear uses camelCase updatedAt
|
|
473
|
+
const item = {
|
|
474
|
+
id: 'linear-1',
|
|
475
|
+
status: 'In Progress',
|
|
476
|
+
updatedAt: '2026-01-05T00:00:00Z'
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
assert.strictEqual(
|
|
480
|
+
poller.shouldReprocess(item, { reprocessOn: ['updatedAt'] }),
|
|
481
|
+
true
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('shouldReprocess returns true for reappeared item (e.g., uncompleted reminder)', async () => {
|
|
486
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
487
|
+
|
|
488
|
+
const poller = createPoller({ stateFile });
|
|
489
|
+
poller.markProcessed('reminder-1', { source: 'reminders' });
|
|
490
|
+
|
|
491
|
+
// Simulate: item disappears from poll (completed), then reappears (uncompleted)
|
|
492
|
+
poller.markUnseen('reminders', []); // Item not in results - marked unseen
|
|
493
|
+
|
|
494
|
+
const item = { id: 'reminder-1' };
|
|
495
|
+
assert.strictEqual(poller.shouldReprocess(item), true);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('shouldReprocess returns false for item that was always present', async () => {
|
|
499
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
500
|
+
|
|
501
|
+
const poller = createPoller({ stateFile });
|
|
502
|
+
poller.markProcessed('reminder-1', { source: 'reminders' });
|
|
503
|
+
|
|
504
|
+
// Item stays in poll results
|
|
505
|
+
poller.markUnseen('reminders', ['reminder-1']);
|
|
506
|
+
|
|
507
|
+
const item = { id: 'reminder-1' };
|
|
508
|
+
assert.strictEqual(poller.shouldReprocess(item), false);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('markUnseen tracks items across multiple polls', async () => {
|
|
512
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
513
|
+
|
|
514
|
+
const poller = createPoller({ stateFile });
|
|
515
|
+
poller.markProcessed('item-1', { source: 'test' });
|
|
516
|
+
poller.markProcessed('item-2', { source: 'test' });
|
|
517
|
+
|
|
518
|
+
// Poll 1: both present
|
|
519
|
+
poller.markUnseen('test', ['item-1', 'item-2']);
|
|
520
|
+
assert.strictEqual(poller.shouldReprocess({ id: 'item-1' }), false);
|
|
521
|
+
assert.strictEqual(poller.shouldReprocess({ id: 'item-2' }), false);
|
|
522
|
+
|
|
523
|
+
// Poll 2: item-2 disappears
|
|
524
|
+
poller.markUnseen('test', ['item-1']);
|
|
525
|
+
assert.strictEqual(poller.shouldReprocess({ id: 'item-1' }), false);
|
|
526
|
+
assert.strictEqual(poller.shouldReprocess({ id: 'item-2' }), true);
|
|
527
|
+
|
|
528
|
+
// Poll 3: item-2 reappears - wasUnseen flag should still be true until cleared
|
|
529
|
+
// (The flag gets cleared when shouldReprocess triggers reprocessing)
|
|
530
|
+
});
|
|
109
531
|
});
|
|
110
532
|
|
|
111
533
|
describe('pollGenericSource', () => {
|
|
@@ -777,4 +777,48 @@ sources: []
|
|
|
777
777
|
assert.deepStrictEqual(defaults, {});
|
|
778
778
|
});
|
|
779
779
|
});
|
|
780
|
+
|
|
781
|
+
describe('cleanup config', () => {
|
|
782
|
+
test('getCleanupTtlDays returns configured value', async () => {
|
|
783
|
+
writeFileSync(configPath, `
|
|
784
|
+
cleanup:
|
|
785
|
+
ttl_days: 14
|
|
786
|
+
|
|
787
|
+
sources: []
|
|
788
|
+
`);
|
|
789
|
+
|
|
790
|
+
const { loadRepoConfig, getCleanupTtlDays } = await import('../../service/repo-config.js');
|
|
791
|
+
loadRepoConfig(configPath);
|
|
792
|
+
const ttlDays = getCleanupTtlDays();
|
|
793
|
+
|
|
794
|
+
assert.strictEqual(ttlDays, 14);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test('getCleanupTtlDays returns default 30 when not configured', async () => {
|
|
798
|
+
writeFileSync(configPath, `
|
|
799
|
+
sources: []
|
|
800
|
+
`);
|
|
801
|
+
|
|
802
|
+
const { loadRepoConfig, getCleanupTtlDays } = await import('../../service/repo-config.js');
|
|
803
|
+
loadRepoConfig(configPath);
|
|
804
|
+
const ttlDays = getCleanupTtlDays();
|
|
805
|
+
|
|
806
|
+
assert.strictEqual(ttlDays, 30);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
test('getCleanupTtlDays returns default 30 when cleanup section exists but ttl_days not set', async () => {
|
|
810
|
+
writeFileSync(configPath, `
|
|
811
|
+
cleanup:
|
|
812
|
+
some_other_option: true
|
|
813
|
+
|
|
814
|
+
sources: []
|
|
815
|
+
`);
|
|
816
|
+
|
|
817
|
+
const { loadRepoConfig, getCleanupTtlDays } = await import('../../service/repo-config.js');
|
|
818
|
+
loadRepoConfig(configPath);
|
|
819
|
+
const ttlDays = getCleanupTtlDays();
|
|
820
|
+
|
|
821
|
+
assert.strictEqual(ttlDays, 30);
|
|
822
|
+
});
|
|
823
|
+
});
|
|
780
824
|
});
|