@oss-autopilot/core 0.44.3 → 0.44.16

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.
@@ -6,9 +6,14 @@
6
6
  * Domain logic lives in src/core/daily-logic.ts; this file is a thin
7
7
  * orchestration layer that wires up the phases and handles I/O.
8
8
  */
9
- import { type DailyDigest, type CommentedIssue, type PRCheckFailure, type RepoGroup } from '../core/index.js';
9
+ import { type DailyDigest, type CommentedIssue, type PRCheckFailure, type RepoGroup, type AgentState, type StarFilter } from '../core/index.js';
10
10
  import { type DailyOutput, type CapacityAssessment, type ActionableIssue, type ActionMenu } from '../formatters/json.js';
11
11
  export { computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
12
+ /**
13
+ * Build a star filter from state for use in fetchUserPRCounts.
14
+ * Returns undefined if no star data is available (first run).
15
+ */
16
+ export declare function buildStarFilter(state: Readonly<AgentState>): StarFilter | undefined;
12
17
  /**
13
18
  * Internal result of the daily check, using full (non-deduplicated) types.
14
19
  * Consumed by printDigest() (text mode) and converted to DailyOutput (JSON mode)
@@ -16,6 +16,23 @@ const MODULE = 'daily';
16
16
  // Re-export domain functions so existing consumers (tests, dashboard, startup)
17
17
  // can continue importing from './daily.js' without changes.
18
18
  export { computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
19
+ /**
20
+ * Build a star filter from state for use in fetchUserPRCounts.
21
+ * Returns undefined if no star data is available (first run).
22
+ */
23
+ export function buildStarFilter(state) {
24
+ const minStars = state.config.minStars ?? 50;
25
+ const knownStarCounts = new Map();
26
+ for (const [repo, score] of Object.entries(state.repoScores)) {
27
+ if (score.stargazersCount !== undefined) {
28
+ knownStarCounts.set(repo, score.stargazersCount);
29
+ }
30
+ }
31
+ // Only filter if we have some star data to work with
32
+ if (knownStarCounts.size === 0)
33
+ return undefined;
34
+ return { minStars, knownStarCounts };
35
+ }
19
36
  // ---------------------------------------------------------------------------
20
37
  // Phase functions
21
38
  // ---------------------------------------------------------------------------
@@ -31,17 +48,21 @@ async function fetchPRData(prMonitor, token) {
31
48
  if (failures.length > 0) {
32
49
  warn(MODULE, `${failures.length} PR fetch(es) failed`);
33
50
  }
51
+ // Build star filter from cached repoScores so low-star repos are excluded
52
+ // from merged/closed histograms (#576). Repos with no cached star data pass through.
53
+ const state = getStateManager().getState();
54
+ const starFilter = buildStarFilter(state);
34
55
  // Fetch merged PR counts, closed PR counts, recently closed PRs, recently merged PRs, and commented issues in parallel
35
56
  // All stats fetches are non-critical (cosmetic/scoring), so isolate their failure
36
57
  const issueMonitor = new IssueConversationMonitor(token);
37
58
  const [mergedResult, closedResult, recentlyClosedPRs, recentlyMergedPRs, issueConversationResult] = await Promise.all([
38
- prMonitor.fetchUserMergedPRCounts().catch((err) => {
59
+ prMonitor.fetchUserMergedPRCounts(starFilter).catch((err) => {
39
60
  if (isRateLimitOrAuthError(err))
40
61
  throw err;
41
62
  warn(MODULE, `Failed to fetch merged PR counts: ${errorMessage(err)}`);
42
63
  return emptyPRCountsResult();
43
64
  }),
44
- prMonitor.fetchUserClosedPRCounts().catch((err) => {
65
+ prMonitor.fetchUserClosedPRCounts(starFilter).catch((err) => {
45
66
  if (isRateLimitOrAuthError(err))
46
67
  throw err;
47
68
  warn(MODULE, `Failed to fetch closed PR counts: ${errorMessage(err)}`);
@@ -177,7 +198,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
177
198
  }
178
199
  catch (error) {
179
200
  warn(MODULE, `Failed to fetch repo star counts: ${errorMessage(error)}`);
180
- warn(MODULE, 'Dashboard minStars filter will use cached star counts (or be skipped for repos without cached data).');
201
+ warn(MODULE, 'Repos without cached star data will be excluded from stats until star counts are fetched on the next successful run.');
181
202
  starCounts = new Map();
182
203
  }
183
204
  let starUpdateFailures = 0;
@@ -243,8 +264,9 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs) {
243
264
  shelvedPRs.push(toShelvedPRRef(pr));
244
265
  }
245
266
  }
246
- else if (pr.status === 'dormant') {
247
- // Dormant PRs are auto-shelved (not persisted — they return when activity resumes)
267
+ else if (pr.stalenessTier === 'dormant' && !CRITICAL_STATUSES.has(pr.status)) {
268
+ // Dormant PRs are auto-shelved unless they need addressing
269
+ // (e.g. maintainer commented on a stale PR — it should resurface)
248
270
  shelvedPRs.push(toShelvedPRRef(pr));
249
271
  }
250
272
  else {
@@ -3,7 +3,7 @@
3
3
  * Handles GitHub API calls, PR grouping, stats computation, and monthly chart data.
4
4
  * Consumed by the dashboard HTTP server (dashboard-server.ts) for the SPA API.
5
5
  */
6
- import type { DailyDigest, AgentState, CommentedIssue } from '../core/types.js';
6
+ import { type DailyDigest, type AgentState, type CommentedIssue } from '../core/types.js';
7
7
  export interface DashboardStats {
8
8
  activePRs: number;
9
9
  shelvedPRs: number;
@@ -12,10 +12,18 @@ export interface DashboardStats {
12
12
  mergeRate: string;
13
13
  }
14
14
  export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>): DashboardStats;
15
+ /**
16
+ * Merge fresh API counts into existing stored counts.
17
+ * Months present in the fresh data are updated; months only in the existing data are preserved.
18
+ * This prevents historical data loss when the API returns incomplete results
19
+ * (e.g. due to pagination limits or transient failures).
20
+ */
21
+ export declare function mergeMonthlyCounts(existing: Record<string, number>, fresh: Record<string, number>): Record<string, number>;
15
22
  /**
16
23
  * Persist monthly chart analytics (merged, closed, opened) to state.
17
24
  * Each metric is isolated so partial failures don't produce inconsistent state.
18
- * Skips overwriting when data is empty to avoid wiping chart data on transient API failures.
25
+ * Fresh API results are merged into existing data so historical months are preserved.
26
+ * Skips updating when fresh data is empty to avoid wiping chart data on transient API failures.
19
27
  */
20
28
  export declare function updateMonthlyAnalytics(prs: Array<{
21
29
  createdAt?: string;
@@ -7,7 +7,8 @@ import { getStateManager, PRMonitor, IssueConversationMonitor } from '../core/in
7
7
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
8
8
  import { warn } from '../core/logger.js';
9
9
  import { emptyPRCountsResult } from '../core/github-stats.js';
10
- import { toShelvedPRRef } from './daily.js';
10
+ import { isBelowMinStars, } from '../core/types.js';
11
+ import { toShelvedPRRef, buildStarFilter } from './daily.js';
11
12
  const MODULE = 'dashboard-data';
12
13
  export function buildDashboardStats(digest, state) {
13
14
  const summary = digest.summary || {
@@ -16,24 +17,40 @@ export function buildDashboardStats(digest, state) {
16
17
  mergeRate: 0,
17
18
  totalNeedingAttention: 0,
18
19
  };
20
+ const minStars = state.config.minStars ?? 50;
19
21
  return {
20
22
  activePRs: summary.totalActivePRs,
21
23
  shelvedPRs: (digest.shelvedPRs || []).length,
22
24
  mergedPRs: summary.totalMergedAllTime,
23
- closedPRs: Object.values(state.repoScores || {}).reduce((sum, s) => sum + (s.closedWithoutMergeCount || 0), 0),
25
+ closedPRs: Object.values(state.repoScores || {}).reduce((sum, s) => sum + (isBelowMinStars(s.stargazersCount, minStars) ? 0 : s.closedWithoutMergeCount || 0), 0),
24
26
  mergeRate: `${(summary.mergeRate ?? 0).toFixed(1)}%`,
25
27
  };
26
28
  }
29
+ /**
30
+ * Merge fresh API counts into existing stored counts.
31
+ * Months present in the fresh data are updated; months only in the existing data are preserved.
32
+ * This prevents historical data loss when the API returns incomplete results
33
+ * (e.g. due to pagination limits or transient failures).
34
+ */
35
+ export function mergeMonthlyCounts(existing, fresh) {
36
+ const merged = { ...existing };
37
+ for (const [month, count] of Object.entries(fresh)) {
38
+ merged[month] = count;
39
+ }
40
+ return merged;
41
+ }
27
42
  /**
28
43
  * Persist monthly chart analytics (merged, closed, opened) to state.
29
44
  * Each metric is isolated so partial failures don't produce inconsistent state.
30
- * Skips overwriting when data is empty to avoid wiping chart data on transient API failures.
45
+ * Fresh API results are merged into existing data so historical months are preserved.
46
+ * Skips updating when fresh data is empty to avoid wiping chart data on transient API failures.
31
47
  */
32
48
  export function updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed) {
33
49
  const stateManager = getStateManager();
50
+ const state = stateManager.getState();
34
51
  try {
35
52
  if (Object.keys(monthlyCounts).length > 0) {
36
- stateManager.setMonthlyMergedCounts(monthlyCounts);
53
+ stateManager.setMonthlyMergedCounts(mergeMonthlyCounts(state.monthlyMergedCounts || {}, monthlyCounts));
37
54
  }
38
55
  }
39
56
  catch (error) {
@@ -41,7 +58,7 @@ export function updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts,
41
58
  }
42
59
  try {
43
60
  if (Object.keys(monthlyClosedCounts).length > 0) {
44
- stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
61
+ stateManager.setMonthlyClosedCounts(mergeMonthlyCounts(state.monthlyClosedCounts || {}, monthlyClosedCounts));
45
62
  }
46
63
  }
47
64
  catch (error) {
@@ -59,7 +76,7 @@ export function updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts,
59
76
  }
60
77
  }
61
78
  if (Object.keys(combinedOpenedCounts).length > 0) {
62
- stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
79
+ stateManager.setMonthlyOpenedCounts(mergeMonthlyCounts(state.monthlyOpenedCounts || {}, combinedOpenedCounts));
63
80
  }
64
81
  }
65
82
  catch (error) {
@@ -75,6 +92,8 @@ export async function fetchDashboardData(token) {
75
92
  const stateManager = getStateManager();
76
93
  const prMonitor = new PRMonitor(token);
77
94
  const issueMonitor = new IssueConversationMonitor(token);
95
+ // Build star filter from cached repoScores (#576)
96
+ const starFilter = buildStarFilter(stateManager.getState());
78
97
  const [{ prs, failures }, recentlyClosedPRs, recentlyMergedPRs, mergedResult, closedResult, fetchedIssues] = await Promise.all([
79
98
  prMonitor.fetchUserOpenPRs(),
80
99
  prMonitor.fetchRecentlyClosedPRs().catch((err) => {
@@ -89,19 +108,21 @@ export async function fetchDashboardData(token) {
89
108
  warn(MODULE, `Failed to fetch recently merged PRs: ${errorMessage(err)}`);
90
109
  return [];
91
110
  }),
92
- prMonitor.fetchUserMergedPRCounts().catch((err) => {
111
+ prMonitor.fetchUserMergedPRCounts(starFilter).catch((err) => {
93
112
  if (isRateLimitOrAuthError(err))
94
113
  throw err;
95
114
  warn(MODULE, `Failed to fetch merged PR counts: ${errorMessage(err)}`);
96
115
  return emptyPRCountsResult();
97
116
  }),
98
- prMonitor.fetchUserClosedPRCounts().catch((err) => {
117
+ prMonitor.fetchUserClosedPRCounts(starFilter).catch((err) => {
99
118
  if (isRateLimitOrAuthError(err))
100
119
  throw err;
101
120
  warn(MODULE, `Failed to fetch closed PR counts: ${errorMessage(err)}`);
102
121
  return emptyPRCountsResult();
103
122
  }),
104
123
  issueMonitor.fetchCommentedIssues().catch((error) => {
124
+ if (isRateLimitOrAuthError(error))
125
+ throw error;
105
126
  const msg = errorMessage(error);
106
127
  if (msg.includes('No GitHub username configured')) {
107
128
  warn(MODULE, `Issue conversation tracking requires setup: ${msg}`);
@@ -128,9 +149,9 @@ export async function fetchDashboardData(token) {
128
149
  updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
129
150
  const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
130
151
  // Apply shelve partitioning for display (auto-unshelve only runs in daily check)
131
- // Dormant PRs are treated as shelved for display purposes
152
+ // Dormant PRs are treated as shelved unless they need addressing
132
153
  const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
133
- const freshShelved = prs.filter((pr) => shelvedUrls.has(pr.url) || pr.status === 'dormant');
154
+ const freshShelved = prs.filter((pr) => shelvedUrls.has(pr.url) || (pr.stalenessTier === 'dormant' && pr.status !== 'needs_addressing'));
134
155
  digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
135
156
  digest.autoUnshelvedPRs = [];
136
157
  digest.summary.totalActivePRs = prs.length - freshShelved.length;
@@ -156,8 +177,11 @@ export function computePRsByRepo(digest, state) {
156
177
  prsByRepo[pr.repo] = { active: 0, merged: 0, closed: 0 };
157
178
  prsByRepo[pr.repo].active++;
158
179
  }
159
- // Add merged/closed counts from repo scores (historical data)
180
+ // Add merged/closed counts from repo scores (historical data), filtering by minStars (#576)
181
+ const minStars = state.config.minStars ?? 50;
160
182
  for (const [repo, score] of Object.entries(state.repoScores || {})) {
183
+ if (isBelowMinStars(score.stargazersCount, minStars))
184
+ continue;
161
185
  if (!prsByRepo[repo])
162
186
  prsByRepo[repo] = { active: 0, merged: 0, closed: 0 };
163
187
  prsByRepo[repo].merged = score.mergedPRCount;
@@ -4,8 +4,9 @@
4
4
  * and detecting whether a server is already running.
5
5
  */
6
6
  import { spawn } from 'child_process';
7
- import { findRunningDashboardServer, isDashboardServerRunning, readDashboardServerInfo } from './dashboard-server.js';
7
+ import { findRunningDashboardServer, isDashboardServerRunning, readDashboardServerInfo, removeDashboardServerInfo, } from './dashboard-server.js';
8
8
  import { resolveAssetsDir } from './dashboard.js';
9
+ import { getCLIVersion } from '../core/index.js';
9
10
  const DEFAULT_PORT = 3000;
10
11
  const POLL_INTERVAL_MS = 200;
11
12
  const MAX_POLL_ATTEMPTS = 25; // 5 seconds total
@@ -30,7 +31,43 @@ export async function launchDashboardServer(options) {
30
31
  // 2. Check if a server is already running
31
32
  const existing = await findRunningDashboardServer();
32
33
  if (existing) {
33
- return { url: existing.url, port: existing.port, alreadyRunning: true };
34
+ // If the running server is from a different CLI version, kill it and relaunch
35
+ // so the dashboard uses the current version's code (#548)
36
+ const info = readDashboardServerInfo();
37
+ const currentVersion = getCLIVersion();
38
+ if (!info) {
39
+ // PID file disappeared between health check and now (race condition).
40
+ // Fall through to launch a new server.
41
+ }
42
+ else if (info.version && currentVersion !== '0.0.0' && info.version !== currentVersion) {
43
+ console.error(`[STARTUP] Dashboard server version mismatch (running: ${info.version}, current: ${currentVersion}). Restarting...`);
44
+ let killed = false;
45
+ try {
46
+ process.kill(info.pid, 'SIGTERM');
47
+ killed = true;
48
+ }
49
+ catch (err) {
50
+ const code = err.code;
51
+ if (code === 'ESRCH') {
52
+ killed = true; // Already exited
53
+ }
54
+ else {
55
+ console.error(`[STARTUP] Could not kill outdated dashboard (PID ${info.pid}): ${err.message}`);
56
+ }
57
+ }
58
+ if (killed) {
59
+ removeDashboardServerInfo();
60
+ }
61
+ else {
62
+ // Could not kill old server (e.g. EPERM); return it rather than
63
+ // attempting a doomed spawn on the same port.
64
+ return { url: existing.url, port: existing.port, alreadyRunning: true };
65
+ }
66
+ // Fall through to launch a new server
67
+ }
68
+ else {
69
+ return { url: existing.url, port: existing.port, alreadyRunning: true };
70
+ }
34
71
  }
35
72
  // 3. Launch as detached child process
36
73
  const port = options?.port ?? DEFAULT_PORT;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Client-side JavaScript for the dashboard: theme toggle, filtering, Chart.js charts.
3
3
  */
4
- import type { DailyDigest, AgentState } from '../core/types.js';
4
+ import { type DailyDigest, type AgentState } from '../core/types.js';
5
5
  import type { DashboardStats } from './dashboard-data.js';
6
6
  /** Generate the Chart.js JavaScript for the dashboard. */
7
7
  export declare function generateDashboardScripts(stats: DashboardStats, monthlyMerged: Record<string, number>, monthlyClosed: Record<string, number>, monthlyOpened: Record<string, number>, digest: DailyDigest, state: Readonly<AgentState>): string;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Client-side JavaScript for the dashboard: theme toggle, filtering, Chart.js charts.
3
3
  */
4
+ import { isBelowMinStars } from '../core/types.js';
4
5
  /** Static client-side JS: theme toggle + filter/search logic. */
5
6
  const THEME_AND_FILTER_SCRIPT = `
6
7
  // === Theme Toggle ===
@@ -141,10 +142,9 @@ export function generateDashboardScripts(stats, monthlyMerged, monthlyClosed, mo
141
142
  if (exOrgs?.some((o) => o.toLowerCase() === repoLower.split('/')[0]))
142
143
  return true;
143
144
  const score = (state.repoScores || {})[repo];
144
- // Fail-open: repos without cached star data are shown (not excluded).
145
- // Unlike issue-discovery (fail-closed), the dashboard shows the user's own
146
- // contribution history — hiding repos just because a star fetch failed would be confusing.
147
- if (score?.stargazersCount !== undefined && score.stargazersCount < starThreshold)
145
+ // Fail-closed: repos without cached star data are excluded from charts.
146
+ // Star data is populated by the daily check; repos appear once stars are fetched.
147
+ if (isBelowMinStars(score?.stargazersCount, starThreshold))
148
148
  return true;
149
149
  return false;
150
150
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Dashboard HTTP server.
3
3
  * Serves the Preact SPA from packages/dashboard/dist/ and provides API endpoints
4
- * for live data fetching and state mutations (shelve, snooze, etc.).
4
+ * for live data fetching and state mutations (shelve, unshelve, override, etc.).
5
5
  *
6
6
  * Uses Node's built-in http module — no Express/Fastify.
7
7
  */
@@ -15,6 +15,7 @@ export interface DashboardServerInfo {
15
15
  pid: number;
16
16
  port: number;
17
17
  startedAt: string;
18
+ version?: string;
18
19
  }
19
20
  export declare function getDashboardPidPath(): string;
20
21
  export declare function writeDashboardServerInfo(info: DashboardServerInfo): void;
@@ -1,28 +1,21 @@
1
1
  /**
2
2
  * Dashboard HTTP server.
3
3
  * Serves the Preact SPA from packages/dashboard/dist/ and provides API endpoints
4
- * for live data fetching and state mutations (shelve, snooze, etc.).
4
+ * for live data fetching and state mutations (shelve, unshelve, override, etc.).
5
5
  *
6
6
  * Uses Node's built-in http module — no Express/Fastify.
7
7
  */
8
8
  import * as http from 'http';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
- import { getStateManager, getGitHubToken, getDataDir } from '../core/index.js';
11
+ import { getStateManager, getGitHubToken, getDataDir, getCLIVersion } from '../core/index.js';
12
12
  import { errorMessage, ValidationError } from '../core/errors.js';
13
13
  import { warn } from '../core/logger.js';
14
- import { validateUrl, validateGitHubUrl, validateMessage, PR_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN, } from './validation.js';
14
+ import { validateUrl, validateGitHubUrl, PR_URL_PATTERN } from './validation.js';
15
15
  import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, } from './dashboard-data.js';
16
16
  import { openInBrowser } from './startup.js';
17
17
  // ── Constants ────────────────────────────────────────────────────────────────
18
- const VALID_ACTIONS = new Set([
19
- 'shelve',
20
- 'unshelve',
21
- 'snooze',
22
- 'unsnooze',
23
- 'dismiss',
24
- 'undismiss',
25
- ]);
18
+ const VALID_ACTIONS = new Set(['shelve', 'unshelve', 'override_status']);
26
19
  const MODULE = 'dashboard-server';
27
20
  const MAX_BODY_BYTES = 10_240;
28
21
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -35,6 +28,40 @@ const MIME_TYPES = {
35
28
  '.png': 'image/png',
36
29
  '.ico': 'image/x-icon',
37
30
  };
31
+ /**
32
+ * Apply status overrides from state to the PR list.
33
+ * Overrides are auto-cleared if the PR has new activity since the override was set.
34
+ */
35
+ function applyStatusOverrides(prs, state) {
36
+ const overrides = state.config.statusOverrides;
37
+ if (!overrides || Object.keys(overrides).length === 0)
38
+ return prs;
39
+ const stateManager = getStateManager();
40
+ // Snapshot keys before iteration — clearStatusOverride mutates the same object
41
+ const overrideUrls = new Set(Object.keys(overrides));
42
+ let didAutoClear = false;
43
+ const result = prs.map((pr) => {
44
+ const override = stateManager.getStatusOverride(pr.url, pr.updatedAt);
45
+ if (!override) {
46
+ if (overrideUrls.has(pr.url))
47
+ didAutoClear = true;
48
+ return pr;
49
+ }
50
+ if (override.status === pr.status)
51
+ return pr;
52
+ return { ...pr, status: override.status };
53
+ });
54
+ // Persist any auto-cleared overrides so they don't resurrect on restart
55
+ if (didAutoClear) {
56
+ try {
57
+ stateManager.save();
58
+ }
59
+ catch (err) {
60
+ warn(MODULE, `Failed to persist auto-cleared overrides — they may reappear on restart: ${errorMessage(err)}`);
61
+ }
62
+ }
63
+ return result;
64
+ }
38
65
  // ── PID File Management ──────────────────────────────────────────────────────
39
66
  export function getDashboardPidPath() {
40
67
  return path.join(getDataDir(), 'dashboard-server.pid');
@@ -49,6 +76,8 @@ export function readDashboardServerInfo() {
49
76
  if (typeof parsed !== 'object' ||
50
77
  parsed === null ||
51
78
  typeof parsed.pid !== 'number' ||
79
+ !Number.isInteger(parsed.pid) ||
80
+ parsed.pid <= 0 ||
52
81
  typeof parsed.port !== 'number' ||
53
82
  typeof parsed.startedAt !== 'string') {
54
83
  warn(MODULE, 'PID file has invalid structure, ignoring');
@@ -133,9 +162,8 @@ function buildDashboardJson(digest, state, commentedIssues) {
133
162
  monthlyMerged,
134
163
  monthlyOpened,
135
164
  monthlyClosed,
136
- activePRs: digest.openPRs || [],
165
+ activePRs: applyStatusOverrides(digest.openPRs || [], state),
137
166
  shelvedPRUrls: state.config.shelvedPRUrls || [],
138
- dismissedUrls: Object.keys(state.config.dismissedIssues || {}),
139
167
  recentlyMergedPRs: digest.recentlyMergedPRs || [],
140
168
  recentlyClosedPRs: digest.recentlyClosedPRs || [],
141
169
  autoUnshelvedPRs: digest.autoUnshelvedPRs || [],
@@ -295,14 +323,10 @@ export async function startDashboardServer(options) {
295
323
  sendError(res, 400, 'Missing or invalid "url" field');
296
324
  return;
297
325
  }
298
- // Validate URL format — same checks as CLI commands.
299
- // Dismiss/undismiss accepts both PR and issue URLs; other actions are PR-only.
300
- const isDismissAction = body.action === 'dismiss' || body.action === 'undismiss';
301
- const urlPattern = isDismissAction ? ISSUE_OR_PR_URL_PATTERN : PR_URL_PATTERN;
302
- const urlType = isDismissAction ? 'issue or PR' : 'PR';
326
+ // Validate URL format — all actions are PR-only now.
303
327
  try {
304
328
  validateUrl(body.url);
305
- validateGitHubUrl(body.url, urlPattern, urlType);
329
+ validateGitHubUrl(body.url, PR_URL_PATTERN, 'PR');
306
330
  }
307
331
  catch (err) {
308
332
  if (err instanceof ValidationError) {
@@ -314,51 +338,30 @@ export async function startDashboardServer(options) {
314
338
  }
315
339
  return;
316
340
  }
317
- // Validate snooze-specific fields
318
- if (body.action === 'snooze') {
319
- const days = body.days ?? 7;
320
- if (typeof days !== 'number' || !Number.isFinite(days) || days <= 0) {
321
- sendError(res, 400, 'Snooze days must be a positive finite number');
341
+ // Validate override_status-specific fields
342
+ if (body.action === 'override_status') {
343
+ if (!body.status || (body.status !== 'needs_addressing' && body.status !== 'waiting_on_maintainer')) {
344
+ sendError(res, 400, 'override_status requires a valid "status" field (needs_addressing or waiting_on_maintainer)');
322
345
  return;
323
346
  }
324
- if (body.reason !== undefined) {
325
- try {
326
- validateMessage(String(body.reason));
327
- }
328
- catch (err) {
329
- if (err instanceof ValidationError) {
330
- sendError(res, 400, err.message);
331
- }
332
- else {
333
- warn(MODULE, `Unexpected error during message validation: ${errorMessage(err)}`);
334
- sendError(res, 400, 'Invalid reason');
335
- }
336
- return;
337
- }
338
- }
339
347
  }
340
348
  try {
341
349
  switch (body.action) {
342
350
  case 'shelve':
343
351
  stateManager.shelvePR(body.url);
344
- stateManager.undismissIssue(body.url); // prevent dual state
345
352
  break;
346
353
  case 'unshelve':
347
354
  stateManager.unshelvePR(body.url);
348
355
  break;
349
- case 'snooze':
350
- stateManager.snoozePR(body.url, body.reason || 'Snoozed via dashboard', body.days ?? 7);
351
- break;
352
- case 'unsnooze':
353
- stateManager.unsnoozePR(body.url);
354
- break;
355
- case 'dismiss':
356
- stateManager.dismissIssue(body.url, new Date().toISOString());
357
- stateManager.unshelvePR(body.url); // prevent dual state
358
- break;
359
- case 'undismiss':
360
- stateManager.undismissIssue(body.url);
356
+ case 'override_status': {
357
+ // body.status is validated above — the early return ensures it's defined here
358
+ const overrideStatus = body.status;
359
+ // Find the PR to get its current updatedAt for auto-clear tracking
360
+ const targetPR = (cachedDigest?.openPRs || []).find((pr) => pr.url === body.url);
361
+ const lastActivityAt = targetPR?.updatedAt || new Date().toISOString();
362
+ stateManager.setStatusOverride(body.url, overrideStatus, lastActivityAt);
361
363
  break;
364
+ }
362
365
  }
363
366
  stateManager.save();
364
367
  }
@@ -480,7 +483,12 @@ export async function startDashboardServer(options) {
480
483
  }
481
484
  }
482
485
  // Write PID file so other processes can detect this running server
483
- writeDashboardServerInfo({ pid: process.pid, port: actualPort, startedAt: new Date().toISOString() });
486
+ writeDashboardServerInfo({
487
+ pid: process.pid,
488
+ port: actualPort,
489
+ startedAt: new Date().toISOString(),
490
+ version: getCLIVersion(),
491
+ });
484
492
  const serverUrl = `http://localhost:${actualPort}`;
485
493
  warn(MODULE, `Dashboard server running at ${serverUrl}`);
486
494
  // ── Background refresh ─────────────────────────────────────────────────