@oss-autopilot/core 0.53.1 → 0.54.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.
@@ -190,29 +190,31 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
190
190
  if (signalUpdateFailures === repoSignals.size && repoSignals.size > 0) {
191
191
  warn(MODULE, `[ALL_SIGNAL_UPDATES_FAILED] All ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`);
192
192
  }
193
- // Fetch star counts for all scored repos (used by dashboard minStars filter, #216)
193
+ // Fetch metadata (stars + language) for all scored repos (used by dashboard minStars filter and merged PR view, #216, #677)
194
194
  const allRepos = Object.keys(stateManager.getState().repoScores);
195
- let starCounts;
195
+ let repoMetadata;
196
196
  try {
197
- starCounts = await prMonitor.fetchRepoStarCounts(allRepos);
197
+ repoMetadata = await prMonitor.fetchRepoMetadata(allRepos);
198
198
  }
199
199
  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();
200
+ if (isRateLimitOrAuthError(error))
201
+ throw error;
202
+ warn(MODULE, `Failed to fetch repo metadata: ${errorMessage(error)}`);
203
+ warn(MODULE, 'Repos without cached metadata will be excluded from dashboard stats and metadata badges until fetched on the next successful run.');
204
+ repoMetadata = new Map();
203
205
  }
204
- let starUpdateFailures = 0;
205
- for (const [repo, stars] of starCounts) {
206
+ let metadataUpdateFailures = 0;
207
+ for (const [repo, { stars, language }] of repoMetadata) {
206
208
  try {
207
- stateManager.updateRepoScore(repo, { stargazersCount: stars });
209
+ stateManager.updateRepoScore(repo, { stargazersCount: stars, language });
208
210
  }
209
211
  catch (error) {
210
- starUpdateFailures++;
211
- warn(MODULE, `Failed to update star count for ${repo}: ${errorMessage(error)}`);
212
+ metadataUpdateFailures++;
213
+ warn(MODULE, `Failed to update metadata for ${repo}: ${errorMessage(error)}`);
212
214
  }
213
215
  }
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
+ if (metadataUpdateFailures === repoMetadata.size && repoMetadata.size > 0) {
217
+ warn(MODULE, `[ALL_METADATA_UPDATES_FAILED] All ${repoMetadata.size} metadata update(s) failed.`);
216
218
  }
217
219
  // Auto-sync trustedProjects from repos with merged PRs
218
220
  let trustSyncFailures = 0;
@@ -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
  /**
@@ -15,6 +15,7 @@ const FORK_LIMITATION_PATTERNS = [
15
15
  /chromatic/i,
16
16
  /percy/i,
17
17
  /cloudflare pages/i,
18
+ /\binternal\b/i,
18
19
  ];
19
20
  /**
20
21
  * Known CI check name patterns that indicate authorization gates (#81).
@@ -30,6 +31,7 @@ const INFRASTRUCTURE_PATTERNS = [
30
31
  /\bsetup\s+fail(ed|ure)?\b/i,
31
32
  /\bservice\s*unavailable/i,
32
33
  /\binfrastructure/i,
34
+ /\bblacksmith\b/i,
33
35
  ];
34
36
  /**
35
37
  * Classify a failing CI check as actionable, fork_limitation, auth_gate, or infrastructure (#81, #145).
@@ -84,13 +84,33 @@ const WAIT_DISPLAY = {
84
84
  return 'CI checks are failing but no action is needed from you';
85
85
  },
86
86
  },
87
+ stale_ci_failure: {
88
+ label: '[Stale CI Failure]',
89
+ description: (pr) => `CI failing for ${pr.daysSinceActivity}+ days — likely pre-existing or non-actionable`,
90
+ },
87
91
  };
92
+ /** Convert a bracketed display label like "[CI Failing]" to a plain lowercase string like "ci failing". */
93
+ function labelToPlainText(reason) {
94
+ const label = ACTION_DISPLAY[reason]?.label;
95
+ if (!label)
96
+ return reason;
97
+ return label.replace(/[[\]]/g, '').toLowerCase();
98
+ }
88
99
  /** Compute display label and description for a FetchedPR (#79). */
89
100
  export function computeDisplayLabel(pr) {
90
101
  if (pr.status === 'needs_addressing' && pr.actionReason) {
91
102
  const entry = ACTION_DISPLAY[pr.actionReason];
92
- if (entry)
93
- return { displayLabel: entry.label, displayDescription: entry.description(pr) };
103
+ if (entry) {
104
+ let displayDescription = entry.description(pr);
105
+ // Append secondary action reasons when multiple issues exist (#675)
106
+ if (pr.actionReasons && pr.actionReasons.length > 1) {
107
+ const secondary = pr.actionReasons.filter((r) => r !== pr.actionReason).map(labelToPlainText);
108
+ if (secondary.length > 0) {
109
+ displayDescription += ` (also: ${secondary.join(', ')})`;
110
+ }
111
+ }
112
+ return { displayLabel: entry.label, displayDescription };
113
+ }
94
114
  }
95
115
  if (pr.status === 'waiting_on_maintainer' && pr.waitReason) {
96
116
  const entry = WAIT_DISPLAY[pr.waitReason];
@@ -1,5 +1,5 @@
1
1
  /**
2
- * GitHub Stats - Fetching merged/closed PR counts and repository star counts.
2
+ * GitHub Stats - Fetching merged/closed PR counts with star-based filtering.
3
3
  * Extracted from PRMonitor to isolate statistics-gathering API calls (#263).
4
4
  */
5
5
  import { Octokit } from '@octokit/rest';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * GitHub Stats - Fetching merged/closed PR counts and repository star counts.
2
+ * GitHub Stats - Fetching merged/closed PR counts with star-based filtering.
3
3
  * Extracted from PRMonitor to isolate statistics-gathering API calls (#263).
4
4
  */
5
5
  import { extractOwnerRepo, parseGitHubUrl, isOwnRepo } from './utils.js';
@@ -9,7 +9,7 @@
9
9
  * - checklist-analysis.ts: PR body checklist analysis
10
10
  * - maintainer-analysis.ts: Maintainer action hint extraction
11
11
  * - display-utils.ts: Display label computation
12
- * - github-stats.ts: Merged/closed PR counts and star fetching
12
+ * - github-stats.ts: Merged/closed PR counts and star-based filtering
13
13
  * - status-determination.ts: PR status classification logic
14
14
  */
15
15
  import { FetchedPR, DailyDigest, ClosedPR, MergedPR, StarFilter } from './types.js';
@@ -68,10 +68,13 @@ export declare class PRMonitor {
68
68
  */
69
69
  fetchUserClosedPRCounts(starFilter?: StarFilter): Promise<PRCountsResult<number>>;
70
70
  /**
71
- * Fetch GitHub star counts for a list of repositories.
72
- * Delegates to github-stats module.
71
+ * Fetch metadata (star count and primary language) for a list of repositories.
72
+ * Both fields come from the same `repos.get()` call — zero additional API cost.
73
73
  */
74
- fetchRepoStarCounts(repos: string[]): Promise<Map<string, number>>;
74
+ fetchRepoMetadata(repos: string[]): Promise<Map<string, {
75
+ stars: number;
76
+ language: string | null;
77
+ }>>;
75
78
  /**
76
79
  * Fetch PRs closed without merge in the last N days.
77
80
  * Delegates to github-stats module.
@@ -9,7 +9,7 @@
9
9
  * - checklist-analysis.ts: PR body checklist analysis
10
10
  * - maintainer-analysis.ts: Maintainer action hint extraction
11
11
  * - display-utils.ts: Display label computation
12
- * - github-stats.ts: Merged/closed PR counts and star fetching
12
+ * - github-stats.ts: Merged/closed PR counts and star-based filtering
13
13
  * - status-determination.ts: PR status classification logic
14
14
  */
15
15
  import { getOctokit } from './github.js';
@@ -220,7 +220,7 @@ export class PRMonitor {
220
220
  const classifiedChecks = classifyFailingChecks(failingCheckNames, failingCheckConclusions);
221
221
  // Determine status
222
222
  const hasActionableCIFailure = ciStatus === 'failing' && classifiedChecks.some((c) => c.category === 'actionable');
223
- const { status, actionReason, waitReason, stalenessTier } = determineStatus({
223
+ const { status, actionReason, waitReason, stalenessTier, actionReasons } = determineStatus({
224
224
  ciStatus,
225
225
  hasMergeConflict,
226
226
  hasUnrespondedComment,
@@ -246,6 +246,7 @@ export class PRMonitor {
246
246
  actionReason,
247
247
  waitReason,
248
248
  stalenessTier,
249
+ actionReasons,
249
250
  createdAt: ghPR.created_at,
250
251
  updatedAt: ghPR.updated_at,
251
252
  daysSinceActivity,
@@ -367,13 +368,13 @@ export class PRMonitor {
367
368
  return fetchUserClosedPRCountsImpl(this.octokit, config.githubUsername, starFilter);
368
369
  }
369
370
  /**
370
- * Fetch GitHub star counts for a list of repositories.
371
- * Delegates to github-stats module.
371
+ * Fetch metadata (star count and primary language) for a list of repositories.
372
+ * Both fields come from the same `repos.get()` call — zero additional API cost.
372
373
  */
373
- async fetchRepoStarCounts(repos) {
374
+ async fetchRepoMetadata(repos) {
374
375
  if (repos.length === 0)
375
376
  return new Map();
376
- debug(MODULE, `Fetching star counts for ${repos.length} repos...`);
377
+ debug(MODULE, `Fetching repo metadata for ${repos.length} repos...`);
377
378
  const results = new Map();
378
379
  const cache = getHttpCache();
379
380
  // Deduplicate repos to avoid fetching the same repo twice
@@ -394,17 +395,18 @@ export class PRMonitor {
394
395
  repo: name,
395
396
  headers,
396
397
  }));
397
- return { repo, stars: data.stargazers_count };
398
+ const metadata = { stars: data.stargazers_count, language: data.language ?? null };
399
+ return { repo, metadata };
398
400
  }));
399
401
  let chunkFailures = 0;
400
402
  for (let j = 0; j < settled.length; j++) {
401
403
  const result = settled[j];
402
404
  if (result.status === 'fulfilled') {
403
- results.set(result.value.repo, result.value.stars);
405
+ results.set(result.value.repo, result.value.metadata);
404
406
  }
405
407
  else {
406
408
  chunkFailures++;
407
- warn(MODULE, `Failed to fetch stars for ${chunk[j]}: ${errorMessage(result.reason)}`);
409
+ warn(MODULE, `Failed to fetch metadata for ${chunk[j]}: ${errorMessage(result.reason)}`);
408
410
  }
409
411
  }
410
412
  // If entire chunk failed, likely a systemic issue (rate limit, auth, outage) — abort remaining
@@ -416,7 +418,7 @@ export class PRMonitor {
416
418
  break;
417
419
  }
418
420
  }
419
- debug(MODULE, `Fetched star counts for ${results.size}/${repos.length} repos`);
421
+ debug(MODULE, `Fetched repo metadata for ${results.size}/${repos.length} repos`);
420
422
  return results;
421
423
  }
422
424
  /**
@@ -94,6 +94,8 @@ export function updateRepoScore(state, repo, updates) {
94
94
  repoScore.lastMergedAt = updates.lastMergedAt;
95
95
  if (updates.stargazersCount !== undefined)
96
96
  repoScore.stargazersCount = updates.stargazersCount;
97
+ if (updates.language !== undefined)
98
+ repoScore.language = updates.language;
97
99
  if (updates.signals) {
98
100
  repoScore.signals = { ...repoScore.signals, ...updates.signals };
99
101
  }
@@ -6,6 +6,8 @@
6
6
  * its CI, review, merge-conflict, and timeline signals.
7
7
  */
8
8
  import type { DetermineStatusInput, DetermineStatusResult } from './types.js';
9
+ /** Days of inactivity after which an actionable CI failure is demoted to stale_ci_failure (#675). */
10
+ export declare const STALE_CI_DEMOTION_DAYS = 5;
9
11
  /**
10
12
  * CI-fix bots that push commits as a direct result of the contributor's push (#568).
11
13
  * Their commits represent contributor work and should count as addressing feedback.
@@ -5,6 +5,8 @@
5
5
  * granular action/wait reasons, and staleness tier for a single PR based on
6
6
  * its CI, review, merge-conflict, and timeline signals.
7
7
  */
8
+ /** Days of inactivity after which an actionable CI failure is demoted to stale_ci_failure (#675). */
9
+ export const STALE_CI_DEMOTION_DAYS = 5;
8
10
  /**
9
11
  * CI-fix bots that push commits as a direct result of the contributor's push (#568).
10
12
  * Their commits represent contributor work and should count as addressing feedback.
@@ -43,35 +45,89 @@ export function isCommitAfterComment(commitDate, commentDate) {
43
45
  }
44
46
  return commitMs - commentMs >= MIN_RESPONSE_GAP_MS;
45
47
  }
48
+ /**
49
+ * Resolve the latest commit date, filtering out non-contributor commits (#547, #568).
50
+ * Returns undefined when the commit was by a non-contributor or when no date is available.
51
+ */
52
+ function resolveContributorCommitDate(input) {
53
+ const { latestCommitDate, latestCommitAuthor, contributorUsername } = input;
54
+ if (!latestCommitDate)
55
+ return undefined;
56
+ return isContributorCommit(latestCommitAuthor, contributorUsername) ? latestCommitDate : undefined;
57
+ }
58
+ /** Check whether an unresponded comment has been addressed by a subsequent contributor commit. */
59
+ function isCommentAddressedByCommit(commitDate, commentDate, changesRequestedDate) {
60
+ if (!commitDate || !commentDate)
61
+ return false;
62
+ if (!isCommitAfterComment(commitDate, commentDate))
63
+ return false;
64
+ // Safety net (#431): if a CHANGES_REQUESTED review came after the commit, it's not addressed
65
+ if (changesRequestedDate && commitDate < changesRequestedDate)
66
+ return false;
67
+ return true;
68
+ }
69
+ /** Check whether a changes_requested review has been addressed by a subsequent contributor commit. */
70
+ function isChangesAddressedByCommit(commitDate, changesRequestedDate) {
71
+ if (!commitDate || !changesRequestedDate)
72
+ return false;
73
+ return commitDate >= changesRequestedDate;
74
+ }
75
+ /**
76
+ * Collect all applicable action reasons independently, without short-circuiting (#675).
77
+ * Used alongside the priority-based decision tree to surface secondary issues.
78
+ */
79
+ function collectAllActionReasons(input) {
80
+ const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, lastMaintainerCommentDate, latestChangesRequestedDate, hasActionableCIFailure = true, } = input;
81
+ const commitDate = resolveContributorCommitDate(input);
82
+ const reasons = [];
83
+ if (hasUnrespondedComment &&
84
+ !isCommentAddressedByCommit(commitDate, lastMaintainerCommentDate, latestChangesRequestedDate)) {
85
+ reasons.push('needs_response');
86
+ }
87
+ if (reviewDecision === 'changes_requested' &&
88
+ latestChangesRequestedDate &&
89
+ !isChangesAddressedByCommit(commitDate, latestChangesRequestedDate)) {
90
+ reasons.push('needs_changes');
91
+ }
92
+ if (ciStatus === 'failing' && hasActionableCIFailure) {
93
+ reasons.push('failing_ci');
94
+ }
95
+ if (hasMergeConflict) {
96
+ reasons.push('merge_conflict');
97
+ }
98
+ if (hasIncompleteChecklist) {
99
+ reasons.push('incomplete_checklist');
100
+ }
101
+ return reasons.length > 0 ? reasons : undefined;
102
+ }
46
103
  /**
47
104
  * Determine the overall status of a PR based on its signals.
48
105
  */
49
106
  export function determineStatus(input) {
50
- const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, latestCommitDate: rawCommitDate, latestCommitAuthor, contributorUsername, lastMaintainerCommentDate, latestChangesRequestedDate, hasActionableCIFailure = true, } = input;
107
+ const primary = determinePrimaryStatus(input);
108
+ const actionReasons = collectAllActionReasons(input);
109
+ if (actionReasons) {
110
+ return { ...primary, actionReasons };
111
+ }
112
+ return primary;
113
+ }
114
+ /**
115
+ * Priority-based decision tree for the primary status classification.
116
+ * Returns the single highest-priority status; `determineStatus` augments
117
+ * this with the full `actionReasons` array.
118
+ */
119
+ function determinePrimaryStatus(input) {
120
+ const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, lastMaintainerCommentDate, latestChangesRequestedDate, hasActionableCIFailure = true, } = input;
51
121
  // Compute staleness tier (independent of status)
52
122
  let stalenessTier = 'active';
53
123
  if (daysSinceActivity >= dormantThreshold)
54
124
  stalenessTier = 'dormant';
55
125
  else if (daysSinceActivity >= approachingThreshold)
56
126
  stalenessTier = 'approaching_dormant';
57
- // Only count the latest commit if it was authored by the contributor or a
58
- // CI bot (#547, #568). Non-contributor commits (maintainer merge commits,
59
- // GitHub suggestion commits) should not mask unaddressed feedback.
60
- const latestCommitDate = rawCommitDate && isContributorCommit(latestCommitAuthor, contributorUsername) ? rawCommitDate : undefined;
127
+ const commitDate = resolveContributorCommitDate(input);
61
128
  // Priority order: needs_addressing (response/changes/ci/conflict/checklist) > waiting_on_maintainer (review/merge/addressed/ci_blocked)
62
129
  if (hasUnrespondedComment) {
63
- // If the contributor pushed a commit after the maintainer's comment,
64
- // the changes have been addressed — waiting for maintainer re-review.
65
- // Require a minimum 2-minute gap to avoid false positives from race
66
- // conditions (pushing while review is being submitted) (#547).
67
- if (latestCommitDate &&
68
- lastMaintainerCommentDate &&
69
- isCommitAfterComment(latestCommitDate, lastMaintainerCommentDate)) {
70
- // Safety net (#431): if a CHANGES_REQUESTED review was submitted after
71
- // the commit, the maintainer still expects changes — don't mask it
72
- if (latestChangesRequestedDate && latestCommitDate < latestChangesRequestedDate) {
73
- return { status: 'needs_addressing', actionReason: 'needs_response', stalenessTier };
74
- }
130
+ if (isCommentAddressedByCommit(commitDate, lastMaintainerCommentDate, latestChangesRequestedDate)) {
75
131
  if (ciStatus === 'failing' && hasActionableCIFailure)
76
132
  return { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier };
77
133
  // Non-actionable CI failures (infrastructure, fork, auth) don't block changes_addressed —
@@ -81,9 +137,8 @@ export function determineStatus(input) {
81
137
  return { status: 'needs_addressing', actionReason: 'needs_response', stalenessTier };
82
138
  }
83
139
  // Review requested changes but no unresponded comment.
84
- // If the latest commit is before the review, the contributor hasn't addressed it yet.
85
140
  if (reviewDecision === 'changes_requested' && latestChangesRequestedDate) {
86
- if (!latestCommitDate || latestCommitDate < latestChangesRequestedDate) {
141
+ if (!isChangesAddressedByCommit(commitDate, latestChangesRequestedDate)) {
87
142
  return { status: 'needs_addressing', actionReason: 'needs_changes', stalenessTier };
88
143
  }
89
144
  // Commit is after review — changes have been addressed
@@ -93,9 +148,14 @@ export function determineStatus(input) {
93
148
  return { status: 'waiting_on_maintainer', waitReason: 'changes_addressed', stalenessTier };
94
149
  }
95
150
  if (ciStatus === 'failing') {
96
- return hasActionableCIFailure
97
- ? { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier }
98
- : { status: 'waiting_on_maintainer', waitReason: 'ci_blocked', stalenessTier };
151
+ if (hasActionableCIFailure) {
152
+ // Demote stale CI failures: if failing for 5+ days with no activity, likely pre-existing (#675)
153
+ if (daysSinceActivity >= STALE_CI_DEMOTION_DAYS) {
154
+ return { status: 'waiting_on_maintainer', waitReason: 'stale_ci_failure', stalenessTier };
155
+ }
156
+ return { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier };
157
+ }
158
+ return { status: 'waiting_on_maintainer', waitReason: 'ci_blocked', stalenessTier };
99
159
  }
100
160
  if (hasMergeConflict) {
101
161
  return { status: 'needs_addressing', actionReason: 'merge_conflict', stalenessTier };
@@ -67,6 +67,8 @@ export interface DetermineStatusResult {
67
67
  actionReason?: ActionReason;
68
68
  waitReason?: WaitReason;
69
69
  stalenessTier: StalenessTier;
70
+ /** All applicable action reasons, ordered by priority. */
71
+ actionReasons?: ActionReason[];
70
72
  }
71
73
  /**
72
74
  * Granular reason why a PR needs addressing (contributor's turn).
@@ -77,7 +79,7 @@ export interface DetermineStatusResult {
77
79
  */
78
80
  export type ActionReason = 'needs_response' | 'needs_changes' | 'failing_ci' | 'merge_conflict' | 'incomplete_checklist' | 'ci_not_running' | 'needs_rebase' | 'missing_required_files';
79
81
  /** Granular reason why a PR is waiting on the maintainer. */
80
- export type WaitReason = 'pending_review' | 'pending_merge' | 'changes_addressed' | 'ci_blocked';
82
+ export type WaitReason = 'pending_review' | 'pending_merge' | 'changes_addressed' | 'ci_blocked' | 'stale_ci_failure';
81
83
  /** How stale is the PR based on days since activity. Orthogonal to status. */
82
84
  export type StalenessTier = 'active' | 'approaching_dormant' | 'dormant';
83
85
  /**
@@ -110,6 +112,8 @@ export interface FetchedPR {
110
112
  actionReason?: ActionReason;
111
113
  /** Granular reason for waiting_on_maintainer status. Undefined when needs_addressing. */
112
114
  waitReason?: WaitReason;
115
+ /** All applicable action reasons, ordered by priority. Primary reason is first. */
116
+ actionReasons?: ActionReason[];
113
117
  /** How stale the PR is based on activity age. Independent of status — a PR can be both needs_addressing and dormant. */
114
118
  stalenessTier: StalenessTier;
115
119
  /** Human-readable status label for consistent display (#79). E.g., "[CI Failing]", "[Needs Response]". */
@@ -264,6 +268,8 @@ export interface RepoScore {
264
268
  signals: RepoSignals;
265
269
  /** GitHub star count, fetched during daily check for dashboard filtering. */
266
270
  stargazersCount?: number;
271
+ /** Primary programming language of the repo, fetched during daily check. */
272
+ language?: string | null;
267
273
  }
268
274
  /** Full set of qualitative signals about a repo's maintainer culture. */
269
275
  export interface RepoSignals {
@@ -285,6 +291,14 @@ export interface RepoScoreUpdate {
285
291
  lastMergedAt?: string;
286
292
  signals?: Partial<RepoSignals>;
287
293
  stargazersCount?: number;
294
+ /** Primary programming language of the repo. */
295
+ language?: string | null;
296
+ }
297
+ /** Repo metadata entry used in dashboard API responses. Shared between server and SPA. */
298
+ export interface RepoMetadataEntry {
299
+ /** Star count, derived from RepoScore.stargazersCount. */
300
+ stars?: number;
301
+ language?: string | null;
288
302
  }
289
303
  /**
290
304
  * Event types recorded in the {@link AgentState} audit log.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "0.53.1",
3
+ "version": "0.54.0",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {