@oss-autopilot/core 0.53.1 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli.bundle.cjs +63 -63
  2. package/dist/commands/comments.js +0 -1
  3. package/dist/commands/config.js +45 -5
  4. package/dist/commands/daily.js +197 -162
  5. package/dist/commands/dashboard-data.js +37 -30
  6. package/dist/commands/dashboard-server.js +8 -1
  7. package/dist/commands/dismiss.js +0 -6
  8. package/dist/commands/init.js +0 -1
  9. package/dist/commands/local-repos.js +1 -2
  10. package/dist/commands/move.js +12 -11
  11. package/dist/commands/setup.d.ts +2 -1
  12. package/dist/commands/setup.js +166 -130
  13. package/dist/commands/shelve.js +10 -10
  14. package/dist/commands/startup.js +30 -14
  15. package/dist/core/ci-analysis.d.ts +6 -0
  16. package/dist/core/ci-analysis.js +91 -12
  17. package/dist/core/daily-logic.js +24 -33
  18. package/dist/core/display-utils.js +22 -2
  19. package/dist/core/github-stats.d.ts +1 -1
  20. package/dist/core/github-stats.js +1 -1
  21. package/dist/core/index.d.ts +2 -1
  22. package/dist/core/index.js +2 -1
  23. package/dist/core/issue-discovery.d.ts +7 -44
  24. package/dist/core/issue-discovery.js +83 -188
  25. package/dist/core/issue-eligibility.d.ts +35 -0
  26. package/dist/core/issue-eligibility.js +126 -0
  27. package/dist/core/issue-vetting.d.ts +6 -21
  28. package/dist/core/issue-vetting.js +15 -279
  29. package/dist/core/pr-monitor.d.ts +14 -16
  30. package/dist/core/pr-monitor.js +26 -90
  31. package/dist/core/repo-health.d.ts +24 -0
  32. package/dist/core/repo-health.js +193 -0
  33. package/dist/core/repo-score-manager.js +2 -0
  34. package/dist/core/search-phases.d.ts +55 -0
  35. package/dist/core/search-phases.js +155 -0
  36. package/dist/core/state.d.ts +11 -0
  37. package/dist/core/state.js +63 -4
  38. package/dist/core/status-determination.d.ts +2 -0
  39. package/dist/core/status-determination.js +82 -22
  40. package/dist/core/types.d.ts +23 -2
  41. package/dist/core/types.js +7 -0
  42. package/dist/formatters/json.d.ts +1 -1
  43. package/package.json +1 -1
@@ -157,7 +157,6 @@ export async function runClaim(options) {
157
157
  updatedAt: new Date().toISOString(),
158
158
  vetted: false,
159
159
  });
160
- stateManager.save();
161
160
  }
162
161
  catch (error) {
163
162
  console.error(`Warning: Comment posted on ${options.issueUrl} but failed to save to local state: ${error instanceof Error ? error.message : error}`);
@@ -3,7 +3,14 @@
3
3
  * Shows or updates configuration
4
4
  */
5
5
  import { getStateManager } from '../core/index.js';
6
+ import { ISSUE_SCOPES } from '../core/types.js';
6
7
  import { validateGitHubUsername } from './validation.js';
8
+ function validateScope(value) {
9
+ if (!ISSUE_SCOPES.includes(value)) {
10
+ throw new Error(`Invalid scope "${value}". Valid scopes: ${ISSUE_SCOPES.join(', ')}`);
11
+ }
12
+ return value;
13
+ }
7
14
  export async function runConfig(options) {
8
15
  const stateManager = getStateManager();
9
16
  const currentConfig = stateManager.getState().config;
@@ -30,6 +37,33 @@ export async function runConfig(options) {
30
37
  stateManager.updateConfig({ labels: [...currentConfig.labels, value] });
31
38
  }
32
39
  break;
40
+ case 'remove-label':
41
+ if (!currentConfig.labels.includes(value)) {
42
+ throw new Error(`Label "${value}" is not currently configured. Current labels: ${currentConfig.labels.join(', ')}`);
43
+ }
44
+ stateManager.updateConfig({ labels: currentConfig.labels.filter((l) => l !== value) });
45
+ break;
46
+ case 'add-scope': {
47
+ const scope = validateScope(value);
48
+ const currentScopes = currentConfig.scope ?? [];
49
+ if (!currentScopes.includes(scope)) {
50
+ stateManager.updateConfig({ scope: [...currentScopes, scope] });
51
+ }
52
+ break;
53
+ }
54
+ case 'remove-scope': {
55
+ const scope = validateScope(value);
56
+ const existingScopes = currentConfig.scope ?? [];
57
+ if (!existingScopes.includes(scope)) {
58
+ throw new Error(`Scope "${value}" is not currently set`);
59
+ }
60
+ const filtered = existingScopes.filter((s) => s !== scope);
61
+ if (filtered.length === 0) {
62
+ throw new Error('Cannot remove the last scope. Use setup to clear scopes entirely.');
63
+ }
64
+ stateManager.updateConfig({ scope: filtered });
65
+ break;
66
+ }
33
67
  case 'exclude-repo': {
34
68
  const parts = value.split('/');
35
69
  if (parts.length !== 2 || !parts[0] || !parts[1]) {
@@ -37,8 +71,10 @@ export async function runConfig(options) {
37
71
  }
38
72
  const valueLower = value.toLowerCase();
39
73
  if (!currentConfig.excludeRepos.some((r) => r.toLowerCase() === valueLower)) {
40
- stateManager.updateConfig({ excludeRepos: [...currentConfig.excludeRepos, value] });
41
- stateManager.cleanupExcludedData([value], []);
74
+ stateManager.batch(() => {
75
+ stateManager.updateConfig({ excludeRepos: [...currentConfig.excludeRepos, value] });
76
+ stateManager.cleanupExcludedData([value], []);
77
+ });
42
78
  }
43
79
  break;
44
80
  }
@@ -48,14 +84,18 @@ export async function runConfig(options) {
48
84
  }
49
85
  const currentOrgs = currentConfig.excludeOrgs ?? [];
50
86
  if (!currentOrgs.some((o) => o.toLowerCase() === value.toLowerCase())) {
51
- stateManager.updateConfig({ excludeOrgs: [...currentOrgs, value] });
52
- stateManager.cleanupExcludedData([], [value]);
87
+ stateManager.batch(() => {
88
+ stateManager.updateConfig({ excludeOrgs: [...currentOrgs, value] });
89
+ stateManager.cleanupExcludedData([], [value]);
90
+ });
53
91
  }
54
92
  break;
55
93
  }
94
+ case 'issueListPath':
95
+ stateManager.updateConfig({ issueListPath: value || undefined });
96
+ break;
56
97
  default:
57
98
  throw new Error(`Unknown config key: ${options.key}`);
58
99
  }
59
- stateManager.save();
60
100
  return { success: true, key: options.key, value };
61
101
  }
@@ -123,110 +123,124 @@ async function fetchPRData(prMonitor, token) {
123
123
  */
124
124
  async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
125
125
  const stateManager = getStateManager();
126
- // Reset stale repos first (so excluded/removed repos get zeroed).
127
- // Guard: if the API returned zero results but we have existing repos with merged PRs,
128
- // skip the reset to avoid wiping scores due to transient API failures.
129
- const existingReposWithMerges = Object.values(stateManager.getState().repoScores).filter((s) => s.mergedPRCount > 0);
130
- if (mergedCounts.size === 0 && existingReposWithMerges.length > 0) {
131
- warn(MODULE, `Skipping stale repo reset: API returned 0 merged PR results but state has ${existingReposWithMerges.length} repo(s) with merges. Possible API issue.`);
132
- }
133
- else {
134
- for (const score of Object.values(stateManager.getState().repoScores)) {
135
- if (!mergedCounts.has(score.repo)) {
136
- stateManager.updateRepoScore(score.repo, { mergedPRCount: 0 });
126
+ // Batch all synchronous score mutations for a single disk write.
127
+ // Per-repo try-catch: a single corrupted repo should not prevent updates to others.
128
+ // Outer try-catch: save failure should not crash the daily check (in-memory mutations still apply).
129
+ try {
130
+ stateManager.batch(() => {
131
+ // Reset stale repos first (so excluded/removed repos get zeroed).
132
+ // Guard: if the API returned zero results but we have existing repos with merged PRs,
133
+ // skip the reset to avoid wiping scores due to transient API failures.
134
+ const existingReposWithMerges = Object.values(stateManager.getState().repoScores).filter((s) => s.mergedPRCount > 0);
135
+ if (mergedCounts.size === 0 && existingReposWithMerges.length > 0) {
136
+ warn(MODULE, `Skipping stale repo reset: API returned 0 merged PR results but state has ${existingReposWithMerges.length} repo(s) with merges. Possible API issue.`);
137
137
  }
138
- }
139
- }
140
- // Update merged/closed counts with per-repo error isolation (matches signal/trust loops below)
141
- let mergedCountFailures = 0;
142
- for (const [repo, { count, lastMergedAt }] of mergedCounts) {
143
- try {
144
- stateManager.updateRepoScore(repo, { mergedPRCount: count, lastMergedAt: lastMergedAt || undefined });
145
- }
146
- catch (error) {
147
- mergedCountFailures++;
148
- warn(MODULE, `Failed to update merged count for ${repo}: ${errorMessage(error)}`);
149
- }
150
- }
151
- if (mergedCountFailures === mergedCounts.size && mergedCounts.size > 0) {
152
- warn(MODULE, `[ALL_MERGED_COUNT_UPDATES_FAILED] All ${mergedCounts.size} merged count update(s) failed.`);
153
- }
154
- // Populate closedWithoutMergeCount in repo scores.
155
- // Diagnostic: warn if API returned empty but we have known closed PRs (possible transient API failure).
156
- // Unlike merged counts above, there is no stale-reset loop for closed counts, so no skip is needed.
157
- const existingReposWithClosed = Object.values(stateManager.getState().repoScores).filter((s) => (s.closedWithoutMergeCount || 0) > 0);
158
- if (closedCounts.size === 0 && existingReposWithClosed.length > 0) {
159
- warn(MODULE, `API returned 0 closed PR results but state has ${existingReposWithClosed.length} repo(s) with closed PRs. Possible transient API issue.`);
160
- }
161
- let closedCountFailures = 0;
162
- for (const [repo, count] of closedCounts) {
163
- try {
164
- stateManager.updateRepoScore(repo, { closedWithoutMergeCount: count });
165
- }
166
- catch (error) {
167
- closedCountFailures++;
168
- warn(MODULE, `Failed to update closed count for ${repo}: ${errorMessage(error)}`);
169
- }
170
- }
171
- if (closedCountFailures === closedCounts.size && closedCounts.size > 0) {
172
- warn(MODULE, `[ALL_CLOSED_COUNT_UPDATES_FAILED] All ${closedCounts.size} closed count update(s) failed.`);
173
- }
174
- // Update repo signals from observed open PR data (responsiveness, active maintainers).
175
- // Only repos with current open PRs get signal updates — repos with no open PRs
176
- // preserve their existing signals to avoid degrading scores when PRs are merged.
177
- // Per-repo try-catch: signal/trust syncing is secondary to the daily digest —
178
- // a single corrupted repo score should not prevent updates to other repos.
179
- const repoSignals = computeRepoSignals(prs);
180
- let signalUpdateFailures = 0;
181
- for (const [repo, signals] of repoSignals) {
182
- try {
183
- stateManager.updateRepoScore(repo, { signals });
184
- }
185
- catch (error) {
186
- signalUpdateFailures++;
187
- warn(MODULE, `Failed to update signals for ${repo}: ${errorMessage(error)}`);
188
- }
138
+ else {
139
+ for (const score of Object.values(stateManager.getState().repoScores)) {
140
+ if (!mergedCounts.has(score.repo)) {
141
+ stateManager.updateRepoScore(score.repo, { mergedPRCount: 0 });
142
+ }
143
+ }
144
+ }
145
+ // Update merged/closed counts
146
+ let mergedCountFailures = 0;
147
+ for (const [repo, { count, lastMergedAt }] of mergedCounts) {
148
+ try {
149
+ stateManager.updateRepoScore(repo, { mergedPRCount: count, lastMergedAt: lastMergedAt || undefined });
150
+ }
151
+ catch (error) {
152
+ mergedCountFailures++;
153
+ warn(MODULE, `Failed to update merged count for ${repo}: ${errorMessage(error)}`);
154
+ }
155
+ }
156
+ if (mergedCountFailures === mergedCounts.size && mergedCounts.size > 0) {
157
+ warn(MODULE, `[ALL_MERGED_COUNT_UPDATES_FAILED] All ${mergedCounts.size} merged count update(s) failed.`);
158
+ }
159
+ // Populate closedWithoutMergeCount in repo scores.
160
+ const existingReposWithClosed = Object.values(stateManager.getState().repoScores).filter((s) => (s.closedWithoutMergeCount || 0) > 0);
161
+ if (closedCounts.size === 0 && existingReposWithClosed.length > 0) {
162
+ warn(MODULE, `API returned 0 closed PR results but state has ${existingReposWithClosed.length} repo(s) with closed PRs. Possible transient API issue.`);
163
+ }
164
+ let closedCountFailures = 0;
165
+ for (const [repo, count] of closedCounts) {
166
+ try {
167
+ stateManager.updateRepoScore(repo, { closedWithoutMergeCount: count });
168
+ }
169
+ catch (error) {
170
+ closedCountFailures++;
171
+ warn(MODULE, `Failed to update closed count for ${repo}: ${errorMessage(error)}`);
172
+ }
173
+ }
174
+ if (closedCountFailures === closedCounts.size && closedCounts.size > 0) {
175
+ warn(MODULE, `[ALL_CLOSED_COUNT_UPDATES_FAILED] All ${closedCounts.size} closed count update(s) failed.`);
176
+ }
177
+ // Update repo signals from observed open PR data
178
+ const repoSignals = computeRepoSignals(prs);
179
+ let signalUpdateFailures = 0;
180
+ for (const [repo, signals] of repoSignals) {
181
+ try {
182
+ stateManager.updateRepoScore(repo, { signals });
183
+ }
184
+ catch (error) {
185
+ signalUpdateFailures++;
186
+ warn(MODULE, `Failed to update signals for ${repo}: ${errorMessage(error)}`);
187
+ }
188
+ }
189
+ if (signalUpdateFailures === repoSignals.size && repoSignals.size > 0) {
190
+ warn(MODULE, `[ALL_SIGNAL_UPDATES_FAILED] All ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`);
191
+ }
192
+ });
189
193
  }
190
- if (signalUpdateFailures === repoSignals.size && repoSignals.size > 0) {
191
- warn(MODULE, `[ALL_SIGNAL_UPDATES_FAILED] All ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`);
194
+ catch (error) {
195
+ warn(MODULE, `Failed to persist repo score updates: ${errorMessage(error)}`);
192
196
  }
193
- // Fetch star counts for all scored repos (used by dashboard minStars filter, #216)
197
+ // Fetch metadata (stars + language) for all scored repos async, so outside the batch above
194
198
  const allRepos = Object.keys(stateManager.getState().repoScores);
195
- let starCounts;
199
+ let repoMetadata;
196
200
  try {
197
- starCounts = await prMonitor.fetchRepoStarCounts(allRepos);
201
+ repoMetadata = await prMonitor.fetchRepoMetadata(allRepos);
198
202
  }
199
203
  catch (error) {
200
- warn(MODULE, `Failed to fetch repo star counts: ${errorMessage(error)}`);
201
- warn(MODULE, 'Repos without cached star data will be excluded from stats until star counts are fetched on the next successful run.');
202
- starCounts = new Map();
204
+ if (isRateLimitOrAuthError(error))
205
+ throw error;
206
+ warn(MODULE, `Failed to fetch repo metadata: ${errorMessage(error)}`);
207
+ warn(MODULE, 'Repos without cached metadata will be excluded from dashboard stats and metadata badges until fetched on the next successful run.');
208
+ repoMetadata = new Map();
203
209
  }
204
- let starUpdateFailures = 0;
205
- for (const [repo, stars] of starCounts) {
206
- try {
207
- stateManager.updateRepoScore(repo, { stargazersCount: stars });
208
- }
209
- catch (error) {
210
- starUpdateFailures++;
211
- warn(MODULE, `Failed to update star count for ${repo}: ${errorMessage(error)}`);
212
- }
213
- }
214
- if (starUpdateFailures === starCounts.size && starCounts.size > 0) {
215
- warn(MODULE, `[ALL_STAR_COUNT_UPDATES_FAILED] All ${starCounts.size} star count update(s) failed.`);
216
- }
217
- // Auto-sync trustedProjects from repos with merged PRs
218
- let trustSyncFailures = 0;
219
- for (const [repo] of mergedCounts) {
220
- try {
221
- stateManager.addTrustedProject(repo);
222
- }
223
- catch (error) {
224
- trustSyncFailures++;
225
- warn(MODULE, `Failed to sync trusted project ${repo}: ${errorMessage(error)}`);
226
- }
210
+ // Batch metadata + trust sync mutations for a single disk write
211
+ try {
212
+ stateManager.batch(() => {
213
+ let metadataUpdateFailures = 0;
214
+ for (const [repo, { stars, language }] of repoMetadata) {
215
+ try {
216
+ stateManager.updateRepoScore(repo, { stargazersCount: stars, language });
217
+ }
218
+ catch (error) {
219
+ metadataUpdateFailures++;
220
+ warn(MODULE, `Failed to update metadata for ${repo}: ${errorMessage(error)}`);
221
+ }
222
+ }
223
+ if (metadataUpdateFailures === repoMetadata.size && repoMetadata.size > 0) {
224
+ warn(MODULE, `[ALL_METADATA_UPDATES_FAILED] All ${repoMetadata.size} metadata update(s) failed.`);
225
+ }
226
+ // Auto-sync trustedProjects from repos with merged PRs
227
+ let trustSyncFailures = 0;
228
+ for (const [repo] of mergedCounts) {
229
+ try {
230
+ stateManager.addTrustedProject(repo);
231
+ }
232
+ catch (error) {
233
+ trustSyncFailures++;
234
+ warn(MODULE, `Failed to sync trusted project ${repo}: ${errorMessage(error)}`);
235
+ }
236
+ }
237
+ if (trustSyncFailures === mergedCounts.size && mergedCounts.size > 0) {
238
+ warn(MODULE, `[ALL_TRUST_SYNCS_FAILED] All ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`);
239
+ }
240
+ });
227
241
  }
228
- if (trustSyncFailures === mergedCounts.size && mergedCounts.size > 0) {
229
- warn(MODULE, `[ALL_TRUST_SYNCS_FAILED] All ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`);
242
+ catch (error) {
243
+ warn(MODULE, `Failed to persist metadata/trust updates: ${errorMessage(error)}`);
230
244
  }
231
245
  }
232
246
  /**
@@ -244,38 +258,47 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs) {
244
258
  const shelvedPRs = [];
245
259
  const autoUnshelvedPRs = [];
246
260
  const activePRs = [];
247
- for (const pr of overriddenPRs) {
248
- if (stateManager.isPRShelved(pr.url)) {
249
- if (CRITICAL_STATUSES.has(pr.status)) {
250
- stateManager.unshelvePR(pr.url);
251
- autoUnshelvedPRs.push(toShelvedPRRef(pr));
252
- activePRs.push(pr);
253
- }
254
- else {
255
- shelvedPRs.push(toShelvedPRRef(pr));
261
+ // Wrap mutations in batch: unshelvePR calls + setLastDigest produce a single save.
262
+ // Outer try-catch: save failure should not crash the daily check (in-memory mutations still apply).
263
+ try {
264
+ stateManager.batch(() => {
265
+ for (const pr of overriddenPRs) {
266
+ if (stateManager.isPRShelved(pr.url)) {
267
+ if (CRITICAL_STATUSES.has(pr.status)) {
268
+ stateManager.unshelvePR(pr.url);
269
+ autoUnshelvedPRs.push(toShelvedPRRef(pr));
270
+ activePRs.push(pr);
271
+ }
272
+ else {
273
+ shelvedPRs.push(toShelvedPRRef(pr));
274
+ }
275
+ }
276
+ else if (pr.stalenessTier === 'dormant' && !CRITICAL_STATUSES.has(pr.status)) {
277
+ // Dormant PRs are auto-shelved unless they need addressing
278
+ // (e.g. maintainer commented on a stale PR — it should resurface)
279
+ shelvedPRs.push(toShelvedPRRef(pr));
280
+ }
281
+ else {
282
+ activePRs.push(pr);
283
+ }
256
284
  }
257
- }
258
- else if (pr.stalenessTier === 'dormant' && !CRITICAL_STATUSES.has(pr.status)) {
259
- // Dormant PRs are auto-shelved unless they need addressing
260
- // (e.g. maintainer commented on a stale PR — it should resurface)
261
- shelvedPRs.push(toShelvedPRRef(pr));
262
- }
263
- else {
264
- activePRs.push(pr);
265
- }
285
+ // Generate digest from override-applied PRs so status categories are correct.
286
+ // Note: digest.openPRs contains ALL fetched PRs (including shelved).
287
+ // We override summary fields below to reflect active-only counts.
288
+ const digest = prMonitor.generateDigest(overriddenPRs, recentlyClosedPRs, recentlyMergedPRs);
289
+ // Attach shelve info to digest
290
+ digest.shelvedPRs = shelvedPRs;
291
+ digest.autoUnshelvedPRs = autoUnshelvedPRs;
292
+ digest.summary.totalActivePRs = activePRs.length;
293
+ // Store digest in state so dashboard can render it
294
+ stateManager.setLastDigest(digest);
295
+ });
296
+ }
297
+ catch (error) {
298
+ warn(MODULE, `Failed to persist partition state: ${errorMessage(error)}`);
266
299
  }
267
- // Generate digest from override-applied PRs so status categories are correct.
268
- // Note: digest.openPRs contains ALL fetched PRs (including shelved).
269
- // We override summary fields below to reflect active-only counts.
270
- const digest = prMonitor.generateDigest(overriddenPRs, recentlyClosedPRs, recentlyMergedPRs);
271
- // Attach shelve info to digest
272
- digest.shelvedPRs = shelvedPRs;
273
- digest.autoUnshelvedPRs = autoUnshelvedPRs;
274
- digest.summary.totalActivePRs = activePRs.length;
275
- // Store digest in state so dashboard can render it
276
- stateManager.setLastDigest(digest);
277
- // Save state (updates lastRunAt, lastDigest, and any auto-unshelve changes)
278
- stateManager.save();
300
+ // Digest was created inside batch reconstruct from state
301
+ const digest = stateManager.getState().lastDigest;
279
302
  return { activePRs, shelvedPRs, autoUnshelvedPRs, digest };
280
303
  }
281
304
  /**
@@ -287,43 +310,47 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
287
310
  const stateManager = getStateManager();
288
311
  // Assess capacity from active PRs only (shelved PRs excluded)
289
312
  const capacity = assessCapacity(activePRs, stateManager.getState().config.maxActivePRs, shelvedPRs.length);
290
- // Filter dismissed issues: suppress if dismissed after last response, resurface + auto-undismiss if new activity
291
- let hasAutoUndismissed = false;
292
- const filteredCommentedIssues = commentedIssues.filter((issue) => {
293
- const dismissedAt = stateManager.getIssueDismissedAt(issue.url);
294
- if (!dismissedAt)
295
- return true; // Not dismissed — include
296
- if (issue.status === 'new_response') {
297
- const responseTime = new Date(issue.lastResponseAt).getTime();
298
- const dismissTime = new Date(dismissedAt).getTime();
299
- if (isNaN(responseTime) || isNaN(dismissTime)) {
300
- // Invalid timestamp fail open (include issue to be safe) without
301
- // permanently removing dismiss record (may be a transient data issue)
302
- warn(MODULE, `Invalid timestamp in dismiss check for ${issue.url}, including issue`);
303
- return true;
304
- }
305
- if (responseTime > dismissTime) {
306
- // New activity after dismiss — auto-undismiss and resurface
307
- warn(MODULE, `Auto-undismissing issue ${issue.url}: new response at ${issue.lastResponseAt} after dismiss at ${dismissedAt}`);
308
- stateManager.undismissIssue(issue.url);
309
- hasAutoUndismissed = true;
310
- return true;
311
- }
312
- }
313
- // Still dismissed (last response is at or before dismiss timestamp)
314
- return false;
315
- });
313
+ // Filter dismissed issues: suppress if dismissed after last response, resurface + auto-undismiss if new activity.
314
+ // Batch: undismissIssue calls trigger autoSave — batch produces a single disk write for all auto-undismisses.
315
+ let filteredCommentedIssues = [];
316
+ try {
317
+ stateManager.batch(() => {
318
+ filteredCommentedIssues = commentedIssues.filter((issue) => {
319
+ const dismissedAt = stateManager.getIssueDismissedAt(issue.url);
320
+ if (!dismissedAt)
321
+ return true; // Not dismissed — include
322
+ if (issue.status === 'new_response') {
323
+ const responseTime = new Date(issue.lastResponseAt).getTime();
324
+ const dismissTime = new Date(dismissedAt).getTime();
325
+ if (isNaN(responseTime) || isNaN(dismissTime)) {
326
+ // Invalid timestamp — fail open (include issue to be safe) without
327
+ // permanently removing dismiss record (may be a transient data issue)
328
+ warn(MODULE, `Invalid timestamp in dismiss check for ${issue.url}, including issue`);
329
+ return true;
330
+ }
331
+ if (responseTime > dismissTime) {
332
+ // New activity after dismiss — auto-undismiss and resurface
333
+ warn(MODULE, `Auto-undismissing issue ${issue.url}: new response at ${issue.lastResponseAt} after dismiss at ${dismissedAt}`);
334
+ try {
335
+ stateManager.undismissIssue(issue.url);
336
+ }
337
+ catch (error) {
338
+ warn(MODULE, `Failed to persist auto-undismiss for ${issue.url}: ${errorMessage(error)}`);
339
+ }
340
+ return true;
341
+ }
342
+ }
343
+ // Still dismissed (last response is at or before dismiss timestamp)
344
+ return false;
345
+ });
346
+ });
347
+ }
348
+ catch (error) {
349
+ warn(MODULE, `Failed to persist auto-undismiss state: ${errorMessage(error)}`);
350
+ }
316
351
  const issueResponses = filteredCommentedIssues.filter((i) => i.status === 'new_response');
317
352
  const summary = formatSummary(digest, capacity, issueResponses);
318
- // Persist auto-undismiss state changes for issues
319
- if (hasAutoUndismissed) {
320
- try {
321
- stateManager.save();
322
- }
323
- catch (error) {
324
- warn(MODULE, `Failed to persist auto-undismissed state: ${errorMessage(error)}`);
325
- }
326
- }
353
+ // Auto-undismiss mutations are auto-saved by undismissIssue()
327
354
  const actionableIssues = collectActionableIssues(activePRs, previousLastDigestAt);
328
355
  digest.summary.totalNeedingAttention = actionableIssues.length;
329
356
  const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length);
@@ -390,8 +417,16 @@ async function executeDailyCheckInternal(token) {
390
417
  const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token);
391
418
  // Phase 2: Update repo scores (signals, star counts, trust sync)
392
419
  await updateRepoScores(prMonitor, prs, mergedCounts, closedCounts);
393
- // Phase 3: Persist monthly analytics
394
- updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
420
+ // Phase 3: Persist monthly analytics (batch the 3 monthly setter calls).
421
+ // try-catch: analytics are supplementary — save failure should not crash the daily check.
422
+ try {
423
+ getStateManager().batch(() => {
424
+ updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
425
+ });
426
+ }
427
+ catch (error) {
428
+ warn(MODULE, `Failed to persist monthly analytics: ${errorMessage(error)}`);
429
+ }
395
430
  // Capture lastDigestAt BEFORE Phase 4 overwrites it with the current run's timestamp.
396
431
  // Used by collectActionableIssues to determine which PRs are "new" (created since last digest).
397
432
  const previousLastDigestAt = getStateManager().getState().lastDigestAt;
@@ -169,43 +169,50 @@ export async function fetchDashboardData(token) {
169
169
  if (failures.length > 0) {
170
170
  warn(MODULE, `${failures.length} PR fetch(es) failed`);
171
171
  }
172
- // Store new merged PRs incrementally (dedupes by URL)
172
+ // Wrap all state mutations in a batch for a single disk write.
173
+ // try-catch: save errors should not crash the dashboard data fetch.
173
174
  try {
174
- stateManager.addMergedPRs(newMergedPRs);
175
- }
176
- catch (error) {
177
- warn(MODULE, `Failed to store merged PRs: ${errorMessage(error)}`);
178
- }
179
- // Store new closed PRs incrementally (dedupes by URL)
180
- try {
181
- stateManager.addClosedPRs(newClosedPRs);
175
+ stateManager.batch(() => {
176
+ // Store new merged PRs incrementally (dedupes by URL)
177
+ try {
178
+ stateManager.addMergedPRs(newMergedPRs);
179
+ }
180
+ catch (error) {
181
+ warn(MODULE, `Failed to store merged PRs: ${errorMessage(error)}`);
182
+ }
183
+ // Store new closed PRs incrementally (dedupes by URL)
184
+ try {
185
+ stateManager.addClosedPRs(newClosedPRs);
186
+ }
187
+ catch (error) {
188
+ warn(MODULE, `Failed to store closed PRs: ${errorMessage(error)}`);
189
+ }
190
+ // Store monthly chart data (opened/merged/closed) so charts have data
191
+ const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
192
+ const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
193
+ updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
194
+ const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
195
+ // Apply shelve partitioning for display (auto-unshelve only runs in daily check)
196
+ // Dormant PRs are treated as shelved unless they need addressing
197
+ const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
198
+ const freshShelved = prs.filter((pr) => shelvedUrls.has(pr.url) || (pr.stalenessTier === 'dormant' && pr.status !== 'needs_addressing'));
199
+ digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
200
+ digest.autoUnshelvedPRs = [];
201
+ digest.summary.totalActivePRs = prs.length - freshShelved.length;
202
+ stateManager.setLastDigest(digest);
203
+ });
182
204
  }
183
205
  catch (error) {
184
- warn(MODULE, `Failed to store closed PRs: ${errorMessage(error)}`);
206
+ warn(MODULE, `Failed to persist dashboard state: ${errorMessage(error)}`);
185
207
  }
186
- // Convert stored PRs to full types (derive repo/number from URL)
208
+ warn(MODULE, `Refreshed: ${prs.length} PRs fetched`);
209
+ // Convert stored PRs to full types (derive repo/number from URL) — read-only, outside batch
187
210
  const allMergedPRs = storedToMergedPRs(stateManager.getMergedPRs());
188
211
  const allClosedPRs = storedToClosedPRs(stateManager.getClosedPRs());
189
- // Store monthly chart data (opened/merged/closed) so charts have data
190
- const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
191
- const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
192
- updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
193
- const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
194
- // Apply shelve partitioning for display (auto-unshelve only runs in daily check)
195
- // Dormant PRs are treated as shelved unless they need addressing
196
- const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
197
- const freshShelved = prs.filter((pr) => shelvedUrls.has(pr.url) || (pr.stalenessTier === 'dormant' && pr.status !== 'needs_addressing'));
198
- digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
199
- digest.autoUnshelvedPRs = [];
200
- digest.summary.totalActivePRs = prs.length - freshShelved.length;
201
- stateManager.setLastDigest(digest);
202
- try {
203
- stateManager.save();
212
+ const digest = stateManager.getState().lastDigest;
213
+ if (!digest) {
214
+ throw new Error('Dashboard data fetch failed: digest was not generated');
204
215
  }
205
- catch (error) {
206
- warn(MODULE, `Failed to save dashboard digest to state: ${errorMessage(error)}`);
207
- }
208
- warn(MODULE, `Refreshed: ${prs.length} PRs fetched`);
209
216
  return { digest, commentedIssues, allMergedPRs, allClosedPRs };
210
217
  }
211
218
  /**
@@ -53,6 +53,13 @@ function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClo
53
53
  const stats = buildDashboardStats(digest, state, filteredMergedPRs.length, filteredClosedPRs.length);
54
54
  const dismissedIssues = state.config.dismissedIssues || {};
55
55
  const issueResponses = commentedIssues.filter((i) => i.status === 'new_response' && !(i.url in dismissedIssues));
56
+ // Build repo metadata map from repoScores — omit repos without stars or language to avoid empty entries
57
+ const repoMetadata = {};
58
+ for (const [repo, score] of Object.entries(repoScores)) {
59
+ if (score.stargazersCount !== undefined || score.language !== undefined) {
60
+ repoMetadata[repo] = { stars: score.stargazersCount, language: score.language };
61
+ }
62
+ }
56
63
  return {
57
64
  stats,
58
65
  prsByRepo,
@@ -69,6 +76,7 @@ function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClo
69
76
  issueResponses,
70
77
  allMergedPRs: filteredMergedPRs,
71
78
  allClosedPRs: filteredClosedPRs,
79
+ repoMetadata,
72
80
  };
73
81
  }
74
82
  /**
@@ -285,7 +293,6 @@ export async function startDashboardServer(options) {
285
293
  else {
286
294
  // dismiss_issue_response
287
295
  stateManager.dismissIssue(body.url, new Date().toISOString());
288
- stateManager.save();
289
296
  }
290
297
  }
291
298
  catch (error) {
@@ -10,9 +10,6 @@ export async function runDismiss(options) {
10
10
  validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
11
11
  const stateManager = getStateManager();
12
12
  const added = stateManager.dismissIssue(options.url, new Date().toISOString());
13
- if (added) {
14
- stateManager.save();
15
- }
16
13
  return { dismissed: added, url: options.url };
17
14
  }
18
15
  export async function runUndismiss(options) {
@@ -20,8 +17,5 @@ export async function runUndismiss(options) {
20
17
  validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
21
18
  const stateManager = getStateManager();
22
19
  const removed = stateManager.undismissIssue(options.url);
23
- if (removed) {
24
- stateManager.save();
25
- }
26
20
  return { undismissed: removed, url: options.url };
27
21
  }