@oss-autopilot/core 3.9.0 → 3.11.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.
Files changed (39) hide show
  1. package/dist/cli-registry.d.ts +7 -0
  2. package/dist/cli-registry.js +29 -5
  3. package/dist/cli.bundle.cjs +114 -114
  4. package/dist/cli.js +11 -3
  5. package/dist/commands/comments.js +31 -15
  6. package/dist/commands/compliance-score.js +12 -4
  7. package/dist/commands/daily.js +47 -2
  8. package/dist/commands/dashboard-data.d.ts +17 -0
  9. package/dist/commands/dashboard-data.js +49 -0
  10. package/dist/commands/dashboard-server.js +93 -24
  11. package/dist/commands/dismiss.d.ts +4 -0
  12. package/dist/commands/dismiss.js +4 -4
  13. package/dist/commands/guidelines.d.ts +19 -0
  14. package/dist/commands/guidelines.js +23 -4
  15. package/dist/commands/index.d.ts +3 -1
  16. package/dist/commands/index.js +2 -0
  17. package/dist/commands/move.d.ts +2 -0
  18. package/dist/commands/move.js +12 -8
  19. package/dist/commands/repo-vet.js +30 -8
  20. package/dist/commands/search.js +20 -2
  21. package/dist/commands/shelve.d.ts +4 -0
  22. package/dist/commands/shelve.js +4 -4
  23. package/dist/core/gist-state-store.js +42 -7
  24. package/dist/core/index.d.ts +1 -1
  25. package/dist/core/index.js +1 -1
  26. package/dist/core/issue-conversation.js +15 -2
  27. package/dist/core/paths.d.ts +12 -0
  28. package/dist/core/paths.js +16 -0
  29. package/dist/core/pr-comments-fetcher.d.ts +10 -2
  30. package/dist/core/pr-comments-fetcher.js +22 -4
  31. package/dist/core/state-persistence.d.ts +31 -9
  32. package/dist/core/state-persistence.js +51 -16
  33. package/dist/core/state.d.ts +18 -1
  34. package/dist/core/state.js +35 -3
  35. package/dist/core/untrusted-content.d.ts +24 -3
  36. package/dist/core/untrusted-content.js +31 -3
  37. package/dist/formatters/json.d.ts +15 -1
  38. package/dist/formatters/json.js +20 -0
  39. package/package.json +7 -7
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Guidelines CLI commands (#867 PR 4).
3
3
  *
4
+ * `guidelines list` — list repos with stored guidelines.
4
5
  * `guidelines view` — read the per-repo guidelines file from the Gist.
5
6
  * `guidelines store` — overwrite the per-repo guidelines file.
6
7
  * `guidelines reset` — tombstone the file so subsequent reads return null.
@@ -41,6 +42,20 @@ export async function runGuidelinesView(options) {
41
42
  storageMode: sm.isGuidelinesAvailable() ? 'gist' : 'local-unavailable',
42
43
  };
43
44
  }
45
+ /**
46
+ * List every repo with non-empty stored guidelines. Never throws in local
47
+ * mode — returns an empty list with `storageMode: 'local-unavailable'` so
48
+ * hosts can distinguish "nothing stored" from "storage not configured".
49
+ */
50
+ export async function runGuidelinesList() {
51
+ const sm = getStateManager();
52
+ const repos = sm.listGuidelinesRepos();
53
+ return {
54
+ repos,
55
+ count: repos.length,
56
+ storageMode: sm.isGuidelinesAvailable() ? 'gist' : 'local-unavailable',
57
+ };
58
+ }
44
59
  /**
45
60
  * Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
46
61
  * when content exceeds the byte budget — the CLI surface relies on the
@@ -57,11 +72,12 @@ export async function runGuidelinesStore(options) {
57
72
  // Push to Gist — autoSave only writes the local state-cache mirror in Gist
58
73
  // mode, so without this checkpoint the change never propagates across
59
74
  // machines (#1200).
60
- await maybeCheckpoint(sm, MODULE);
75
+ const gistSyncWarning = await maybeCheckpoint(sm, MODULE);
61
76
  return {
62
77
  repo: options.repo,
63
78
  byteSize: Buffer.byteLength(options.content, 'utf8'),
64
79
  stored: true,
80
+ ...(gistSyncWarning ? { gistSyncWarning } : {}),
65
81
  };
66
82
  }
67
83
  /** Tombstone the guidelines file for `repo`. */
@@ -72,12 +88,13 @@ export async function runGuidelinesReset(options) {
72
88
  throw new GuidelinesNotAvailableError();
73
89
  }
74
90
  const existed = sm.getGuidelines(options.repo) !== null;
91
+ let gistSyncWarning = null;
75
92
  if (existed) {
76
93
  sm.deleteGuidelines(options.repo);
77
94
  // Push to Gist — see runGuidelinesStore note (#1200).
78
- await maybeCheckpoint(sm, MODULE);
95
+ gistSyncWarning = await maybeCheckpoint(sm, MODULE);
79
96
  }
80
- return { repo: options.repo, deleted: existed };
97
+ return { repo: options.repo, deleted: existed, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
81
98
  }
82
99
  /**
83
100
  * Fetch raw PR comment bundles for the most recent merged/closed PRs in `repo`
@@ -146,8 +163,9 @@ export async function runFetchCorpus(options) {
146
163
  // Push commentsFetchedAt stamps to Gist so other machines don't re-fetch
147
164
  // the same PRs forever. autoSave only writes the local mirror in Gist
148
165
  // mode (#1200).
166
+ let gistSyncWarning = null;
149
167
  if (bundles.length > 0) {
150
- await maybeCheckpoint(sm, MODULE);
168
+ gistSyncWarning = await maybeCheckpoint(sm, MODULE);
151
169
  }
152
170
  return {
153
171
  repo: options.repo,
@@ -155,6 +173,7 @@ export async function runFetchCorpus(options) {
155
173
  prCount: bundles.length,
156
174
  skipped,
157
175
  failures,
176
+ ...(gistSyncWarning ? { gistSyncWarning } : {}),
158
177
  };
159
178
  }
160
179
  function clampLimit(limit) {
@@ -61,6 +61,8 @@ export { runInit } from './init.js';
61
61
  export { runSetup } from './setup.js';
62
62
  /** Check whether setup has been completed. */
63
63
  export { runCheckSetup } from './setup.js';
64
+ /** List repos with stored guidelines (empty in local mode). */
65
+ export { runGuidelinesList } from './guidelines.js';
64
66
  /** Read the guidelines file for a repo. */
65
67
  export { runGuidelinesView } from './guidelines.js';
66
68
  /** Persist a guidelines file for a repo (overwrites on subsequent calls). */
@@ -96,7 +98,7 @@ export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput, VetListOutput,
96
98
  export type { ConfigOutput, DetectFormattersOutput, ParseIssueListOutput, ParsedIssueItem, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
97
99
  export type { ShelveOutput, UnshelveOutput } from './shelve.js';
98
100
  export type { MoveOutput, MoveTarget } from './move.js';
99
- export type { GuidelinesViewOutput, GuidelinesStoreOutput, GuidelinesResetOutput, FetchCorpusOutput, } from './guidelines.js';
101
+ export type { GuidelinesListOutput, GuidelinesViewOutput, GuidelinesStoreOutput, GuidelinesResetOutput, FetchCorpusOutput, } from './guidelines.js';
100
102
  export type { DismissOutput, UndismissOutput } from './dismiss.js';
101
103
  export type { InitOutput } from './init.js';
102
104
  export type { ConfigSetOutput, ConfigCommandOutput } from './config.js';
@@ -66,6 +66,8 @@ export { runSetup } from './setup.js';
66
66
  /** Check whether setup has been completed. */
67
67
  export { runCheckSetup } from './setup.js';
68
68
  // ── Per-Repo Guidelines (#867) ──────────────────────────────────────────────
69
+ /** List repos with stored guidelines (empty in local mode). */
70
+ export { runGuidelinesList } from './guidelines.js';
69
71
  /** Read the guidelines file for a repo. */
70
72
  export { runGuidelinesView } from './guidelines.js';
71
73
  /** Persist a guidelines file for a repo (overwrites on subsequent calls). */
@@ -9,6 +9,8 @@ export interface MoveOutput {
9
9
  target: MoveTarget;
10
10
  /** Human-readable description of what happened. */
11
11
  description: string;
12
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
13
+ gistSyncWarning?: string;
12
14
  }
13
15
  /**
14
16
  * Move a PR between states: attention, waiting, shelved, or auto (computed).
@@ -7,6 +7,10 @@ import { ValidationError } from '../core/errors.js';
7
7
  import { PR_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
8
8
  const MODULE = 'move';
9
9
  export const VALID_TARGETS = ['attention', 'waiting', 'shelved', 'auto'];
10
+ /** Attach the checkpoint warning only when present, keeping the key off the wire on clean runs (#1370). */
11
+ function withGistSyncWarning(output, gistSyncWarning) {
12
+ return gistSyncWarning ? { ...output, gistSyncWarning } : output;
13
+ }
10
14
  /**
11
15
  * Move a PR between states: attention, waiting, shelved, or auto (computed).
12
16
  *
@@ -36,32 +40,32 @@ export async function runMove(options) {
36
40
  stateManager.setStatusOverride(options.prUrl, status, lastActivityAt);
37
41
  stateManager.unshelvePR(options.prUrl);
38
42
  });
39
- await maybeCheckpoint(stateManager, MODULE);
40
- return { url: options.prUrl, target, description: `Moved to ${label}` };
43
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
44
+ return withGistSyncWarning({ url: options.prUrl, target, description: `Moved to ${label}` }, gistSyncWarning);
41
45
  }
42
46
  case 'shelved': {
43
47
  stateManager.batch(() => {
44
48
  stateManager.shelvePR(options.prUrl);
45
49
  stateManager.clearStatusOverride(options.prUrl);
46
50
  });
47
- await maybeCheckpoint(stateManager, MODULE);
48
- return {
51
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
52
+ return withGistSyncWarning({
49
53
  url: options.prUrl,
50
54
  target,
51
55
  description: 'Shelved — excluded from capacity and actionable items',
52
- };
56
+ }, gistSyncWarning);
53
57
  }
54
58
  case 'auto': {
55
59
  stateManager.batch(() => {
56
60
  stateManager.clearStatusOverride(options.prUrl);
57
61
  stateManager.unshelvePR(options.prUrl);
58
62
  });
59
- await maybeCheckpoint(stateManager, MODULE);
60
- return {
63
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
64
+ return withGistSyncWarning({
61
65
  url: options.prUrl,
62
66
  target,
63
67
  description: 'Reset to computed status',
64
- };
68
+ }, gistSyncWarning);
65
69
  }
66
70
  default: {
67
71
  const _exhaustive = target;
@@ -11,7 +11,7 @@
11
11
  * no state mutation, runs against a public `owner/repo` slug.
12
12
  */
13
13
  import { getOctokit, requireGitHubToken } from '../core/index.js';
14
- import { errorMessage } from '../core/errors.js';
14
+ import { errorMessage, getHttpStatusCode, isRateLimitOrAuthError } from '../core/errors.js';
15
15
  import { warn } from '../core/logger.js';
16
16
  import { validateRepoIdentifier } from './validation.js';
17
17
  import { computeRepoVet } from '../core/repo-vet.js';
@@ -160,13 +160,30 @@ export async function runRepoVet(options) {
160
160
  const token = requireGitHubToken();
161
161
  const octokit = getOctokit(token);
162
162
  const now = new Date();
163
+ // Tracks the release-list analogue of communityHealth's `incomplete`:
164
+ // set when the listReleases call failed for a reason that does NOT
165
+ // prove absence (5xx, network), so the caller can distinguish "repo
166
+ // has no releases" from "couldn't check releases" (#1373).
167
+ let releasesIncomplete = false;
163
168
  const [repoMetaResp, closedPRsResp, commitsResp, releasesResp, communityHealthSummary] = await Promise.all([
164
169
  octokit.repos.get({ owner, repo }),
165
170
  octokit.pulls.list({ owner, repo, state: 'closed', sort: 'updated', direction: 'desc', per_page: 100 }),
166
171
  octokit.repos.listCommits({ owner, repo, per_page: 100 }),
167
- octokit.repos
168
- .listReleases({ owner, repo, per_page: 1 })
169
- .catch(() => ({ data: [] })),
172
+ octokit.repos.listReleases({ owner, repo, per_page: 1 }).catch((err) => {
173
+ // Rate-limit / auth failures must abort the run, same as every
174
+ // sibling fetch under throttling, a blanket catch here would
175
+ // silently report "no releases" for the whole vet batch (#1373).
176
+ if (isRateLimitOrAuthError(err))
177
+ throw err;
178
+ const empty = { data: [] };
179
+ // 404 is a definitive "nothing there" — genuine absence, not a gap.
180
+ if (getHttpStatusCode(err) === 404)
181
+ return empty;
182
+ // 5xx / network: absence is unproven. Degrade gracefully but flag it.
183
+ releasesIncomplete = true;
184
+ warn(MODULE, `release listing for ${owner}/${repo} failed: ${errorMessage(err)} — release-recency signal is incomplete`);
185
+ return empty;
186
+ }),
170
187
  checkCommunityHealth(octokit, owner, repo),
171
188
  ]);
172
189
  const prs = closedPRsResp.data.map((p) => ({
@@ -201,15 +218,20 @@ export async function runRepoVet(options) {
201
218
  const result = computeRepoVet(input);
202
219
  // The core function names its metadata object `repo`. Rename to `repoMeta`
203
220
  // at the CLI boundary so the top-level slug doesn't collide with it.
204
- // Also overlay the community-health `incomplete` flag the wrapper
205
- // tracks (the core type doesn't carry it because computeRepoVet is
206
- // pure — only the wrapper makes the API calls that can fail mid-probe).
207
- const { repo: repoMeta, communityHealth, ...rest } = result;
221
+ // Also overlay the community-health `incomplete` and the
222
+ // `releasesIncomplete` flags the wrapper tracks (the core type doesn't
223
+ // carry them because computeRepoVet is pure — only the wrapper makes
224
+ // the API calls that can fail mid-probe). Like communityHealth's
225
+ // incomplete flag, releasesIncomplete deliberately does NOT alter the
226
+ // rubric score — the score reflects the fetched signals as-is and the
227
+ // flag tells consumers which signal was unverified.
228
+ const { repo: repoMeta, communityHealth, maintainerActivity, ...rest } = result;
208
229
  return {
209
230
  repoSlug: options.repo,
210
231
  fetchedAt: now.toISOString(),
211
232
  repoMeta,
212
233
  communityHealth: { ...communityHealth, incomplete: communityHealthSummary.incomplete },
234
+ maintainerActivity: { ...maintainerActivity, releasesIncomplete },
213
235
  ...rest,
214
236
  };
215
237
  }
@@ -5,7 +5,8 @@
5
5
  import { buildCandidateLinkedPR, createAutopilotScout } from './scout-bridge.js';
6
6
  import { getStateManager } from '../core/index.js';
7
7
  import { gradeFromCandidate } from '../core/issue-grading.js';
8
- import { warn } from '../core/logger.js';
8
+ import { computeStrategy } from '../core/strategy.js';
9
+ import { debug, warn } from '../core/logger.js';
9
10
  const MODULE = 'search';
10
11
  /**
11
12
  * Hard cap on issue-search result count. Shared between CLI (`cli-registry.ts`),
@@ -46,8 +47,23 @@ function sanitizeViabilityScore(raw) {
46
47
  }
47
48
  export async function runSearch(options) {
48
49
  const scout = await createAutopilotScout();
49
- const result = await scout.search({ maxResults: options.maxResults });
50
50
  const stateManager = getStateManager();
51
+ // Derive personalization from local history (#1244). `computeStrategy`
52
+ // returns null below the merged-PR floor — in that case we pass nothing
53
+ // and scout's sort behaves exactly as before. Once strategy data is
54
+ // available, scout boosts language/repo matches into a separate sort
55
+ // tier (still no filtering).
56
+ const strategy = computeStrategy(stateManager.getState());
57
+ const preferLanguages = strategy?.recommendations.languages ?? undefined;
58
+ const preferRepos = strategy?.recommendations.repos ?? undefined;
59
+ if (preferLanguages?.length || preferRepos?.length) {
60
+ debug(MODULE, `Applying strategy bias to search: preferLanguages=${JSON.stringify(preferLanguages ?? [])}, preferRepos=${JSON.stringify(preferRepos ?? [])}`);
61
+ }
62
+ const result = await scout.search({
63
+ maxResults: options.maxResults,
64
+ preferLanguages,
65
+ preferRepos,
66
+ });
51
67
  const searchOutput = {
52
68
  candidates: result.candidates.map((c) => {
53
69
  const repoScoreRecord = stateManager.getRepoScore(c.issue.repo);
@@ -97,6 +113,8 @@ export async function runSearch(options) {
97
113
  }
98
114
  : undefined,
99
115
  ...(linkedPR ? { linkedPR } : {}),
116
+ ...(typeof c.boostScore === 'number' ? { boostScore: c.boostScore } : {}),
117
+ ...(c.boostReasons && c.boostReasons.length > 0 ? { boostReasons: c.boostReasons } : {}),
100
118
  };
101
119
  }),
102
120
  excludedRepos: result.excludedRepos,
@@ -11,10 +11,14 @@ import { PR_URL_PATTERN } from './validation.js';
11
11
  export interface ShelveOutput {
12
12
  shelved: boolean;
13
13
  url: string;
14
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
15
+ gistSyncWarning?: string;
14
16
  }
15
17
  export interface UnshelveOutput {
16
18
  unshelved: boolean;
17
19
  url: string;
20
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
21
+ gistSyncWarning?: string;
18
22
  }
19
23
  export { PR_URL_PATTERN };
20
24
  /**
@@ -29,8 +29,8 @@ export async function runShelve(options) {
29
29
  added = stateManager.shelvePR(options.prUrl);
30
30
  stateManager.clearStatusOverride(options.prUrl);
31
31
  });
32
- await maybeCheckpoint(stateManager, MODULE);
33
- return { shelved: added, url: options.prUrl };
32
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
33
+ return { shelved: added, url: options.prUrl, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
34
34
  }
35
35
  /**
36
36
  * Unshelve a PR, restoring it to the daily digest.
@@ -49,6 +49,6 @@ export async function runUnshelve(options) {
49
49
  removed = stateManager.unshelvePR(options.prUrl);
50
50
  stateManager.clearStatusOverride(options.prUrl);
51
51
  });
52
- await maybeCheckpoint(stateManager, MODULE);
53
- return { unshelved: removed, url: options.prUrl };
52
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
53
+ return { unshelved: removed, url: options.prUrl, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
54
54
  }
@@ -49,7 +49,7 @@ import { AgentStateSchema } from './state-schema.js';
49
49
  import { atomicWriteFileSync, createFreshState, migrateV1ToV2, migrateV2ToV3, migrateV3ToV4, } from './state-persistence.js';
50
50
  import { getGistIdPath, getStateCachePath } from './paths.js';
51
51
  import { debug, warn } from './logger.js';
52
- import { GistPermissionError, GistConcurrencyError, GistCorruptError, isRateLimitError } from './errors.js';
52
+ import { ConfigurationError, GistPermissionError, GistConcurrencyError, GistCorruptError, isRateLimitError, } from './errors.js';
53
53
  const MODULE = 'gist-store';
54
54
  /**
55
55
  * Extract the ETag header from an Octokit response, tolerating both lower-
@@ -139,6 +139,13 @@ export class GistStateStore {
139
139
  return { gistId: localId, state, created: false };
140
140
  }
141
141
  catch (err) {
142
+ // A corrupt or permission-broken Gist must surface immediately
143
+ // (#1367): falling through to search would either re-find the same
144
+ // corrupt Gist or silently abandon it and create a fresh one.
145
+ if (err instanceof ConfigurationError)
146
+ throw err;
147
+ if (isRateLimitError(err))
148
+ throw err;
142
149
  warn(MODULE, `Failed to fetch Gist ${localId}, will search/create`, err);
143
150
  // Fall through to search
144
151
  }
@@ -159,10 +166,22 @@ export class GistStateStore {
159
166
  return { gistId: id, state, created: true };
160
167
  }
161
168
  catch (err) {
162
- // Configuration errors (e.g. GistPermissionError) must surface, not degrade
163
- if (err instanceof GistPermissionError)
169
+ // Configuration errors (GistPermissionError, GistCorruptError) and rate
170
+ // limits must surface, not degrade (#1367). A corrupt Gist especially:
171
+ // fetchAndCache arms this.gistId/lastFetchedEtag before the parse
172
+ // throws, so a degraded store could push() local or fresh state over
173
+ // the corrupt remote — the exact data loss #1201 exists to prevent.
174
+ // Rate limits propagate per the errors.ts contract; degrading would
175
+ // present "you are rate-limited" as a stale local cache.
176
+ if (err instanceof ConfigurationError)
164
177
  throw err;
165
- // All API paths failed — enter degraded mode
178
+ if (isRateLimitError(err))
179
+ throw err;
180
+ // All API paths failed — enter degraded mode. Disarm the remote write
181
+ // path first: a degraded store never verified its Gist, so it must
182
+ // never be able to push to one (#1367).
183
+ this.gistId = null;
184
+ this.lastFetchedEtag = null;
166
185
  warn(MODULE, 'All Gist API paths failed, entering degraded mode', err);
167
186
  // Try reading from local cache file
168
187
  const cachePath = getStateCachePath();
@@ -222,6 +241,12 @@ export class GistStateStore {
222
241
  return { gistId: localId, state, created: false, migrated: false };
223
242
  }
224
243
  catch (err) {
244
+ // See bootstrap(): corrupt/permission/rate-limit errors surface
245
+ // immediately rather than falling through (#1367).
246
+ if (err instanceof ConfigurationError)
247
+ throw err;
248
+ if (isRateLimitError(err))
249
+ throw err;
225
250
  warn(MODULE, `bootstrapWithMigration: failed to fetch Gist ${localId}, will search/create`, err);
226
251
  // Fall through to search
227
252
  }
@@ -242,10 +267,16 @@ export class GistStateStore {
242
267
  return { gistId: id, state, created: true, migrated: true };
243
268
  }
244
269
  catch (err) {
245
- // Configuration errors (e.g. GistPermissionError) must surface, not degrade
246
- if (err instanceof GistPermissionError)
270
+ // Same surfacing contract as bootstrap() (#1367): configuration errors
271
+ // and rate limits rethrow; only genuine API unavailability degrades.
272
+ if (err instanceof ConfigurationError)
273
+ throw err;
274
+ if (isRateLimitError(err))
247
275
  throw err;
248
- // All API paths failed — enter degraded mode
276
+ // All API paths failed — enter degraded mode with the push path
277
+ // disarmed (see bootstrap()).
278
+ this.gistId = null;
279
+ this.lastFetchedEtag = null;
249
280
  warn(MODULE, 'bootstrapWithMigration: all Gist API paths failed, entering degraded mode', err);
250
281
  // Try reading from local cache file
251
282
  const cachePath = getStateCachePath();
@@ -627,6 +658,10 @@ export class GistStateStore {
627
658
  }
628
659
  }
629
660
  catch (err) {
661
+ // A rate-limited search must not read as "no Gist found" — bootstrap
662
+ // would proceed to create a duplicate Gist while throttled (#1367).
663
+ if (isRateLimitError(err))
664
+ throw err;
630
665
  warn(MODULE, 'Failed to search Gists by description', err);
631
666
  }
632
667
  return null;
@@ -8,7 +8,7 @@ export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX,
8
8
  export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
9
9
  export { IssueConversationMonitor } from './issue-conversation.js';
10
10
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
11
- export { wrapUntrustedContent, extractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, type UntrustedContentMeta, } from './untrusted-content.js';
11
+ export { wrapUntrustedContent, extractFromFence, safeExtractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, type UntrustedContentMeta, } from './untrusted-content.js';
12
12
  export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
13
13
  export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
14
14
  export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
@@ -9,7 +9,7 @@ export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks,
9
9
  // Search/vetting now delegated to @oss-scout/core via commands/scout-bridge.ts
10
10
  export { IssueConversationMonitor } from './issue-conversation.js';
11
11
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
12
- export { wrapUntrustedContent, extractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, } from './untrusted-content.js';
12
+ export { wrapUntrustedContent, extractFromFence, safeExtractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, } from './untrusted-content.js';
13
13
  export { getOctokit, checkRateLimit } from './github.js';
14
14
  export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
15
15
  export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
@@ -12,7 +12,7 @@ import { getStateManager } from './state.js';
12
12
  import { daysBetween } from './dates.js';
13
13
  import { splitRepo, extractOwnerRepo, isOwnRepo } from './urls.js';
14
14
  import { runWorkerPool, DEFAULT_CONCURRENCY } from './concurrency.js';
15
- import { ConfigurationError, errorMessage } from './errors.js';
15
+ import { ConfigurationError, errorMessage, isRateLimitOrAuthError } from './errors.js';
16
16
  import { debug, warn } from './logger.js';
17
17
  const MODULE = 'issue-conversation';
18
18
  const MAX_CONCURRENT_REQUESTS = DEFAULT_CONCURRENCY;
@@ -108,6 +108,14 @@ export class IssueConversationMonitor {
108
108
  }
109
109
  }
110
110
  catch (error) {
111
+ // Rate-limit / auth failures must propagate, not degrade to "fewer
112
+ // results" — under throttling every sibling analysis fails the same
113
+ // way and the partial result silently looks like a quiet day (#1391).
114
+ // runWorkerPool aborts remaining workers and rejects; daily.ts and
115
+ // dashboard-data.ts rethrow rate-limit/auth from their phase catches,
116
+ // aborting the run just like PRMonitor's 429s do.
117
+ if (isRateLimitOrAuthError(error))
118
+ throw error;
111
119
  const msg = errorMessage(error);
112
120
  failures.push({ issueUrl: item.html_url, error: msg });
113
121
  warn(MODULE, `Error analyzing issue ${item.html_url}: ${msg}`);
@@ -117,7 +125,7 @@ export class IssueConversationMonitor {
117
125
  warn(MODULE, `${failures.length}/${candidates.length} issue analysis call(s) failed`);
118
126
  }
119
127
  if (failures.length === candidates.length && candidates.length > 0) {
120
- warn(MODULE, `All ${candidates.length} issue analysis call(s) failed. Possible systemic issue (rate limit, auth, network).`);
128
+ warn(MODULE, `All ${candidates.length} issue analysis call(s) failed. Possible systemic issue (network, GitHub availability).`);
121
129
  }
122
130
  // Sort: new_response first, then waiting, then acknowledged
123
131
  const statusOrder = {
@@ -199,6 +207,11 @@ export class IssueConversationMonitor {
199
207
  }
200
208
  }
201
209
  const labels = (item.labels || []).map((l) => l.name || '').filter(Boolean);
210
+ // Body excerpts stay RAW here on purpose (#1372): these objects feed the
211
+ // dashboard SPA and the CLI text renderers directly, and the matching
212
+ // above (acknowledgment / @mention) operates on raw text. The
213
+ // `<github-content>` fence is applied at the agent-facing serialization
214
+ // boundary in `toDailyOutput()` (commands/daily.ts).
202
215
  const base = {
203
216
  repo: repoFullName,
204
217
  number: item.number,
@@ -25,6 +25,18 @@ export declare function getDataDir(): string;
25
25
  * Implicitly creates the data directory via {@link getDataDir} if it does not exist.
26
26
  */
27
27
  export declare function getStatePath(): string;
28
+ /**
29
+ * Returns the legacy (pre-`~/.oss-autopilot`) state file path: `./data/state.json`
30
+ * relative to the working directory.
31
+ *
32
+ * Functions rather than module constants so tests can mock them per-file:
33
+ * as constants in state-persistence.ts they resolved to the one shared
34
+ * `packages/core/data/` during vitest, and the migration tests racing
35
+ * parallel workers on that real directory caused CI flakes (#1382).
36
+ */
37
+ export declare function getLegacyStatePath(): string;
38
+ /** Legacy backup directory (`./data/backups`); see {@link getLegacyStatePath}. */
39
+ export declare function getLegacyBackupDir(): string;
28
40
  /**
29
41
  * Returns the backup directory path, creating it if it does not exist.
30
42
  *
@@ -36,6 +36,22 @@ export function getDataDir() {
36
36
  export function getStatePath() {
37
37
  return path.join(getDataDir(), 'state.json');
38
38
  }
39
+ /**
40
+ * Returns the legacy (pre-`~/.oss-autopilot`) state file path: `./data/state.json`
41
+ * relative to the working directory.
42
+ *
43
+ * Functions rather than module constants so tests can mock them per-file:
44
+ * as constants in state-persistence.ts they resolved to the one shared
45
+ * `packages/core/data/` during vitest, and the migration tests racing
46
+ * parallel workers on that real directory caused CI flakes (#1382).
47
+ */
48
+ export function getLegacyStatePath() {
49
+ return path.join(process.cwd(), 'data', 'state.json');
50
+ }
51
+ /** Legacy backup directory (`./data/backups`); see {@link getLegacyStatePath}. */
52
+ export function getLegacyBackupDir() {
53
+ return path.join(process.cwd(), 'data', 'backups');
54
+ }
39
55
  /**
40
56
  * Returns the backup directory path, creating it if it does not exist.
41
57
  *
@@ -16,6 +16,7 @@ import type { Octokit } from '@octokit/rest';
16
16
  export interface PRReviewEntry {
17
17
  author: string;
18
18
  authorAssociation: string;
19
+ /** `<github-content>`-fenced review body (#1372). */
19
20
  body: string;
20
21
  submittedAt: string;
21
22
  }
@@ -23,6 +24,7 @@ export interface PRReviewEntry {
23
24
  export interface PRReviewCommentEntry {
24
25
  author: string;
25
26
  authorAssociation: string;
27
+ /** `<github-content>`-fenced comment body (#1372). */
26
28
  body: string;
27
29
  path: string;
28
30
  createdAt: string;
@@ -31,6 +33,7 @@ export interface PRReviewCommentEntry {
31
33
  export interface PRIssueCommentEntry {
32
34
  author: string;
33
35
  authorAssociation: string;
36
+ /** `<github-content>`-fenced comment body (#1372). */
34
37
  body: string;
35
38
  createdAt: string;
36
39
  }
@@ -62,8 +65,13 @@ export declare function fetchPRCommentBundle(octokit: Octokit, prUrl: string, gi
62
65
  * `{ bundles, failures }` so the caller can decide whether to retry, surface
63
66
  * a partial-data banner, or proceed. Rationale: extraction quality is already
64
67
  * a partial-information problem (users contribute to many repos and many PRs),
65
- * so a single 404 / rate limit on one PR should not deny the host the corpus
66
- * from the other 4 — but the failure should still be visible (#1209 L8).
68
+ * so a single 404 / transient failure on one PR should not deny the host the
69
+ * corpus from the other 4 — but the failure should still be visible (#1209 L8).
70
+ *
71
+ * Rate-limit / auth errors are the exception: they reject the whole batch
72
+ * (#1391). Under throttling every remaining PR would fail the same way, so
73
+ * degrading to "skipped N PRs" silently masks a systemic failure the caller
74
+ * needs to surface (the CLI/MCP error envelope on `guidelines fetch-corpus`).
67
75
  */
68
76
  export interface PRCommentBundlesBatchResult {
69
77
  bundles: PRCommentBundle[];
@@ -1,7 +1,8 @@
1
1
  import { paginateAll } from './pagination.js';
2
+ import { wrapUntrustedContent } from './untrusted-content.js';
2
3
  import { isBotAuthor } from './comment-utils.js';
3
4
  import { parseGitHubUrl } from './urls.js';
4
- import { ValidationError, errorMessage } from './errors.js';
5
+ import { ValidationError, errorMessage, isRateLimitOrAuthError } from './errors.js';
5
6
  import { debug, warn } from './logger.js';
6
7
  const MODULE = 'pr-comments-fetcher';
7
8
  /** Default concurrency for {@link fetchPRCommentBundlesBatch}. */
@@ -60,6 +61,12 @@ export async function fetchPRCommentBundle(octokit, prUrl, githubUsername) {
60
61
  return true;
61
62
  };
62
63
  const mergedAt = pr.merged_at ?? pr.closed_at ?? '';
64
+ // Fence every body at fetch time (#1372): the bundle's only consumer is
65
+ // `guidelines fetch-corpus`, whose output goes straight into the host's
66
+ // extract-learnings prompt — there is no human-display or string-matching
67
+ // consumer downstream, so fetch-time wrapping is safe here.
68
+ const fenceLabel = `${repoFull}#${pull_number}`;
69
+ const fence = (body, source, author, association) => wrapUntrustedContent(body, fenceLabel, { author, association, source });
63
70
  return {
64
71
  prUrl,
65
72
  prTitle: pr.title,
@@ -70,7 +77,7 @@ export async function fetchPRCommentBundle(octokit, prUrl, githubUsername) {
70
77
  .map((r) => ({
71
78
  author: r.user?.login ?? '',
72
79
  authorAssociation: r.author_association ?? 'NONE',
73
- body: r.body ?? '',
80
+ body: fence(r.body ?? '', 'pr-review', r.user?.login ?? '', r.author_association ?? 'NONE'),
74
81
  submittedAt: r.submitted_at ?? '',
75
82
  })),
76
83
  reviewComments: reviewComments
@@ -78,7 +85,7 @@ export async function fetchPRCommentBundle(octokit, prUrl, githubUsername) {
78
85
  .map((c) => ({
79
86
  author: c.user?.login ?? '',
80
87
  authorAssociation: c.author_association ?? 'NONE',
81
- body: c.body ?? '',
88
+ body: fence(c.body ?? '', 'pr-review-comment', c.user?.login ?? '', c.author_association ?? 'NONE'),
82
89
  path: c.path ?? '',
83
90
  createdAt: c.created_at ?? '',
84
91
  })),
@@ -87,7 +94,7 @@ export async function fetchPRCommentBundle(octokit, prUrl, githubUsername) {
87
94
  .map((c) => ({
88
95
  author: c.user?.login ?? '',
89
96
  authorAssociation: c.author_association ?? 'NONE',
90
- body: c.body ?? '',
97
+ body: fence(c.body ?? '', 'pr-issue-comment', c.user?.login ?? '', c.author_association ?? 'NONE'),
91
98
  createdAt: c.created_at ?? '',
92
99
  })),
93
100
  };
@@ -96,8 +103,11 @@ export async function fetchPRCommentBundlesBatch(octokit, prUrls, githubUsername
96
103
  const bundles = [];
97
104
  const failures = [];
98
105
  const queue = [...prUrls];
106
+ let aborted = false;
99
107
  async function worker() {
100
108
  while (queue.length > 0) {
109
+ if (aborted)
110
+ return;
101
111
  const url = queue.shift();
102
112
  if (!url)
103
113
  return;
@@ -106,6 +116,14 @@ export async function fetchPRCommentBundlesBatch(octokit, prUrls, githubUsername
106
116
  bundles.push(bundle);
107
117
  }
108
118
  catch (err) {
119
+ // Rate-limit / auth failures abort the batch instead of degrading to
120
+ // a per-PR skip — see the doc comment above (#1391). The abort flag
121
+ // stops sibling workers from burning more rate-limited requests on
122
+ // results that will be discarded when Promise.all rejects.
123
+ if (isRateLimitOrAuthError(err)) {
124
+ aborted = true;
125
+ throw err;
126
+ }
109
127
  const errorMsg = errorMessage(err);
110
128
  failures.push({ prUrl: url, error: errorMsg });
111
129
  warn(MODULE, `Skipping ${url}: ${errorMsg}`);