@oss-autopilot/core 0.48.0 → 0.50.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.
@@ -8,7 +8,7 @@
8
8
  */
9
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
- export { computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
11
+ export { applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
12
12
  /**
13
13
  * Build a star filter from state for use in fetchUserPRCounts.
14
14
  * Returns undefined if no star data is available (first run).
@@ -6,7 +6,7 @@
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 { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } from '../core/index.js';
9
+ import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } from '../core/index.js';
10
10
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
11
11
  import { warn } from '../core/logger.js';
12
12
  import { emptyPRCountsResult } from '../core/github-stats.js';
@@ -15,7 +15,7 @@ import { deduplicateDigest, compactActionableIssues, compactRepoGroups, } from '
15
15
  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
- export { computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
18
+ export { applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
19
19
  /**
20
20
  * Build a star filter from state for use in fetchUserPRCounts.
21
21
  * Returns undefined if no star data is available (first run).
@@ -249,11 +249,15 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs) {
249
249
  catch (error) {
250
250
  warn(MODULE, `Failed to expire/persist snoozes: ${errorMessage(error)}`);
251
251
  }
252
+ // Apply dashboard/CLI status overrides before partitioning.
253
+ // This ensures PRs reclassified in the dashboard (e.g., "Need Attention" → "Waiting")
254
+ // are respected by the CLI pipeline.
255
+ const overriddenPRs = applyStatusOverrides(prs, stateManager.getState());
252
256
  // Partition PRs into active vs shelved, auto-unshelving when maintainers engage
253
257
  const shelvedPRs = [];
254
258
  const autoUnshelvedPRs = [];
255
259
  const activePRs = [];
256
- for (const pr of prs) {
260
+ for (const pr of overriddenPRs) {
257
261
  if (stateManager.isPRShelved(pr.url)) {
258
262
  if (CRITICAL_STATUSES.has(pr.status)) {
259
263
  stateManager.unshelvePR(pr.url);
@@ -273,10 +277,10 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs) {
273
277
  activePRs.push(pr);
274
278
  }
275
279
  }
276
- // Generate digest from fresh data.
280
+ // Generate digest from override-applied PRs so status categories are correct.
277
281
  // Note: digest.openPRs contains ALL fetched PRs (including shelved).
278
282
  // We override summary fields below to reflect active-only counts.
279
- const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
283
+ const digest = prMonitor.generateDigest(overriddenPRs, recentlyClosedPRs, recentlyMergedPRs);
280
284
  // Attach shelve info to digest
281
285
  digest.shelvedPRs = shelvedPRs;
282
286
  digest.autoUnshelvedPRs = autoUnshelvedPRs;
@@ -292,7 +296,7 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs) {
292
296
  * Assesses capacity, filters dismissed issues, computes actionable items,
293
297
  * and assembles the action menu.
294
298
  */
295
- function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures) {
299
+ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, previousLastDigestAt) {
296
300
  const stateManager = getStateManager();
297
301
  // Assess capacity from active PRs only (shelved PRs excluded)
298
302
  const capacity = assessCapacity(activePRs, stateManager.getState().config.maxActivePRs, shelvedPRs.length);
@@ -357,7 +361,7 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
357
361
  warn(MODULE, `Failed to persist auto-undismissed state: ${errorMessage(error)}`);
358
362
  }
359
363
  }
360
- const actionableIssues = collectActionableIssues(nonDismissedPRs, snoozedUrls);
364
+ const actionableIssues = collectActionableIssues(nonDismissedPRs, snoozedUrls, previousLastDigestAt);
361
365
  digest.summary.totalNeedingAttention = actionableIssues.length;
362
366
  const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length);
363
367
  const actionMenu = computeActionMenu(actionableIssues, capacity, filteredCommentedIssues);
@@ -425,10 +429,13 @@ async function executeDailyCheckInternal(token) {
425
429
  await updateRepoScores(prMonitor, prs, mergedCounts, closedCounts);
426
430
  // Phase 3: Persist monthly analytics
427
431
  updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
432
+ // Capture lastDigestAt BEFORE Phase 4 overwrites it with the current run's timestamp.
433
+ // Used by collectActionableIssues to determine which PRs are "new" (created since last digest).
434
+ const previousLastDigestAt = getStateManager().getState().lastDigestAt;
428
435
  // Phase 4: Expire snoozes, partition PRs, generate and save digest
429
436
  const { activePRs, shelvedPRs, digest } = partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs);
430
437
  // Phase 5: Build structured output (capacity, dismiss filter, action menu)
431
- return generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures);
438
+ return generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, previousLastDigestAt);
432
439
  }
433
440
  /**
434
441
  * Run the daily check and return deduplicated DailyOutput.
@@ -8,10 +8,10 @@
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, getCLIVersion } from '../core/index.js';
11
+ import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides } 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, PR_URL_PATTERN } from './validation.js';
14
+ import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN } from './validation.js';
15
15
  import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
16
16
  import { openInBrowser } from './startup.js';
17
17
  import { writeDashboardServerInfo, removeDashboardServerInfo } from './dashboard-process.js';
@@ -20,7 +20,12 @@ import { isBelowMinStars, } from '../core/types.js';
20
20
  // Re-export process management functions for backward compatibility
21
21
  export { getDashboardPidPath, writeDashboardServerInfo, readDashboardServerInfo, removeDashboardServerInfo, isDashboardServerRunning, findRunningDashboardServer, } from './dashboard-process.js';
22
22
  // ── Constants ────────────────────────────────────────────────────────────────
23
- const VALID_ACTIONS = new Set(['shelve', 'unshelve', 'override_status']);
23
+ const VALID_ACTIONS = new Set([
24
+ 'shelve',
25
+ 'unshelve',
26
+ 'override_status',
27
+ 'dismiss_issue_response',
28
+ ]);
24
29
  const MODULE = 'dashboard-server';
25
30
  const MAX_BODY_BYTES = 10_240;
26
31
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -33,40 +38,6 @@ const MIME_TYPES = {
33
38
  '.png': 'image/png',
34
39
  '.ico': 'image/x-icon',
35
40
  };
36
- /**
37
- * Apply status overrides from state to the PR list.
38
- * Overrides are auto-cleared if the PR has new activity since the override was set.
39
- */
40
- function applyStatusOverrides(prs, state) {
41
- const overrides = state.config.statusOverrides;
42
- if (!overrides || Object.keys(overrides).length === 0)
43
- return prs;
44
- const stateManager = getStateManager();
45
- // Snapshot keys before iteration — clearStatusOverride mutates the same object
46
- const overrideUrls = new Set(Object.keys(overrides));
47
- let didAutoClear = false;
48
- const result = prs.map((pr) => {
49
- const override = stateManager.getStatusOverride(pr.url, pr.updatedAt);
50
- if (!override) {
51
- if (overrideUrls.has(pr.url))
52
- didAutoClear = true;
53
- return pr;
54
- }
55
- if (override.status === pr.status)
56
- return pr;
57
- return { ...pr, status: override.status };
58
- });
59
- // Persist any auto-cleared overrides so they don't resurrect on restart
60
- if (didAutoClear) {
61
- try {
62
- stateManager.save();
63
- }
64
- catch (err) {
65
- warn(MODULE, `Failed to persist auto-cleared overrides — they may reappear on restart: ${errorMessage(err)}`);
66
- }
67
- }
68
- return result;
69
- }
70
41
  // ── Helpers ────────────────────────────────────────────────────────────────────
71
42
  /**
72
43
  * Build the JSON payload that the SPA expects from GET /api/data.
@@ -85,7 +56,8 @@ function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClo
85
56
  const filteredMergedPRs = mergedPRs.filter(isAboveMinStars);
86
57
  const filteredClosedPRs = closedPRs.filter(isAboveMinStars);
87
58
  const stats = buildDashboardStats(digest, state, filteredMergedPRs.length, filteredClosedPRs.length);
88
- const issueResponses = commentedIssues.filter((i) => i.status === 'new_response');
59
+ const dismissedIssues = state.config.dismissedIssues || {};
60
+ const issueResponses = commentedIssues.filter((i) => i.status === 'new_response' && !(i.url in dismissedIssues));
89
61
  return {
90
62
  stats,
91
63
  prsByRepo,
@@ -278,10 +250,11 @@ export async function startDashboardServer(options) {
278
250
  sendError(res, 400, 'Missing or invalid "url" field');
279
251
  return;
280
252
  }
281
- // Validate URL format — all actions are PR-only now.
253
+ // Validate URL format — dismiss_issue_response accepts issue or PR URLs, others are PR-only.
254
+ const isDismiss = body.action === 'dismiss_issue_response';
282
255
  try {
283
256
  validateUrl(body.url);
284
- validateGitHubUrl(body.url, PR_URL_PATTERN, 'PR');
257
+ validateGitHubUrl(body.url, isDismiss ? ISSUE_OR_PR_URL_PATTERN : PR_URL_PATTERN, isDismiss ? 'issue or PR' : 'PR');
285
258
  }
286
259
  catch (err) {
287
260
  if (err instanceof ValidationError) {
@@ -317,6 +290,9 @@ export async function startDashboardServer(options) {
317
290
  stateManager.setStatusOverride(body.url, overrideStatus, lastActivityAt);
318
291
  break;
319
292
  }
293
+ case 'dismiss_issue_response':
294
+ stateManager.dismissIssue(body.url, new Date().toISOString());
295
+ break;
320
296
  }
321
297
  stateManager.save();
322
298
  }
@@ -2,6 +2,7 @@
2
2
  * Setup command
3
3
  * Interactive setup / configuration
4
4
  */
5
+ import { type ProjectCategory } from '../core/types.js';
5
6
  interface SetupOptions {
6
7
  reset?: boolean;
7
8
  set?: string[];
@@ -20,6 +21,8 @@ export interface SetupCompleteOutput {
20
21
  approachingDormantDays: number;
21
22
  languages: string[];
22
23
  labels: string[];
24
+ projectCategories: ProjectCategory[];
25
+ preferredOrgs: string[];
23
26
  };
24
27
  }
25
28
  export interface SetupPrompt {
@@ -5,6 +5,7 @@
5
5
  import { getStateManager, DEFAULT_CONFIG } from '../core/index.js';
6
6
  import { ValidationError } from '../core/errors.js';
7
7
  import { validateGitHubUsername } from './validation.js';
8
+ import { PROJECT_CATEGORIES } from '../core/types.js';
8
9
  /** Parse and validate a positive integer setting value. */
9
10
  function parsePositiveInt(value, settingName) {
10
11
  const parsed = Number(value);
@@ -111,6 +112,51 @@ export async function runSetup(options) {
111
112
  results[key] = valid.length > 0 ? valid.join(', ') : '(empty)';
112
113
  break;
113
114
  }
115
+ case 'projectCategories': {
116
+ const categories = value
117
+ .split(',')
118
+ .map((c) => c.trim())
119
+ .filter(Boolean);
120
+ const validCategories = [];
121
+ const invalidCategories = [];
122
+ for (const cat of categories) {
123
+ if (PROJECT_CATEGORIES.includes(cat)) {
124
+ validCategories.push(cat);
125
+ }
126
+ else {
127
+ invalidCategories.push(cat);
128
+ }
129
+ }
130
+ if (invalidCategories.length > 0) {
131
+ warnings.push(`Unknown project categories: ${invalidCategories.join(', ')}. Valid: ${PROJECT_CATEGORIES.join(', ')}`);
132
+ }
133
+ const dedupedCategories = [...new Set(validCategories)];
134
+ stateManager.updateConfig({ projectCategories: dedupedCategories });
135
+ results[key] = dedupedCategories.length > 0 ? dedupedCategories.join(', ') : '(empty)';
136
+ break;
137
+ }
138
+ case 'preferredOrgs': {
139
+ const orgs = value
140
+ .split(',')
141
+ .map((o) => o.trim())
142
+ .filter(Boolean);
143
+ const validOrgs = [];
144
+ for (const org of orgs) {
145
+ if (org.includes('/')) {
146
+ warnings.push(`"${org}" looks like a repo path. Use org name only (e.g., "vercel" not "vercel/next.js").`);
147
+ }
148
+ else if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(org)) {
149
+ warnings.push(`"${org}" is not a valid GitHub organization name. Skipping.`);
150
+ }
151
+ else {
152
+ validOrgs.push(org.toLowerCase());
153
+ }
154
+ }
155
+ const dedupedOrgs = [...new Set(validOrgs)];
156
+ stateManager.updateConfig({ preferredOrgs: dedupedOrgs });
157
+ results[key] = dedupedOrgs.length > 0 ? dedupedOrgs.join(', ') : '(empty)';
158
+ break;
159
+ }
114
160
  case 'complete':
115
161
  if (value === 'true') {
116
162
  stateManager.markSetupComplete();
@@ -135,6 +181,8 @@ export async function runSetup(options) {
135
181
  approachingDormantDays: config.approachingDormantDays,
136
182
  languages: config.languages,
137
183
  labels: config.labels,
184
+ projectCategories: config.projectCategories ?? [],
185
+ preferredOrgs: config.preferredOrgs ?? [],
138
186
  },
139
187
  };
140
188
  }
@@ -191,6 +239,20 @@ export async function runSetup(options) {
191
239
  default: ['matplotlib/matplotlib'],
192
240
  type: 'list',
193
241
  },
242
+ {
243
+ setting: 'projectCategories',
244
+ prompt: 'What types of projects interest you? (nonprofit, devtools, infrastructure, web-frameworks, data-ml, education)',
245
+ current: config.projectCategories ?? [],
246
+ default: [],
247
+ type: 'list',
248
+ },
249
+ {
250
+ setting: 'preferredOrgs',
251
+ prompt: 'Any GitHub organizations to prioritize? (org names, comma-separated)',
252
+ current: config.preferredOrgs ?? [],
253
+ default: [],
254
+ type: 'list',
255
+ },
194
256
  ],
195
257
  };
196
258
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Category Mapping — static mappings from project categories to GitHub topics and organizations.
3
+ *
4
+ * Used by issue discovery to prioritize repos matching user's category preferences.
5
+ */
6
+ import type { ProjectCategory } from './types.js';
7
+ /** GitHub topics associated with each project category, used for `topic:` search queries. */
8
+ export declare const CATEGORY_TOPICS: Record<ProjectCategory, string[]>;
9
+ /** Well-known GitHub organizations associated with each project category. */
10
+ export declare const CATEGORY_ORGS: Record<ProjectCategory, string[]>;
11
+ /**
12
+ * Check if a repo belongs to any of the given categories based on its owner matching a category org.
13
+ * Comparison is case-insensitive.
14
+ */
15
+ export declare function repoBelongsToCategory(repoFullName: string, categories: ProjectCategory[]): boolean;
16
+ /**
17
+ * Get deduplicated GitHub topics for the given categories, for use in `topic:` search queries.
18
+ */
19
+ export declare function getTopicsForCategories(categories: ProjectCategory[]): string[];
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Category Mapping — static mappings from project categories to GitHub topics and organizations.
3
+ *
4
+ * Used by issue discovery to prioritize repos matching user's category preferences.
5
+ */
6
+ /** GitHub topics associated with each project category, used for `topic:` search queries. */
7
+ export const CATEGORY_TOPICS = {
8
+ nonprofit: ['nonprofit', 'social-good', 'humanitarian', 'charity', 'social-impact', 'civic-tech'],
9
+ devtools: ['developer-tools', 'devtools', 'cli', 'sdk', 'linter', 'formatter', 'build-tool'],
10
+ infrastructure: ['infrastructure', 'cloud', 'kubernetes', 'docker', 'devops', 'monitoring', 'observability'],
11
+ 'web-frameworks': ['web-framework', 'frontend', 'backend', 'fullstack', 'nextjs', 'react', 'vue'],
12
+ 'data-ml': ['machine-learning', 'data-science', 'deep-learning', 'nlp', 'data-pipeline', 'analytics'],
13
+ education: ['education', 'learning', 'tutorial', 'courseware', 'edtech', 'teaching'],
14
+ };
15
+ /** Well-known GitHub organizations associated with each project category. */
16
+ export const CATEGORY_ORGS = {
17
+ nonprofit: ['code-for-america', 'opengovfoundation', 'ushahidi', 'hotosm', 'openfn', 'democracyearth'],
18
+ devtools: ['eslint', 'prettier', 'vitejs', 'biomejs', 'oxc-project', 'ast-grep', 'turbot'],
19
+ infrastructure: ['kubernetes', 'hashicorp', 'grafana', 'prometheus', 'open-telemetry', 'envoyproxy', 'cncf'],
20
+ 'web-frameworks': ['vercel', 'remix-run', 'sveltejs', 'nuxt', 'astro', 'redwoodjs', 'blitz-js'],
21
+ 'data-ml': ['huggingface', 'mlflow', 'apache', 'dbt-labs', 'dagster-io', 'prefecthq', 'langchain-ai'],
22
+ education: ['freeCodeCamp', 'TheOdinProject', 'exercism', 'codecademy', 'oppia', 'Khan'],
23
+ };
24
+ /**
25
+ * Check if a repo belongs to any of the given categories based on its owner matching a category org.
26
+ * Comparison is case-insensitive.
27
+ */
28
+ export function repoBelongsToCategory(repoFullName, categories) {
29
+ if (categories.length === 0)
30
+ return false;
31
+ const owner = repoFullName.split('/')[0]?.toLowerCase();
32
+ if (!owner)
33
+ return false;
34
+ for (const category of categories) {
35
+ const orgs = CATEGORY_ORGS[category];
36
+ if (!orgs)
37
+ continue; // Guard against invalid categories from hand-edited state.json
38
+ if (orgs.some((org) => org.toLowerCase() === owner)) {
39
+ return true;
40
+ }
41
+ }
42
+ return false;
43
+ }
44
+ /**
45
+ * Get deduplicated GitHub topics for the given categories, for use in `topic:` search queries.
46
+ */
47
+ export function getTopicsForCategories(categories) {
48
+ const topics = new Set();
49
+ for (const category of categories) {
50
+ const categoryTopics = CATEGORY_TOPICS[category];
51
+ if (!categoryTopics)
52
+ continue; // Guard against invalid categories from hand-edited state.json
53
+ for (const topic of categoryTopics) {
54
+ topics.add(topic);
55
+ }
56
+ }
57
+ return [...topics];
58
+ }
@@ -10,7 +10,7 @@
10
10
  * - formatActionHint — human-readable maintainer action hint label
11
11
  * - formatBriefSummary / formatSummary / printDigest — rendering
12
12
  */
13
- import type { FetchedPR, FetchedPRStatus, StalenessTier, ActionReason, DailyDigest, ShelvedPRRef, MaintainerActionHint, ComputedRepoSignals, RepoGroup, CommentedIssue, CommentedIssueWithResponse } from './types.js';
13
+ import type { FetchedPR, FetchedPRStatus, StalenessTier, ActionReason, DailyDigest, AgentState, ShelvedPRRef, MaintainerActionHint, ComputedRepoSignals, RepoGroup, CommentedIssue, CommentedIssueWithResponse } from './types.js';
14
14
  import type { CapacityAssessment, ActionableIssue, ActionMenu } from '../formatters/json.js';
15
15
  /**
16
16
  * Statuses indicating action needed from the contributor.
@@ -25,6 +25,7 @@ export declare const CRITICAL_STATUSES: ReadonlySet<FetchedPRStatus>;
25
25
  export declare const CRITICAL_ACTION_REASONS: ReadonlySet<ActionReason>;
26
26
  /** Staleness tiers indicating staleness — maintainer comments during these tiers don't count as responsive. */
27
27
  export declare const STALE_STATUSES: ReadonlySet<StalenessTier>;
28
+ export declare function applyStatusOverrides(prs: FetchedPR[], state: Readonly<AgentState>): FetchedPR[];
28
29
  /**
29
30
  * Map a full FetchedPR to a lightweight ShelvedPRRef for digest output.
30
31
  * Only the fields needed for display are retained, reducing JSON payload size.
@@ -53,7 +54,7 @@ export declare function assessCapacity(activePRs: FetchedPR[], maxActivePRs: num
53
54
  * Note: Recently closed PRs are informational only and excluded from this list.
54
55
  * They are available separately in digest.recentlyClosedPRs (#156).
55
56
  */
56
- export declare function collectActionableIssues(prs: FetchedPR[], snoozedUrls?: Set<string>): ActionableIssue[];
57
+ export declare function collectActionableIssues(prs: FetchedPR[], snoozedUrls?: Set<string>, lastDigestAt?: string): ActionableIssue[];
57
58
  /**
58
59
  * Format a maintainer action hint as a human-readable label
59
60
  */
@@ -12,6 +12,8 @@
12
12
  */
13
13
  import { formatRelativeTime } from './utils.js';
14
14
  import { warn } from './logger.js';
15
+ import { errorMessage } from './errors.js';
16
+ import { getStateManager } from './state.js';
15
17
  // ---------------------------------------------------------------------------
16
18
  // Constants
17
19
  // ---------------------------------------------------------------------------
@@ -34,6 +36,62 @@ export const CRITICAL_ACTION_REASONS = new Set([
34
36
  /** Staleness tiers indicating staleness — maintainer comments during these tiers don't count as responsive. */
35
37
  export const STALE_STATUSES = new Set(['dormant', 'approaching_dormant']);
36
38
  // ---------------------------------------------------------------------------
39
+ // Status overrides
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Apply status overrides from state to the PR list.
43
+ * Overrides are auto-cleared if the PR has new activity since the override was set.
44
+ *
45
+ * When an override changes the status, the contradictory reason field is cleared
46
+ * and an appropriate default is set so downstream logic (assessCapacity, collectActionableIssues)
47
+ * works correctly.
48
+ */
49
+ const VALID_OVERRIDE_STATUSES = new Set(['needs_addressing', 'waiting_on_maintainer']);
50
+ export function applyStatusOverrides(prs, state) {
51
+ const overrides = state.config.statusOverrides;
52
+ if (!overrides || Object.keys(overrides).length === 0)
53
+ return prs;
54
+ const stateManager = getStateManager();
55
+ // Snapshot keys before iteration — clearStatusOverride mutates the same object
56
+ const overrideUrls = new Set(Object.keys(overrides));
57
+ let didAutoClear = false;
58
+ const result = prs.map((pr) => {
59
+ try {
60
+ const override = stateManager.getStatusOverride(pr.url, pr.updatedAt);
61
+ if (!override) {
62
+ if (overrideUrls.has(pr.url))
63
+ didAutoClear = true;
64
+ return pr;
65
+ }
66
+ if (!VALID_OVERRIDE_STATUSES.has(override.status)) {
67
+ warn('daily-logic', `Invalid override status "${override.status}" for ${pr.url} — ignoring`);
68
+ return pr;
69
+ }
70
+ if (override.status === pr.status)
71
+ return pr;
72
+ // Clear the contradictory reason field and set an appropriate default
73
+ if (override.status === 'waiting_on_maintainer') {
74
+ return { ...pr, status: override.status, actionReason: undefined, waitReason: 'pending_review' };
75
+ }
76
+ return { ...pr, status: override.status, waitReason: undefined, actionReason: 'needs_response' };
77
+ }
78
+ catch (err) {
79
+ warn('daily-logic', `Failed to apply status override for ${pr.url}: ${errorMessage(err)}`);
80
+ return pr;
81
+ }
82
+ });
83
+ // Persist any auto-cleared overrides so they don't resurrect on restart
84
+ if (didAutoClear) {
85
+ try {
86
+ stateManager.save();
87
+ }
88
+ catch (err) {
89
+ warn('daily-logic', `Failed to persist auto-cleared overrides — they may reappear on restart: ${errorMessage(err)}`);
90
+ }
91
+ }
92
+ return result;
93
+ }
94
+ // ---------------------------------------------------------------------------
37
95
  // Internal helpers
38
96
  // ---------------------------------------------------------------------------
39
97
  /**
@@ -140,9 +198,10 @@ export function assessCapacity(activePRs, maxActivePRs, shelvedPRCount) {
140
198
  * Note: Recently closed PRs are informational only and excluded from this list.
141
199
  * They are available separately in digest.recentlyClosedPRs (#156).
142
200
  */
143
- export function collectActionableIssues(prs, snoozedUrls = new Set()) {
201
+ export function collectActionableIssues(prs, snoozedUrls = new Set(), lastDigestAt) {
144
202
  const issues = [];
145
203
  const actionPRs = prs.filter((pr) => pr.status === 'needs_addressing');
204
+ const lastDigestTime = lastDigestAt ? new Date(lastDigestAt).getTime() : NaN;
146
205
  const reasonOrder = [
147
206
  'needs_response',
148
207
  'needs_changes',
@@ -191,7 +250,18 @@ export function collectActionableIssues(prs, snoozedUrls = new Set()) {
191
250
  label = `[${reason}]`;
192
251
  type = 'needs_response';
193
252
  }
194
- issues.push({ type, pr, label });
253
+ // A PR is "new" if it was created after the last daily digest (first time seen).
254
+ // If there's no previous digest (first run) or createdAt is invalid, assume new.
255
+ const createdTime = new Date(pr.createdAt).getTime();
256
+ let isNewContribution;
257
+ if (isNaN(createdTime)) {
258
+ warn('daily-logic', `Invalid createdAt "${pr.createdAt}" for PR ${pr.url}, assuming new contribution`);
259
+ isNewContribution = true;
260
+ }
261
+ else {
262
+ isNewContribution = isNaN(lastDigestTime) || createdTime > lastDigestTime;
263
+ }
264
+ issues.push({ type, pr, label, isNewContribution });
195
265
  }
196
266
  }
197
267
  return issues;
@@ -224,10 +294,15 @@ export function computeActionMenu(actionableIssues, capacity, commentedIssues =
224
294
  const hasActionableIssues = actionableIssues.length > 0;
225
295
  const hasIssueResponses = issueResponses.length > 0;
226
296
  if (hasActionableIssues) {
297
+ const isSingle = actionableIssues.length === 1;
227
298
  items.push({
228
299
  key: 'address_all',
229
- label: `Work through all ${actionableIssues.length} issue${actionableIssues.length === 1 ? '' : 's'} (Recommended)`,
230
- description: 'Run maintenance in parallel, then address code changes one at a time',
300
+ label: isSingle
301
+ ? 'Address this issue (Recommended)'
302
+ : `Work through all ${actionableIssues.length} issues (Recommended)`,
303
+ description: isSingle
304
+ ? 'Fix the issue blocking your PR'
305
+ : 'Run maintenance in parallel, then address code changes one at a time',
231
306
  });
232
307
  }
233
308
  // Issue replies — positioned after address_all but before search
@@ -12,7 +12,7 @@ export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDa
12
12
  export { OssAutopilotError, ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, } from './errors.js';
13
13
  export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
14
14
  export { HttpCache, getHttpCache, cachedRequest, type CacheEntry } from './http-cache.js';
15
- export { CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
15
+ export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
16
16
  export { computeContributionStats, type ContributionStats, type ComputeStatsInput } from './stats.js';
17
17
  export { fetchPRTemplate, type PRTemplateResult } from './pr-template.js';
18
18
  export * from './types.js';
@@ -12,7 +12,7 @@ export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDa
12
12
  export { OssAutopilotError, ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, } from './errors.js';
13
13
  export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
14
14
  export { HttpCache, getHttpCache, cachedRequest } from './http-cache.js';
15
- export { CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
15
+ export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
16
16
  export { computeContributionStats } from './stats.js';
17
17
  export { fetchPRTemplate } from './pr-template.js';
18
18
  export * from './types.js';
@@ -18,6 +18,7 @@ import { getHttpCache, cachedTimeBased } from './http-cache.js';
18
18
  import { isDocOnlyIssue, detectLabelFarmingRepos, applyPerRepoCap } from './issue-filtering.js';
19
19
  import { IssueVetter } from './issue-vetting.js';
20
20
  import { calculateViabilityScore as calcViabilityScore } from './issue-scoring.js';
21
+ import { getTopicsForCategories } from './category-mapping.js';
21
22
  // Re-export everything from sub-modules for backward compatibility.
22
23
  // Existing consumers (tests, CLI commands) import from './issue-discovery.js'.
23
24
  export { isDocOnlyIssue, applyPerRepoCap, isLabelFarming, hasTemplatedTitle, detectLabelFarmingRepos, DOC_ONLY_LABELS, BEGINNER_LABELS, } from './issue-filtering.js';
@@ -282,6 +283,51 @@ export class IssueDiscovery {
282
283
  }
283
284
  }
284
285
  }
286
+ // Phase 0.5: Search preferred organizations (explicit user preference)
287
+ let phase0_5Error = null;
288
+ const preferredOrgs = config.preferredOrgs ?? [];
289
+ if (allCandidates.length < maxResults && preferredOrgs.length > 0) {
290
+ // Filter out orgs already covered by Phase 0 repos
291
+ const phase0Orgs = new Set(phase0Repos.map((r) => r.split('/')[0]?.toLowerCase()));
292
+ const orgsToSearch = preferredOrgs.filter((org) => !phase0Orgs.has(org.toLowerCase())).slice(0, 5);
293
+ if (orgsToSearch.length > 0) {
294
+ info(MODULE, `Phase 0.5: Searching issues in ${orgsToSearch.length} preferred org(s)...`);
295
+ const remainingNeeded = maxResults - allCandidates.length;
296
+ const orgRepoFilter = orgsToSearch.map((org) => `org:${org}`).join(' OR ');
297
+ const orgQuery = `${baseQuery} (${orgRepoFilter})`;
298
+ try {
299
+ const data = await this.cachedSearch({
300
+ q: orgQuery,
301
+ sort: 'created',
302
+ order: 'desc',
303
+ per_page: remainingNeeded * 3,
304
+ });
305
+ if (data.items.length > 0) {
306
+ const filtered = filterIssues(data.items).filter((item) => {
307
+ const repoFullName = item.repository_url.split('/').slice(-2).join('/');
308
+ return !phase0RepoSet.has(repoFullName);
309
+ });
310
+ const { candidates: orgCandidates, allFailed: allVetFailed, rateLimitHit, } = await this.vetter.vetIssuesParallel(filtered.slice(0, remainingNeeded * 2).map((i) => i.html_url), remainingNeeded, 'preferred_org');
311
+ allCandidates.push(...orgCandidates);
312
+ if (allVetFailed) {
313
+ phase0_5Error = 'All preferred org issue vetting failed';
314
+ }
315
+ if (rateLimitHit) {
316
+ rateLimitHitDuringSearch = true;
317
+ }
318
+ info(MODULE, `Found ${orgCandidates.length} candidates from preferred orgs`);
319
+ }
320
+ }
321
+ catch (error) {
322
+ const errMsg = errorMessage(error);
323
+ phase0_5Error = errMsg;
324
+ if (isRateLimitError(error)) {
325
+ rateLimitHitDuringSearch = true;
326
+ }
327
+ warn(MODULE, `Error searching preferred orgs: ${errMsg}`);
328
+ }
329
+ }
330
+ }
285
331
  // Phase 1: Search starred repos (filter out already-searched Phase 0 repos)
286
332
  if (allCandidates.length < maxResults && starredRepos.length > 0) {
287
333
  const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
@@ -345,7 +391,12 @@ export class IssueDiscovery {
345
391
  const thirtyDaysAgo = new Date();
346
392
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
347
393
  const pushedSince = thirtyDaysAgo.toISOString().split('T')[0];
348
- const phase3Query = `is:issue is:open no:assignee ${langQuery} stars:>=${minStars} pushed:>=${pushedSince} archived:false`
394
+ // When user has category preferences, add a single topic filter to focus on relevant repos.
395
+ // GitHub Search API AND-joins multiple topic: qualifiers, which is overly restrictive,
396
+ // so we pick just the first topic to nudge results without eliminating valid matches.
397
+ const categoryTopics = getTopicsForCategories(config.projectCategories ?? []);
398
+ const topicQuery = categoryTopics.length > 0 ? `topic:${categoryTopics[0]}` : '';
399
+ const phase3Query = `is:issue is:open no:assignee ${langQuery} ${topicQuery} stars:>=${minStars} pushed:>=${pushedSince} archived:false`
349
400
  .replace(/ +/g, ' ')
350
401
  .trim();
351
402
  try {
@@ -379,6 +430,7 @@ export class IssueDiscovery {
379
430
  if (allCandidates.length === 0) {
380
431
  const phaseErrors = [
381
432
  phase0Error ? `Phase 0 (merged-PR repos): ${phase0Error}` : null,
433
+ phase0_5Error ? `Phase 0.5 (preferred orgs): ${phase0_5Error}` : null,
382
434
  phase1Error ? `Phase 1 (starred repos): ${phase1Error}` : null,
383
435
  phase2Error ? `Phase 2 (general): ${phase2Error}` : null,
384
436
  phase3Error ? `Phase 3 (maintained repos): ${phase3Error}` : null,
@@ -406,8 +458,8 @@ export class IssueDiscovery {
406
458
  }
407
459
  // Sort by priority first, then by recommendation, then by viability score
408
460
  allCandidates.sort((a, b) => {
409
- // Priority order: merged_pr > starred > normal
410
- const priorityOrder = { merged_pr: 0, starred: 1, normal: 2 };
461
+ // Priority order: merged_pr > preferred_org > starred > normal
462
+ const priorityOrder = { merged_pr: 0, preferred_org: 1, starred: 2, normal: 3 };
411
463
  const priorityDiff = priorityOrder[a.searchPriority] - priorityOrder[b.searchPriority];
412
464
  if (priorityDiff !== 0)
413
465
  return priorityDiff;