@oss-autopilot/core 0.51.0 → 0.52.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.
@@ -165,7 +165,7 @@ export async function startDashboardServer(options) {
165
165
  // ── Rate limiters ───────────────────────────────────────────────────────
166
166
  const dataLimiter = new RateLimiter({ maxRequests: 30, windowMs: 60_000 }); // 30/min
167
167
  const actionLimiter = new RateLimiter({ maxRequests: 10, windowMs: 60_000 }); // 10/min
168
- const refreshLimiter = new RateLimiter({ maxRequests: 2, windowMs: 60_000 }); // 2/min
168
+ const refreshLimiter = new RateLimiter({ maxRequests: 6, windowMs: 60_000 }); // 6/min
169
169
  // ── Request handler ──────────────────────────────────────────────────────
170
170
  const server = http.createServer(async (req, res) => {
171
171
  const method = req.method || 'GET';
@@ -179,6 +179,16 @@ export async function startDashboardServer(options) {
179
179
  sendError(res, 429, 'Too many requests');
180
180
  return;
181
181
  }
182
+ // Re-read state.json if CLI commands modified it externally
183
+ if (stateManager.reloadIfChanged()) {
184
+ try {
185
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
186
+ }
187
+ catch (error) {
188
+ warn(MODULE, `Failed to rebuild dashboard data after state reload: ${errorMessage(error)}`);
189
+ // Intentional: serve previous cachedJsonData rather than returning 500
190
+ }
191
+ }
182
192
  sendJson(res, 200, cachedJsonData);
183
193
  return;
184
194
  }
@@ -227,6 +237,8 @@ export async function startDashboardServer(options) {
227
237
  server.requestTimeout = REQUEST_TIMEOUT_MS;
228
238
  // ── POST /api/action handler ─────────────────────────────────────────────
229
239
  async function handleAction(req, res) {
240
+ // Reload state before mutating to avoid overwriting external CLI changes
241
+ stateManager.reloadIfChanged();
230
242
  let body;
231
243
  try {
232
244
  const raw = await readBody(req);
@@ -293,6 +305,7 @@ export async function startDashboardServer(options) {
293
305
  return;
294
306
  }
295
307
  try {
308
+ stateManager.reloadIfChanged();
296
309
  warn(MODULE, 'Refreshing dashboard data from GitHub...');
297
310
  const result = await fetchDashboardData(currentToken);
298
311
  cachedDigest = result.digest;
@@ -408,6 +421,7 @@ export async function startDashboardServer(options) {
408
421
  if (token) {
409
422
  fetchDashboardData(token)
410
423
  .then((result) => {
424
+ stateManager.reloadIfChanged();
411
425
  cachedDigest = result.digest;
412
426
  cachedCommentedIssues = result.commentedIssues;
413
427
  cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs);
@@ -2,7 +2,7 @@
2
2
  * Core module exports
3
3
  * Re-exports all core functionality for convenient imports
4
4
  */
5
- export { StateManager, getStateManager, resetStateManager } from './state.js';
5
+ export { StateManager, getStateManager, resetStateManager, type Stats } from './state.js';
6
6
  export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
7
7
  export { IssueDiscovery, type IssueCandidate, type SearchPriority, isDocOnlyIssue, applyPerRepoCap, DOC_ONLY_LABELS, } from './issue-discovery.js';
8
8
  export { IssueConversationMonitor } from './issue-conversation.js';
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Repository scoring logic for the OSS Contribution Agent.
3
+ * Functions that operate on AgentState for scoring, querying,
4
+ * and computing aggregate statistics. Mutation functions modify
5
+ * the passed state object in place; query functions are pure.
6
+ */
7
+ import { AgentState, RepoScore, RepoScoreUpdate } from './types.js';
8
+ /**
9
+ * Calculate the score based on the repo's metrics.
10
+ * Base 5, logarithmic merge bonus (max +5), -1 per closed without merge (max -3),
11
+ * +1 if recently merged (within 90 days), +1 if responsive, -2 if hostile. Clamp 1-10.
12
+ */
13
+ export declare function calculateScore(repoScore: RepoScore): number;
14
+ /**
15
+ * Get the score record for a repository.
16
+ */
17
+ export declare function getRepoScore(state: Readonly<AgentState>, repo: string): Readonly<RepoScore> | undefined;
18
+ /**
19
+ * Update a repository's score with partial updates. If the repo has no existing score,
20
+ * a default score record is created first (base score 5). After applying updates, the
21
+ * numeric score is recalculated.
22
+ */
23
+ export declare function updateRepoScore(state: AgentState, repo: string, updates: RepoScoreUpdate): void;
24
+ /**
25
+ * Increment the merged PR count for a repository and recalculate its score.
26
+ */
27
+ export declare function incrementMergedCount(state: AgentState, repo: string): void;
28
+ /**
29
+ * Increment the closed-without-merge count for a repository and recalculate its score.
30
+ */
31
+ export declare function incrementClosedCount(state: AgentState, repo: string): void;
32
+ /**
33
+ * Mark a repository as having hostile maintainer comments and recalculate its score.
34
+ * This applies a -2 penalty to the score. Creates a default score record if needed.
35
+ */
36
+ export declare function markRepoHostile(state: AgentState, repo: string): void;
37
+ /**
38
+ * Get repositories where the user has at least one merged PR, sorted by merged count descending.
39
+ */
40
+ export declare function getReposWithMergedPRs(state: Readonly<AgentState>): string[];
41
+ /**
42
+ * Get repositories with a score record but no merge or closure outcomes yet
43
+ * (typically repos with only open PRs), sorted by score descending.
44
+ */
45
+ export declare function getReposWithOpenPRs(state: Readonly<AgentState>): string[];
46
+ /**
47
+ * Get repositories with a score at or above the given threshold, sorted highest first.
48
+ */
49
+ export declare function getHighScoringRepos(state: Readonly<AgentState>, minScore?: number): string[];
50
+ /**
51
+ * Get repositories with a score at or below the given threshold, sorted lowest first.
52
+ */
53
+ export declare function getLowScoringRepos(state: Readonly<AgentState>, maxScore?: number): string[];
54
+ /**
55
+ * Aggregate statistics returned by {@link getStats}.
56
+ */
57
+ export interface Stats {
58
+ /** Total merged PRs across scored repositories (above minStars threshold). */
59
+ mergedPRs: number;
60
+ /** Total PRs closed without merge across scored repositories (above minStars threshold). */
61
+ closedPRs: number;
62
+ /** Number of active issues. Always 0 in v2 (sourced from fresh fetch instead). */
63
+ activeIssues: number;
64
+ /** Number of trusted projects. */
65
+ trustedProjects: number;
66
+ /** Merge success rate as a percentage string (e.g. "75.0%"). */
67
+ mergeRate: string;
68
+ /** Number of scored repositories (above minStars threshold). */
69
+ totalTracked: number;
70
+ /** Number of PRs needing a response. Always 0 in v2 (sourced from fresh fetch instead). */
71
+ needsResponse: number;
72
+ }
73
+ /**
74
+ * Compute aggregate statistics from the current state.
75
+ */
76
+ export declare function getStats(state: Readonly<AgentState>): Stats;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Repository scoring logic for the OSS Contribution Agent.
3
+ * Functions that operate on AgentState for scoring, querying,
4
+ * and computing aggregate statistics. Mutation functions modify
5
+ * the passed state object in place; query functions are pure.
6
+ */
7
+ import { isBelowMinStars } from './types.js';
8
+ import { debug, warn } from './logger.js';
9
+ const MODULE = 'scoring';
10
+ /** Repo scores older than this are considered stale and excluded from low-scoring lists. */
11
+ const SCORE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
12
+ /**
13
+ * Create a default repo score for a new repository.
14
+ */
15
+ function createDefaultRepoScore(repo) {
16
+ return {
17
+ repo,
18
+ score: 5, // Base score
19
+ mergedPRCount: 0,
20
+ closedWithoutMergeCount: 0,
21
+ avgResponseDays: null,
22
+ lastEvaluatedAt: new Date().toISOString(),
23
+ signals: {
24
+ hasActiveMaintainers: true, // Assume positive by default
25
+ isResponsive: false,
26
+ hasHostileComments: false,
27
+ },
28
+ };
29
+ }
30
+ /**
31
+ * Calculate the score based on the repo's metrics.
32
+ * Base 5, logarithmic merge bonus (max +5), -1 per closed without merge (max -3),
33
+ * +1 if recently merged (within 90 days), +1 if responsive, -2 if hostile. Clamp 1-10.
34
+ */
35
+ export function calculateScore(repoScore) {
36
+ let score = 5; // Base score
37
+ // Logarithmic merge bonus (max +5): 1→+2, 2→+3, 3→+4, 4+→+5
38
+ if (repoScore.mergedPRCount > 0) {
39
+ const mergedBonus = Math.min(Math.round(Math.log2(repoScore.mergedPRCount + 1) * 2), 5);
40
+ score += mergedBonus;
41
+ }
42
+ // -1 per closed without merge (max -3)
43
+ const closedPenalty = Math.min(repoScore.closedWithoutMergeCount, 3);
44
+ score -= closedPenalty;
45
+ // +1 if lastMergedAt is set and within 90 days (recency)
46
+ if (repoScore.lastMergedAt) {
47
+ const lastMergedDate = new Date(repoScore.lastMergedAt);
48
+ if (isNaN(lastMergedDate.getTime())) {
49
+ warn(MODULE, `Invalid lastMergedAt date for ${repoScore.repo}: "${repoScore.lastMergedAt}". Skipping recency bonus.`);
50
+ }
51
+ else {
52
+ const msPerDay = 1000 * 60 * 60 * 24;
53
+ const daysSince = Math.floor((Date.now() - lastMergedDate.getTime()) / msPerDay);
54
+ if (daysSince <= 90) {
55
+ score += 1;
56
+ }
57
+ }
58
+ }
59
+ // +1 if responsive
60
+ if (repoScore.signals.isResponsive) {
61
+ score += 1;
62
+ }
63
+ // -2 if hostile
64
+ if (repoScore.signals.hasHostileComments) {
65
+ score -= 2;
66
+ }
67
+ // Clamp to 1-10
68
+ return Math.max(1, Math.min(10, score));
69
+ }
70
+ /**
71
+ * Get the score record for a repository.
72
+ */
73
+ export function getRepoScore(state, repo) {
74
+ return state.repoScores[repo];
75
+ }
76
+ /**
77
+ * Update a repository's score with partial updates. If the repo has no existing score,
78
+ * a default score record is created first (base score 5). After applying updates, the
79
+ * numeric score is recalculated.
80
+ */
81
+ export function updateRepoScore(state, repo, updates) {
82
+ if (!state.repoScores[repo]) {
83
+ state.repoScores[repo] = createDefaultRepoScore(repo);
84
+ }
85
+ const repoScore = state.repoScores[repo];
86
+ // Apply explicit field updates (skip undefined values to preserve existing data)
87
+ if (updates.mergedPRCount !== undefined)
88
+ repoScore.mergedPRCount = updates.mergedPRCount;
89
+ if (updates.closedWithoutMergeCount !== undefined)
90
+ repoScore.closedWithoutMergeCount = updates.closedWithoutMergeCount;
91
+ if (updates.avgResponseDays !== undefined)
92
+ repoScore.avgResponseDays = updates.avgResponseDays;
93
+ if (updates.lastMergedAt !== undefined)
94
+ repoScore.lastMergedAt = updates.lastMergedAt;
95
+ if (updates.stargazersCount !== undefined)
96
+ repoScore.stargazersCount = updates.stargazersCount;
97
+ if (updates.signals) {
98
+ repoScore.signals = { ...repoScore.signals, ...updates.signals };
99
+ }
100
+ // Recalculate score
101
+ repoScore.score = calculateScore(repoScore);
102
+ repoScore.lastEvaluatedAt = new Date().toISOString();
103
+ debug(MODULE, `Updated repo score for ${repo}: ${repoScore.score}/10`);
104
+ }
105
+ /**
106
+ * Increment the merged PR count for a repository and recalculate its score.
107
+ */
108
+ export function incrementMergedCount(state, repo) {
109
+ const newCount = (state.repoScores[repo]?.mergedPRCount ?? 0) + 1;
110
+ updateRepoScore(state, repo, {
111
+ mergedPRCount: newCount,
112
+ lastMergedAt: new Date().toISOString(),
113
+ });
114
+ }
115
+ /**
116
+ * Increment the closed-without-merge count for a repository and recalculate its score.
117
+ */
118
+ export function incrementClosedCount(state, repo) {
119
+ const newCount = (state.repoScores[repo]?.closedWithoutMergeCount ?? 0) + 1;
120
+ updateRepoScore(state, repo, { closedWithoutMergeCount: newCount });
121
+ }
122
+ /**
123
+ * Mark a repository as having hostile maintainer comments and recalculate its score.
124
+ * This applies a -2 penalty to the score. Creates a default score record if needed.
125
+ */
126
+ export function markRepoHostile(state, repo) {
127
+ updateRepoScore(state, repo, { signals: { hasHostileComments: true } });
128
+ }
129
+ /**
130
+ * Get repositories where the user has at least one merged PR, sorted by merged count descending.
131
+ */
132
+ export function getReposWithMergedPRs(state) {
133
+ return Object.values(state.repoScores)
134
+ .filter((rs) => rs.mergedPRCount > 0)
135
+ .sort((a, b) => b.mergedPRCount - a.mergedPRCount)
136
+ .map((rs) => rs.repo);
137
+ }
138
+ /**
139
+ * Get repositories with a score record but no merge or closure outcomes yet
140
+ * (typically repos with only open PRs), sorted by score descending.
141
+ */
142
+ export function getReposWithOpenPRs(state) {
143
+ return Object.values(state.repoScores)
144
+ .filter((rs) => rs.mergedPRCount === 0 && rs.closedWithoutMergeCount === 0)
145
+ .sort((a, b) => b.score - a.score)
146
+ .map((rs) => rs.repo);
147
+ }
148
+ /**
149
+ * Get repositories with a score at or above the given threshold, sorted highest first.
150
+ */
151
+ export function getHighScoringRepos(state, minScore) {
152
+ const threshold = minScore ?? state.config.minRepoScoreThreshold;
153
+ return Object.values(state.repoScores)
154
+ .filter((rs) => rs.score >= threshold)
155
+ .sort((a, b) => b.score - a.score)
156
+ .map((rs) => rs.repo);
157
+ }
158
+ /**
159
+ * Get repositories with a score at or below the given threshold, sorted lowest first.
160
+ */
161
+ export function getLowScoringRepos(state, maxScore) {
162
+ const threshold = maxScore ?? state.config.minRepoScoreThreshold;
163
+ const now = Date.now();
164
+ return Object.values(state.repoScores)
165
+ .filter((rs) => {
166
+ if (rs.score > threshold)
167
+ return false;
168
+ // Stale scores (>30 days) should not permanently block repos (#487)
169
+ const age = now - new Date(rs.lastEvaluatedAt).getTime();
170
+ if (!Number.isFinite(age)) {
171
+ warn(MODULE, `Invalid lastEvaluatedAt for repo ${rs.repo}: "${rs.lastEvaluatedAt}", treating as stale`);
172
+ return false;
173
+ }
174
+ return age <= SCORE_TTL_MS;
175
+ })
176
+ .sort((a, b) => a.score - b.score)
177
+ .map((rs) => rs.repo);
178
+ }
179
+ /**
180
+ * Compute aggregate statistics from the current state.
181
+ */
182
+ export function getStats(state) {
183
+ let totalMerged = 0;
184
+ let totalClosed = 0;
185
+ let totalTracked = 0;
186
+ for (const score of Object.values(state.repoScores)) {
187
+ if (isBelowMinStars(score.stargazersCount, state.config.minStars ?? 50))
188
+ continue;
189
+ totalTracked++;
190
+ totalMerged += score.mergedPRCount;
191
+ totalClosed += score.closedWithoutMergeCount;
192
+ }
193
+ const completed = totalMerged + totalClosed;
194
+ const mergeRate = completed > 0 ? (totalMerged / completed) * 100 : 0;
195
+ return {
196
+ mergedPRs: totalMerged,
197
+ closedPRs: totalClosed,
198
+ activeIssues: 0,
199
+ trustedProjects: state.config.trustedProjects.length,
200
+ mergeRate: mergeRate.toFixed(1) + '%',
201
+ totalTracked,
202
+ needsResponse: 0,
203
+ };
204
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * State persistence layer for the OSS Contribution Agent.
3
+ * Handles file I/O, locking, backup/restore, and v1-to-v2 migration.
4
+ * No module-level mutable state — functions accept/return AgentState objects.
5
+ */
6
+ import { AgentState } from './types.js';
7
+ /**
8
+ * Acquire an advisory file lock using exclusive-create (`wx` flag).
9
+ * If the lock file already exists but is stale (older than LOCK_TIMEOUT_MS or corrupt),
10
+ * it is removed and re-acquired.
11
+ * @throws Error if the lock is held by another active process.
12
+ */
13
+ export declare function acquireLock(lockPath: string): void;
14
+ /**
15
+ * Release an advisory file lock, but only if this process owns it.
16
+ * Silently ignores missing lock files or locks owned by other processes.
17
+ */
18
+ export declare function releaseLock(lockPath: string): void;
19
+ /**
20
+ * Write data to `filePath` atomically by first writing to a temporary file
21
+ * in the same directory and then renaming. Rename is atomic on POSIX filesystems,
22
+ * preventing partial/corrupt state files if the process crashes mid-write.
23
+ */
24
+ export declare function atomicWriteFileSync(filePath: string, data: string, mode?: number): void;
25
+ /**
26
+ * Create a fresh state (v2: fresh GitHub fetching).
27
+ */
28
+ export declare function createFreshState(): AgentState;
29
+ /**
30
+ * Load state from file, or create initial state if none exists.
31
+ * If the main state file is corrupted, attempts to restore from the most recent backup.
32
+ * Performs migration from legacy ./data/ location if needed.
33
+ * @returns Object with the loaded state and the file's mtime (for change detection).
34
+ */
35
+ export declare function loadState(): {
36
+ state: AgentState;
37
+ mtimeMs: number;
38
+ };
39
+ /**
40
+ * Persist state to disk, creating a timestamped backup of the previous
41
+ * state file first. Retains at most 10 backup files.
42
+ * @returns The file's mtime after writing (for change detection).
43
+ */
44
+ export declare function saveState(state: Readonly<AgentState>): number;
45
+ /**
46
+ * Re-read state from disk if the file has been modified since the last load/save.
47
+ * Uses mtime comparison (single statSync call) to avoid unnecessary JSON parsing.
48
+ * @returns The new state and mtime if reloaded, or null if no change detected.
49
+ */
50
+ export declare function reloadStateIfChanged(lastLoadedMtimeMs: number): {
51
+ state: AgentState;
52
+ mtimeMs: number;
53
+ } | null;