@oss-autopilot/core 0.46.2 → 0.47.1

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.
@@ -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, type AgentState, type CommentedIssue } from '../core/types.js';
6
+ import { type DailyDigest, type AgentState, type MergedPR, type StoredMergedPR, type CommentedIssue } from '../core/types.js';
7
7
  export interface DashboardStats {
8
8
  activePRs: number;
9
9
  shelvedPRs: number;
@@ -11,7 +11,7 @@ export interface DashboardStats {
11
11
  closedPRs: number;
12
12
  mergeRate: string;
13
13
  }
14
- export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>): DashboardStats;
14
+ export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>, storedMergedCount?: number): DashboardStats;
15
15
  /**
16
16
  * Merge fresh API counts into existing stored counts.
17
17
  * Months present in the fresh data are updated; months only in the existing data are preserved.
@@ -31,6 +31,7 @@ export declare function updateMonthlyAnalytics(prs: Array<{
31
31
  export interface DashboardFetchResult {
32
32
  digest: DailyDigest;
33
33
  commentedIssues: CommentedIssue[];
34
+ allMergedPRs: MergedPR[];
34
35
  }
35
36
  /**
36
37
  * Fetch fresh dashboard data from GitHub.
@@ -38,6 +39,11 @@ export interface DashboardFetchResult {
38
39
  * Throws if the fetch fails entirely (caller should fall back to cached data).
39
40
  */
40
41
  export declare function fetchDashboardData(token: string): Promise<DashboardFetchResult>;
42
+ /**
43
+ * Convert StoredMergedPR[] to MergedPR[] by deriving repo and number from URL.
44
+ * Skips entries with unparseable URLs.
45
+ */
46
+ export declare function storedToMergedPRs(stored: StoredMergedPR[]): MergedPR[];
41
47
  /**
42
48
  * Compute PRs grouped by repository from a digest and state.
43
49
  * Used for chart data in the dashboard API.
@@ -3,14 +3,15 @@
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 { getStateManager, PRMonitor, IssueConversationMonitor } from '../core/index.js';
6
+ import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit } from '../core/index.js';
7
7
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
8
8
  import { warn } from '../core/logger.js';
9
- import { emptyPRCountsResult } from '../core/github-stats.js';
9
+ import { emptyPRCountsResult, fetchMergedPRsSince } from '../core/github-stats.js';
10
+ import { parseGitHubUrl } from '../core/utils.js';
10
11
  import { isBelowMinStars, } from '../core/types.js';
11
12
  import { toShelvedPRRef, buildStarFilter } from './daily.js';
12
13
  const MODULE = 'dashboard-data';
13
- export function buildDashboardStats(digest, state) {
14
+ export function buildDashboardStats(digest, state, storedMergedCount) {
14
15
  const summary = digest.summary || {
15
16
  totalActivePRs: 0,
16
17
  totalMergedAllTime: 0,
@@ -18,10 +19,15 @@ export function buildDashboardStats(digest, state) {
18
19
  totalNeedingAttention: 0,
19
20
  };
20
21
  const minStars = state.config.minStars ?? 50;
22
+ // Use the higher of stored merged PR count and repoScores-derived count to avoid regressions
23
+ // when stored list hasn't caught up yet (first fetch caps at 300)
24
+ const mergedPRs = storedMergedCount !== undefined
25
+ ? Math.max(storedMergedCount, summary.totalMergedAllTime)
26
+ : summary.totalMergedAllTime;
21
27
  return {
22
28
  activePRs: summary.totalActivePRs,
23
29
  shelvedPRs: (digest.shelvedPRs || []).length,
24
- mergedPRs: summary.totalMergedAllTime,
30
+ mergedPRs,
25
31
  closedPRs: Object.values(state.repoScores || {}).reduce((sum, s) => sum + (isBelowMinStars(s.stargazersCount, minStars) ? 0 : s.closedWithoutMergeCount || 0), 0),
26
32
  mergeRate: `${(summary.mergeRate ?? 0).toFixed(1)}%`,
27
33
  };
@@ -92,9 +98,13 @@ export async function fetchDashboardData(token) {
92
98
  const stateManager = getStateManager();
93
99
  const prMonitor = new PRMonitor(token);
94
100
  const issueMonitor = new IssueConversationMonitor(token);
101
+ const octokit = getOctokit(token);
102
+ const config = stateManager.getState().config;
95
103
  // Build star filter from cached repoScores (#576)
96
104
  const starFilter = buildStarFilter(stateManager.getState());
97
- const [{ prs, failures }, recentlyClosedPRs, recentlyMergedPRs, mergedResult, closedResult, fetchedIssues] = await Promise.all([
105
+ // Get watermark for incremental merged PR fetch
106
+ const watermark = stateManager.getMergedPRWatermark();
107
+ const [{ prs, failures }, recentlyClosedPRs, recentlyMergedPRs, mergedResult, closedResult, fetchedIssues, newMergedPRs,] = await Promise.all([
98
108
  prMonitor.fetchUserOpenPRs(),
99
109
  prMonitor.fetchRecentlyClosedPRs().catch((err) => {
100
110
  if (isRateLimitOrAuthError(err))
@@ -135,6 +145,12 @@ export async function fetchDashboardData(token) {
135
145
  failures: [{ issueUrl: 'N/A', error: `Issue conversation fetch failed: ${msg}` }],
136
146
  };
137
147
  }),
148
+ fetchMergedPRsSince(octokit, config, watermark).catch((err) => {
149
+ if (isRateLimitOrAuthError(err))
150
+ throw err;
151
+ warn(MODULE, `Failed to fetch merged PRs for storage: ${errorMessage(err)}`);
152
+ return [];
153
+ }),
138
154
  ]);
139
155
  const commentedIssues = fetchedIssues.issues;
140
156
  if (fetchedIssues.failures.length > 0) {
@@ -143,6 +159,15 @@ export async function fetchDashboardData(token) {
143
159
  if (failures.length > 0) {
144
160
  warn(MODULE, `${failures.length} PR fetch(es) failed`);
145
161
  }
162
+ // Store new merged PRs incrementally (dedupes by URL)
163
+ try {
164
+ stateManager.addMergedPRs(newMergedPRs);
165
+ }
166
+ catch (error) {
167
+ warn(MODULE, `Failed to store merged PRs: ${errorMessage(error)}`);
168
+ }
169
+ // Convert stored merged PRs to full MergedPR type (derive repo/number from URL)
170
+ const allMergedPRs = storedToMergedPRs(stateManager.getMergedPRs());
146
171
  // Store monthly chart data (opened/merged/closed) so charts have data
147
172
  const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
148
173
  const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
@@ -163,7 +188,33 @@ export async function fetchDashboardData(token) {
163
188
  warn(MODULE, `Failed to save dashboard digest to state: ${errorMessage(error)}`);
164
189
  }
165
190
  warn(MODULE, `Refreshed: ${prs.length} PRs fetched`);
166
- return { digest, commentedIssues };
191
+ return { digest, commentedIssues, allMergedPRs };
192
+ }
193
+ /**
194
+ * Convert StoredMergedPR[] to MergedPR[] by deriving repo and number from URL.
195
+ * Skips entries with unparseable URLs.
196
+ */
197
+ export function storedToMergedPRs(stored) {
198
+ const results = [];
199
+ let skipped = 0;
200
+ for (const pr of stored) {
201
+ const parsed = parseGitHubUrl(pr.url);
202
+ if (!parsed) {
203
+ skipped++;
204
+ continue;
205
+ }
206
+ results.push({
207
+ url: pr.url,
208
+ repo: `${parsed.owner}/${parsed.repo}`,
209
+ number: parsed.number,
210
+ title: pr.title,
211
+ mergedAt: pr.mergedAt,
212
+ });
213
+ }
214
+ if (skipped > 0) {
215
+ warn(MODULE, `Skipped ${skipped} stored merged PR(s) with unparseable URLs`);
216
+ }
217
+ return results;
167
218
  }
168
219
  /**
169
220
  * Compute PRs grouped by repository from a digest and state.
@@ -12,7 +12,7 @@ import { getStateManager, getGitHubToken, getCLIVersion } from '../core/index.js
12
12
  import { errorMessage, ValidationError } from '../core/errors.js';
13
13
  import { warn } from '../core/logger.js';
14
14
  import { validateUrl, validateGitHubUrl, PR_URL_PATTERN } from './validation.js';
15
- import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, } from './dashboard-data.js';
15
+ import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, storedToMergedPRs, } from './dashboard-data.js';
16
16
  import { openInBrowser } from './startup.js';
17
17
  import { writeDashboardServerInfo, removeDashboardServerInfo } from './dashboard-process.js';
18
18
  import { RateLimiter } from './rate-limiter.js';
@@ -70,11 +70,13 @@ function applyStatusOverrides(prs, state) {
70
70
  /**
71
71
  * Build the JSON payload that the SPA expects from GET /api/data.
72
72
  */
73
- function buildDashboardJson(digest, state, commentedIssues) {
73
+ function buildDashboardJson(digest, state, commentedIssues, allMergedPRs) {
74
74
  const prsByRepo = computePRsByRepo(digest, state);
75
75
  const topRepos = computeTopRepos(prsByRepo);
76
76
  const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
77
- const stats = buildDashboardStats(digest, state);
77
+ // Derive allMergedPRs from state if not provided (e.g. initial load from cached state)
78
+ const mergedPRs = allMergedPRs ?? storedToMergedPRs(getStateManager().getMergedPRs());
79
+ const stats = buildDashboardStats(digest, state, mergedPRs.length);
78
80
  const issueResponses = commentedIssues.filter((i) => i.status === 'new_response');
79
81
  return {
80
82
  stats,
@@ -90,6 +92,7 @@ function buildDashboardJson(digest, state, commentedIssues) {
90
92
  autoUnshelvedPRs: digest.autoUnshelvedPRs || [],
91
93
  commentedIssues,
92
94
  issueResponses,
95
+ allMergedPRs: mergedPRs,
93
96
  };
94
97
  }
95
98
  /**
@@ -139,7 +142,7 @@ function isValidOrigin(req, port) {
139
142
  const origin = req.headers['origin'];
140
143
  if (!origin)
141
144
  return true; // No Origin header = same-origin request (non-browser or same-page)
142
- const allowed = [`http://localhost:${port}`, `http://127.0.0.1:${port}`];
145
+ const allowed = [`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://oss.localhost:${port}`];
143
146
  return allowed.includes(origin);
144
147
  }
145
148
  /**
@@ -329,7 +332,7 @@ export async function startDashboardServer(options) {
329
332
  const result = await fetchDashboardData(currentToken);
330
333
  cachedDigest = result.digest;
331
334
  cachedCommentedIssues = result.commentedIssues;
332
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
335
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs);
333
336
  sendJson(res, 200, cachedJsonData);
334
337
  }
335
338
  catch (error) {
@@ -433,7 +436,7 @@ export async function startDashboardServer(options) {
433
436
  version: getCLIVersion(),
434
437
  });
435
438
  const serverUrl = `http://localhost:${actualPort}`;
436
- warn(MODULE, `Dashboard server running at ${serverUrl}`);
439
+ warn(MODULE, `Dashboard server running at ${serverUrl} (also: http://oss.localhost:${actualPort})`);
437
440
  // ── Background refresh ─────────────────────────────────────────────────
438
441
  // Port is bound and PID file written — now fetch fresh data from GitHub
439
442
  // so subsequent /api/data requests get live data instead of cached state.
@@ -442,7 +445,7 @@ export async function startDashboardServer(options) {
442
445
  .then((result) => {
443
446
  cachedDigest = result.digest;
444
447
  cachedCommentedIssues = result.commentedIssues;
445
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
448
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs);
446
449
  warn(MODULE, 'Background data refresh complete');
447
450
  })
448
451
  .catch((error) => {
@@ -3,7 +3,7 @@
3
3
  * Extracted from PRMonitor to isolate statistics-gathering API calls (#263).
4
4
  */
5
5
  import { Octokit } from '@octokit/rest';
6
- import { ClosedPR, MergedPR, type StarFilter } from './types.js';
6
+ import { ClosedPR, MergedPR, StoredMergedPR, type StarFilter } from './types.js';
7
7
  /** TTL for cached PR count results (24 hours — these stats change slowly). */
8
8
  export declare const PR_COUNTS_CACHE_TTL_MS: number;
9
9
  /** Return type shared by both merged and closed PR count functions. */
@@ -42,3 +42,11 @@ export declare function fetchRecentlyClosedPRs(octokit: Octokit, config: {
42
42
  export declare function fetchRecentlyMergedPRs(octokit: Octokit, config: {
43
43
  githubUsername: string;
44
44
  }, days?: number): Promise<MergedPR[]>;
45
+ /**
46
+ * Fetch merged PRs since a watermark date for incremental storage.
47
+ * If no watermark is provided (first-ever fetch), fetches all merged PRs (up to pagination cap).
48
+ * Returns StoredMergedPR[] (minimal: url, title, mergedAt) for state persistence.
49
+ */
50
+ export declare function fetchMergedPRsSince(octokit: Octokit, config: {
51
+ githubUsername: string;
52
+ }, since?: string): Promise<StoredMergedPR[]>;
@@ -235,3 +235,55 @@ export async function fetchRecentlyMergedPRs(octokit, config, days = 7) {
235
235
  };
236
236
  });
237
237
  }
238
+ /**
239
+ * Fetch merged PRs since a watermark date for incremental storage.
240
+ * If no watermark is provided (first-ever fetch), fetches all merged PRs (up to pagination cap).
241
+ * Returns StoredMergedPR[] (minimal: url, title, mergedAt) for state persistence.
242
+ */
243
+ export async function fetchMergedPRsSince(octokit, config, since) {
244
+ if (!config.githubUsername) {
245
+ warn(MODULE, 'Skipping merged PRs fetch: no githubUsername configured.');
246
+ return [];
247
+ }
248
+ const dateFilter = since ? ` merged:>${since}` : '';
249
+ const q = `is:pr is:merged author:${config.githubUsername} -user:${config.githubUsername}${dateFilter}`;
250
+ debug(MODULE, `Fetching merged PRs${since ? ` since ${since}` : ' (all time)'}...`);
251
+ const results = [];
252
+ let page = 1;
253
+ let fetched = 0;
254
+ while (true) {
255
+ const { data } = await octokit.search.issuesAndPullRequests({
256
+ q,
257
+ sort: 'updated',
258
+ order: 'desc',
259
+ per_page: 100,
260
+ page,
261
+ });
262
+ for (const item of data.items) {
263
+ const parsed = parseGitHubUrl(item.html_url);
264
+ if (!parsed) {
265
+ warn(MODULE, `Skipping merged PR with unparseable URL: ${item.html_url}`);
266
+ continue;
267
+ }
268
+ if (isOwnRepo(parsed.owner, config.githubUsername))
269
+ continue;
270
+ const mergedAt = item.pull_request?.merged_at || item.closed_at || '';
271
+ if (!mergedAt) {
272
+ warn(MODULE, `Skipping merged PR with no merge date: ${item.html_url}`);
273
+ continue;
274
+ }
275
+ results.push({
276
+ url: item.html_url,
277
+ title: item.title,
278
+ mergedAt,
279
+ });
280
+ }
281
+ fetched += data.items.length;
282
+ if (fetched >= data.total_count || fetched >= 1000 || data.items.length === 0 || page >= MAX_PAGINATION_PAGES) {
283
+ break;
284
+ }
285
+ page++;
286
+ }
287
+ debug(MODULE, `Fetched ${results.length} merged PRs${since ? ' (incremental)' : ' (initial)'}`);
288
+ return results;
289
+ }
@@ -2,7 +2,7 @@
2
2
  * State management for the OSS Contribution Agent
3
3
  * Persists state to a JSON file in ~/.oss-autopilot/
4
4
  */
5
- import { AgentState, TrackedIssue, RepoScore, RepoScoreUpdate, StateEvent, StateEventType, DailyDigest, LocalRepoCache, SnoozeInfo, StatusOverride, FetchedPRStatus } from './types.js';
5
+ import { AgentState, TrackedIssue, RepoScore, RepoScoreUpdate, StateEvent, StateEventType, DailyDigest, LocalRepoCache, SnoozeInfo, StatusOverride, FetchedPRStatus, StoredMergedPR } from './types.js';
6
6
  /**
7
7
  * Acquire an advisory file lock using exclusive-create (`wx` flag).
8
8
  * If the lock file already exists but is stale (older than LOCK_TIMEOUT_MS or corrupt),
@@ -113,6 +113,22 @@ export declare class StateManager {
113
113
  */
114
114
  setMonthlyOpenedCounts(counts: Record<string, number>): void;
115
115
  setDailyActivityCounts(counts: Record<string, number>): void;
116
+ /**
117
+ * Get all stored merged PRs.
118
+ * @returns Array of stored merged PRs, sorted by mergedAt desc.
119
+ */
120
+ getMergedPRs(): StoredMergedPR[];
121
+ /**
122
+ * Add new merged PRs to the stored list. Deduplicates by URL and sorts by mergedAt desc.
123
+ * @param prs - New merged PRs to add.
124
+ */
125
+ addMergedPRs(prs: StoredMergedPR[]): void;
126
+ /**
127
+ * Get the most recent mergedAt timestamp from stored merged PRs.
128
+ * Used as the watermark for incremental fetching.
129
+ * @returns ISO date string of the most recent merge, or undefined if no stored PRs.
130
+ */
131
+ getMergedPRWatermark(): string | undefined;
116
132
  /**
117
133
  * Store cached local repo scan results (#84).
118
134
  * @param cache - The scan results, paths scanned, and timestamp.
@@ -419,6 +419,10 @@ export class StateManager {
419
419
  if (s.events === undefined) {
420
420
  s.events = [];
421
421
  }
422
+ // Migrate older states that don't have mergedPRs
423
+ if (s.mergedPRs === undefined) {
424
+ s.mergedPRs = [];
425
+ }
422
426
  // Base requirements for all versions
423
427
  const hasBaseFields = typeof s.version === 'number' &&
424
428
  typeof s.repoScores === 'object' &&
@@ -536,6 +540,47 @@ export class StateManager {
536
540
  setDailyActivityCounts(counts) {
537
541
  this.state.dailyActivityCounts = counts;
538
542
  }
543
+ // === Merged PR Storage ===
544
+ /**
545
+ * Get all stored merged PRs.
546
+ * @returns Array of stored merged PRs, sorted by mergedAt desc.
547
+ */
548
+ getMergedPRs() {
549
+ return this.state.mergedPRs ?? [];
550
+ }
551
+ /**
552
+ * Add new merged PRs to the stored list. Deduplicates by URL and sorts by mergedAt desc.
553
+ * @param prs - New merged PRs to add.
554
+ */
555
+ addMergedPRs(prs) {
556
+ if (prs.length === 0)
557
+ return;
558
+ if (!this.state.mergedPRs) {
559
+ this.state.mergedPRs = [];
560
+ }
561
+ const existingUrls = new Set(this.state.mergedPRs.map((pr) => pr.url));
562
+ const newPRs = prs.filter((pr) => !existingUrls.has(pr.url));
563
+ if (newPRs.length === 0)
564
+ return;
565
+ this.state.mergedPRs.push(...newPRs);
566
+ this.state.mergedPRs.sort((a, b) => b.mergedAt.localeCompare(a.mergedAt));
567
+ debug(MODULE, `Added ${newPRs.length} merged PRs (total: ${this.state.mergedPRs.length})`);
568
+ }
569
+ /**
570
+ * Get the most recent mergedAt timestamp from stored merged PRs.
571
+ * Used as the watermark for incremental fetching.
572
+ * @returns ISO date string of the most recent merge, or undefined if no stored PRs.
573
+ */
574
+ getMergedPRWatermark() {
575
+ const prs = this.state.mergedPRs;
576
+ if (!prs || prs.length === 0)
577
+ return undefined;
578
+ // List is sorted desc by mergedAt, so first element is most recent
579
+ const watermark = prs[0].mergedAt;
580
+ if (!watermark)
581
+ return undefined;
582
+ return watermark;
583
+ }
539
584
  /**
540
585
  * Store cached local repo scan results (#84).
541
586
  * @param cache - The scan results, paths scanned, and timestamp.
@@ -307,6 +307,12 @@ export interface ClosedPR {
307
307
  closedAt: string;
308
308
  closedBy?: string;
309
309
  }
310
+ /** Minimal merged PR data persisted in state.json. Repo/number derived from URL at display time. */
311
+ export interface StoredMergedPR {
312
+ url: string;
313
+ title: string;
314
+ mergedAt: string;
315
+ }
310
316
  /** Minimal record of a PR that was merged, used in the daily digest. */
311
317
  export interface MergedPR {
312
318
  url: string;
@@ -383,6 +389,8 @@ export interface AgentState {
383
389
  dailyActivityCounts?: Record<string, number>;
384
390
  /** Cached local repo scan results (#84). Avoids re-scanning the filesystem every session. */
385
391
  localRepoCache?: LocalRepoCache;
392
+ /** All merged PRs stored incrementally. Source of truth for the merged PR detail view. */
393
+ mergedPRs?: StoredMergedPR[];
386
394
  activeIssues: TrackedIssue[];
387
395
  }
388
396
  /** Cached results from scanning the filesystem for local git clones (#84). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "0.46.2",
3
+ "version": "0.47.1",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {