opencode-pilot 0.6.0 → 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 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
@@ -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;
@@ -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 # Default agent for all sources
11
- prompt: default # Default prompt template
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
- # ----- PRESET SYNTAX (recommended for common patterns) -----
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: # Optional: filter to specific repos
18
+ repos:
36
19
  - myorg/backend
37
20
  - myorg/frontend
38
21
 
39
- # Linear issues (requires teamId and assigneeId)
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" # Replace with actual team UUID
44
- assigneeId: "your-user-uuid" # Replace with actual 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
- # ----- SHORTHAND SYNTAX (for custom GitHub queries) -----
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
- # ----- FULL SYNTAX (for non-GitHub sources or full control) -----
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
- # Available presets (no tools config needed):
103
- # github/my-issues - Issues assigned to me
104
- # github/review-requests - PRs needing my review
105
- # github/my-prs-feedback - My PRs with change requests
106
- # linear/my-issues - Linear tickets (requires teamId, assigneeId)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -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
- const toolProviderConfig = getToolProviderConfig(source.tool.mcp);
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
- debug(`Skipping ${item.id} - already processed`);
161
- continue;
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, { repoKey, command: result.command });
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
  }
@@ -3,7 +3,13 @@
3
3
  # Provider-level config (applies to all GitHub presets)
4
4
  _provider:
5
5
  response_key: items
6
- mappings: {}
6
+ mappings:
7
+ # Extract repo full name from repository_url (e.g., "https://api.github.com/repos/owner/repo")
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)
7
13
 
8
14
  # Presets
9
15
  my-issues:
@@ -15,7 +21,7 @@ my-issues:
15
21
  q: "is:issue assignee:@me state:open"
16
22
  item:
17
23
  id: "{html_url}"
18
- repo: "{repository.full_name}"
24
+ repo: "{repository_full_name}"
19
25
 
20
26
  review-requests:
21
27
  name: review-requests
@@ -26,7 +32,7 @@ review-requests:
26
32
  q: "is:pr review-requested:@me state:open"
27
33
  item:
28
34
  id: "{html_url}"
29
- repo: "{repository.full_name}"
35
+ repo: "{repository_full_name}"
30
36
 
31
37
  my-prs-feedback:
32
38
  name: my-prs-feedback
@@ -37,4 +43,4 @@ my-prs-feedback:
37
43
  q: "is:pr author:@me state:open review:changes_requested"
38
44
  item:
39
45
  id: "{html_url}"
40
- repo: "{repository.full_name}"
46
+ repo: "{repository_full_name}"
@@ -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:
@@ -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
  */
@@ -159,14 +159,15 @@ sources:
159
159
  loadRepoConfig(configPath);
160
160
 
161
161
  const source = getSources()[0];
162
+ // Item with repository_full_name (mapped from repository_url by GitHub provider)
162
163
  const item = {
163
- repository: { full_name: 'myorg/backend' },
164
+ repository_full_name: 'myorg/backend',
164
165
  number: 123,
165
166
  html_url: 'https://github.com/myorg/backend/issues/123'
166
167
  };
167
168
 
168
- // Source should have repo field from preset
169
- assert.strictEqual(source.repo, '{repository.full_name}');
169
+ // Source should have repo field from preset (uses mapped field)
170
+ assert.strictEqual(source.repo, '{repository_full_name}');
170
171
 
171
172
  // resolveRepoForItem should extract repo key from item
172
173
  const repoKeys = resolveRepoForItem(source, item);
@@ -194,8 +195,9 @@ sources:
194
195
  loadRepoConfig(configPath);
195
196
 
196
197
  const source = getSources()[0];
198
+ // Item with repository_full_name (mapped from repository_url by GitHub provider)
197
199
  const item = {
198
- repository: { full_name: 'unknown/repo' },
200
+ repository_full_name: 'unknown/repo',
199
201
  number: 456
200
202
  };
201
203
 
@@ -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', () => {
@@ -352,7 +352,9 @@ sources: []
352
352
 
353
353
  // GitHub preset has response_key: items, user config doesn't override it
354
354
  assert.strictEqual(toolConfig.response_key, 'items');
355
- assert.deepStrictEqual(toolConfig.mappings, { url: 'html_url' });
355
+ // GitHub provider has mapping for repository_full_name extraction from URL
356
+ assert.strictEqual(toolConfig.mappings.url, 'html_url');
357
+ assert.ok(toolConfig.mappings.repository_full_name, 'Should have repository_full_name mapping');
356
358
  });
357
359
 
358
360
  test('getToolProviderConfig falls back to preset provider config', async () => {
@@ -628,11 +630,12 @@ sources:
628
630
  loadRepoConfig(configPath);
629
631
  const sources = getSources();
630
632
 
631
- // All GitHub presets should have repo field for automatic resolution
632
- const mockItem = { repository: { full_name: 'myorg/backend' } };
633
+ // All GitHub presets should have repo field that references repository_full_name
634
+ // (which is mapped from repository_url by the GitHub provider)
635
+ const mockItem = { repository_full_name: 'myorg/backend' };
633
636
 
634
637
  for (const source of sources) {
635
- assert.strictEqual(source.repo, '{repository.full_name}', `Preset ${source.name} should have repo field`);
638
+ assert.strictEqual(source.repo, '{repository_full_name}', `Preset ${source.name} should have repo field`);
636
639
  const repos = resolveRepoForItem(source, mockItem);
637
640
  assert.deepStrictEqual(repos, ['myorg/backend'], `Preset ${source.name} should resolve repo from item`);
638
641
  }
@@ -651,12 +654,12 @@ sources:
651
654
  loadRepoConfig(configPath);
652
655
  const source = getSources()[0];
653
656
 
654
- // Item from allowed repo should resolve
655
- const allowedItem = { repository: { full_name: 'myorg/backend' } };
657
+ // Item from allowed repo should resolve (repository_full_name is mapped from repository_url)
658
+ const allowedItem = { repository_full_name: 'myorg/backend' };
656
659
  assert.deepStrictEqual(resolveRepoForItem(source, allowedItem), ['myorg/backend']);
657
660
 
658
661
  // Item from non-allowed repo should return empty (filtered out)
659
- const filteredItem = { repository: { full_name: 'other/repo' } };
662
+ const filteredItem = { repository_full_name: 'other/repo' };
660
663
  assert.deepStrictEqual(resolveRepoForItem(source, filteredItem), []);
661
664
  });
662
665
  });
@@ -774,4 +777,48 @@ sources: []
774
777
  assert.deepStrictEqual(defaults, {});
775
778
  });
776
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
+ });
777
824
  });