@oss-autopilot/core 3.1.0 → 3.2.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.
@@ -0,0 +1,67 @@
1
+ import { type PRCommentBundle } from '../core/pr-comments-fetcher.js';
2
+ export interface GuidelinesViewOutput {
3
+ repo: string;
4
+ /** Markdown content, or null when the repo has no guidelines stored. */
5
+ content: string | null;
6
+ /** UTF-8 byte size of `content`, or 0 when content is null. */
7
+ byteSize: number;
8
+ /** Whether a guidelines file exists for this repo. */
9
+ exists: boolean;
10
+ /** Where the guidelines would be persisted if a write happened now. */
11
+ storageMode: 'gist' | 'local-unavailable';
12
+ }
13
+ export interface GuidelinesStoreOutput {
14
+ repo: string;
15
+ byteSize: number;
16
+ stored: boolean;
17
+ }
18
+ export interface GuidelinesResetOutput {
19
+ repo: string;
20
+ /** True when an existing file was tombstoned, false when no file existed. */
21
+ deleted: boolean;
22
+ }
23
+ export interface FetchCorpusOutput {
24
+ repo: string;
25
+ bundles: PRCommentBundle[];
26
+ /** How many PRs were considered after recency + already-fetched filtering. */
27
+ prCount: number;
28
+ /** PRs skipped because `commentsFetchedAt` is already set (without --force). */
29
+ skipped: number;
30
+ }
31
+ interface RepoOption {
32
+ repo: string;
33
+ }
34
+ interface FetchCorpusOptions extends RepoOption {
35
+ /** Cap on PRs to process. Defaults to 5; capped at 10 to bound prompt size. */
36
+ limit?: number;
37
+ /** Re-fetch even when commentsFetchedAt is already set. */
38
+ forceRefetch?: boolean;
39
+ }
40
+ interface StoreOptions extends RepoOption {
41
+ content: string;
42
+ }
43
+ /** Read the per-repo guidelines for `repo`. Returns a `local-unavailable` envelope in non-Gist mode. */
44
+ export declare function runGuidelinesView(options: RepoOption): Promise<GuidelinesViewOutput>;
45
+ /**
46
+ * Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
47
+ * when content exceeds the byte budget — the CLI surface relies on the
48
+ * outputJson error envelope to surface that to the host cleanly.
49
+ */
50
+ export declare function runGuidelinesStore(options: StoreOptions): Promise<GuidelinesStoreOutput>;
51
+ /** Tombstone the guidelines file for `repo`. */
52
+ export declare function runGuidelinesReset(options: RepoOption): Promise<GuidelinesResetOutput>;
53
+ /**
54
+ * Fetch raw PR comment bundles for the most recent merged/closed PRs in `repo`
55
+ * that haven't already been processed. Returns a serializable corpus for the
56
+ * host's extract-learnings prompt.
57
+ *
58
+ * Filters applied at the CLI layer (not the fetcher):
59
+ * - Only PRs in the requested `repo`
60
+ * - PRs older than 12 months are dropped (recency cliff)
61
+ * - PRs with `commentsFetchedAt` already set are skipped unless `forceRefetch`
62
+ * - Capped at `limit` (default 5, max 10)
63
+ *
64
+ * Stamps `commentsFetchedAt` on every PR that was successfully fetched.
65
+ */
66
+ export declare function runFetchCorpus(options: FetchCorpusOptions): Promise<FetchCorpusOutput>;
67
+ export {};
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Guidelines CLI commands (#867 PR 4).
3
+ *
4
+ * `guidelines view` — read the per-repo guidelines file from the Gist.
5
+ * `guidelines store` — overwrite the per-repo guidelines file.
6
+ * `guidelines reset` — tombstone the file so subsequent reads return null.
7
+ * `guidelines fetch-corpus` — pull raw PR comment bundles for the host's
8
+ * extract-learnings prompt to consume.
9
+ *
10
+ * The CLI layer is pure data plumbing — extraction is the host's job.
11
+ * Standalone-mode users see "not available" on store/reset/fetch-corpus
12
+ * because per-repo guidelines require Gist persistence to be useful.
13
+ */
14
+ import { getStateManager, requireGitHubToken, getOctokit, GuidelinesNotAvailableError } from '../core/index.js';
15
+ import { fetchPRCommentBundlesBatch } from '../core/pr-comments-fetcher.js';
16
+ import { warn } from '../core/logger.js';
17
+ const MODULE = 'guidelines';
18
+ const REPO_ID_PATTERN = /^[^/]+\/[^/]+$/;
19
+ const DEFAULT_FETCH_LIMIT = 5;
20
+ const MAX_FETCH_LIMIT = 10;
21
+ /**
22
+ * Cliff at 12 months: PRs older than this are excluded from corpus fetches
23
+ * (#867 design decision §5). Computed at call time, not module load.
24
+ */
25
+ const RECENCY_CLIFF_MS = 365 * 24 * 60 * 60 * 1000;
26
+ function validateRepo(repo) {
27
+ if (!REPO_ID_PATTERN.test(repo)) {
28
+ throw new Error(`Invalid repo identifier "${repo}". Expected "owner/repo" format.`);
29
+ }
30
+ }
31
+ /** Read the per-repo guidelines for `repo`. Returns a `local-unavailable` envelope in non-Gist mode. */
32
+ export async function runGuidelinesView(options) {
33
+ validateRepo(options.repo);
34
+ const sm = getStateManager();
35
+ const content = sm.getGuidelines(options.repo);
36
+ return {
37
+ repo: options.repo,
38
+ content,
39
+ byteSize: content === null ? 0 : Buffer.byteLength(content, 'utf-8'),
40
+ exists: content !== null,
41
+ storageMode: sm.isGuidelinesAvailable() ? 'gist' : 'local-unavailable',
42
+ };
43
+ }
44
+ /**
45
+ * Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
46
+ * when content exceeds the byte budget — the CLI surface relies on the
47
+ * outputJson error envelope to surface that to the host cleanly.
48
+ */
49
+ export async function runGuidelinesStore(options) {
50
+ validateRepo(options.repo);
51
+ if (!options.content || options.content.length === 0) {
52
+ throw new Error('Cannot store empty content. Use `guidelines reset` to delete a guidelines file.');
53
+ }
54
+ const sm = getStateManager();
55
+ // Throws GuidelinesNotAvailableError or GuidelinesTooLargeError on failure.
56
+ sm.setGuidelines(options.repo, options.content);
57
+ return {
58
+ repo: options.repo,
59
+ byteSize: Buffer.byteLength(options.content, 'utf-8'),
60
+ stored: true,
61
+ };
62
+ }
63
+ /** Tombstone the guidelines file for `repo`. */
64
+ export async function runGuidelinesReset(options) {
65
+ validateRepo(options.repo);
66
+ const sm = getStateManager();
67
+ if (!sm.isGuidelinesAvailable()) {
68
+ throw new GuidelinesNotAvailableError();
69
+ }
70
+ const existed = sm.getGuidelines(options.repo) !== null;
71
+ if (existed) {
72
+ sm.deleteGuidelines(options.repo);
73
+ }
74
+ return { repo: options.repo, deleted: existed };
75
+ }
76
+ /**
77
+ * Fetch raw PR comment bundles for the most recent merged/closed PRs in `repo`
78
+ * that haven't already been processed. Returns a serializable corpus for the
79
+ * host's extract-learnings prompt.
80
+ *
81
+ * Filters applied at the CLI layer (not the fetcher):
82
+ * - Only PRs in the requested `repo`
83
+ * - PRs older than 12 months are dropped (recency cliff)
84
+ * - PRs with `commentsFetchedAt` already set are skipped unless `forceRefetch`
85
+ * - Capped at `limit` (default 5, max 10)
86
+ *
87
+ * Stamps `commentsFetchedAt` on every PR that was successfully fetched.
88
+ */
89
+ export async function runFetchCorpus(options) {
90
+ validateRepo(options.repo);
91
+ const limit = clampLimit(options.limit);
92
+ const sm = getStateManager();
93
+ const merged = sm.getMergedPRs() ?? [];
94
+ const closed = sm.getClosedPRs() ?? [];
95
+ const cutoffMs = Date.now() - RECENCY_CLIFF_MS;
96
+ /** Treat both merged and closed-without-merge PRs as candidates. */
97
+ const candidates = [
98
+ ...merged.map((pr) => ({ url: pr.url, timestamp: pr.mergedAt, alreadyFetched: !!pr.commentsFetchedAt })),
99
+ ...closed.map((pr) => ({ url: pr.url, timestamp: pr.closedAt, alreadyFetched: !!pr.commentsFetchedAt })),
100
+ ];
101
+ const repoUrlPrefix = `https://github.com/${options.repo}/`;
102
+ const eligible = candidates.filter((c) => {
103
+ if (!c.url.startsWith(repoUrlPrefix))
104
+ return false;
105
+ if (Date.parse(c.timestamp || '') < cutoffMs)
106
+ return false;
107
+ return true;
108
+ });
109
+ // Skipped count tracks ONLY the eligibility-passing PRs that were excluded
110
+ // for already-fetched. PRs filtered by repo/recency aren't "skipped" — they
111
+ // just don't apply to this repo or cutoff.
112
+ const skipped = options.forceRefetch ? 0 : eligible.filter((c) => c.alreadyFetched).length;
113
+ const toFetch = (options.forceRefetch ? eligible : eligible.filter((c) => !c.alreadyFetched))
114
+ // Most-recent first so the host always sees the freshest signal in its corpus window.
115
+ .sort((a, b) => Date.parse(b.timestamp || '') - Date.parse(a.timestamp || ''))
116
+ .slice(0, limit);
117
+ if (toFetch.length === 0) {
118
+ return { repo: options.repo, bundles: [], prCount: 0, skipped };
119
+ }
120
+ const token = requireGitHubToken();
121
+ const octokit = getOctokit(token);
122
+ const username = sm.getState().config.githubUsername;
123
+ if (!username) {
124
+ warn(MODULE, 'githubUsername is not set; bot/own-comment filtering will be incomplete');
125
+ }
126
+ const bundles = await fetchPRCommentBundlesBatch(octokit, toFetch.map((c) => c.url), username);
127
+ // Stamp commentsFetchedAt on each PR we successfully fetched — the bundle
128
+ // list mirrors the input order until paginateAll fan-out, so we mark on
129
+ // a per-bundle basis to be safe.
130
+ const now = new Date().toISOString();
131
+ for (const bundle of bundles) {
132
+ sm.markPRCommentsFetched(bundle.prUrl, now);
133
+ }
134
+ return {
135
+ repo: options.repo,
136
+ bundles,
137
+ prCount: bundles.length,
138
+ skipped,
139
+ };
140
+ }
141
+ function clampLimit(limit) {
142
+ if (limit === undefined)
143
+ return DEFAULT_FETCH_LIMIT;
144
+ if (!Number.isFinite(limit) || limit < 1)
145
+ return DEFAULT_FETCH_LIMIT;
146
+ return Math.min(limit, MAX_FETCH_LIMIT);
147
+ }
@@ -53,6 +53,14 @@ export { runInit } from './init.js';
53
53
  export { runSetup } from './setup.js';
54
54
  /** Check whether setup has been completed. */
55
55
  export { runCheckSetup } from './setup.js';
56
+ /** Read the guidelines file for a repo. */
57
+ export { runGuidelinesView } from './guidelines.js';
58
+ /** Persist a guidelines file for a repo (overwrites on subsequent calls). */
59
+ export { runGuidelinesStore } from './guidelines.js';
60
+ /** Tombstone a guidelines file so subsequent reads return null. */
61
+ export { runGuidelinesReset } from './guidelines.js';
62
+ /** Fetch raw PR comment bundles for the host's extract-learnings prompt. */
63
+ export { runFetchCorpus } from './guidelines.js';
56
64
  /** Show current persistence mode, Gist ID, and sync status. */
57
65
  export { runStateShow } from './state-cmd.js';
58
66
  /** Force push state to the backing Gist (no-op in local mode). */
@@ -78,6 +86,7 @@ export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput, VetListOutput,
78
86
  export type { ConfigOutput, DetectFormattersOutput, ParseIssueListOutput, ParsedIssueItem, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
79
87
  export type { ShelveOutput, UnshelveOutput } from './shelve.js';
80
88
  export type { MoveOutput, MoveTarget } from './move.js';
89
+ export type { GuidelinesViewOutput, GuidelinesStoreOutput, GuidelinesResetOutput, FetchCorpusOutput, } from './guidelines.js';
81
90
  export type { DismissOutput, UndismissOutput } from './dismiss.js';
82
91
  export type { InitOutput } from './init.js';
83
92
  export type { ConfigSetOutput, ConfigCommandOutput } from './config.js';
@@ -57,6 +57,15 @@ export { runInit } from './init.js';
57
57
  export { runSetup } from './setup.js';
58
58
  /** Check whether setup has been completed. */
59
59
  export { runCheckSetup } from './setup.js';
60
+ // ── Per-Repo Guidelines (#867) ──────────────────────────────────────────────
61
+ /** Read the guidelines file for a repo. */
62
+ export { runGuidelinesView } from './guidelines.js';
63
+ /** Persist a guidelines file for a repo (overwrites on subsequent calls). */
64
+ export { runGuidelinesStore } from './guidelines.js';
65
+ /** Tombstone a guidelines file so subsequent reads return null. */
66
+ export { runGuidelinesReset } from './guidelines.js';
67
+ /** Fetch raw PR comment bundles for the host's extract-learnings prompt. */
68
+ export { runFetchCorpus } from './guidelines.js';
60
69
  // ── State Persistence ────────────────────────────────────────────────────────
61
70
  /** Show current persistence mode, Gist ID, and sync status. */
62
71
  export { runStateShow } from './state-cmd.js';
@@ -200,7 +200,7 @@ export declare class GistStateStore {
200
200
  private fetchAndCache;
201
201
  /**
202
202
  * Parse `state.json` from the in-memory cache. Handles v2 migration
203
- * by running through the Zod schema (which requires version: 3).
203
+ * by running through the Zod schema (which requires version: 4).
204
204
  * Falls back to fresh state if the file is missing or unparseable.
205
205
  */
206
206
  private parseStateFromCache;
@@ -33,7 +33,7 @@
33
33
  */
34
34
  import * as fs from 'fs';
35
35
  import { AgentStateSchema } from './state-schema.js';
36
- import { atomicWriteFileSync, createFreshState, migrateV1ToV2, migrateV2ToV3 } from './state-persistence.js';
36
+ import { atomicWriteFileSync, createFreshState, migrateV1ToV2, migrateV2ToV3, migrateV3ToV4, } from './state-persistence.js';
37
37
  import { getGistIdPath, getStateCachePath } from './paths.js';
38
38
  import { debug, warn } from './logger.js';
39
39
  import { GistPermissionError, GistConcurrencyError, isRateLimitError } from './errors.js';
@@ -127,6 +127,8 @@ export class GistStateStore {
127
127
  obj = migrateV1ToV2(record);
128
128
  if (obj.version === 2)
129
129
  obj = migrateV2ToV3(obj);
130
+ if (obj.version === 3)
131
+ obj = migrateV3ToV4(obj);
130
132
  }
131
133
  const cachedState = AgentStateSchema.parse(obj);
132
134
  debug(MODULE, 'Loaded state from local cache in degraded mode');
@@ -197,6 +199,8 @@ export class GistStateStore {
197
199
  obj = migrateV1ToV2(record);
198
200
  if (obj.version === 2)
199
201
  obj = migrateV2ToV3(obj);
202
+ if (obj.version === 3)
203
+ obj = migrateV3ToV4(obj);
200
204
  }
201
205
  const cachedState = AgentStateSchema.parse(obj);
202
206
  debug(MODULE, 'bootstrapWithMigration: loaded state from local cache in degraded mode');
@@ -433,7 +437,7 @@ export class GistStateStore {
433
437
  }
434
438
  /**
435
439
  * Parse `state.json` from the in-memory cache. Handles v2 migration
436
- * by running through the Zod schema (which requires version: 3).
440
+ * by running through the Zod schema (which requires version: 4).
437
441
  * Falls back to fresh state if the file is missing or unparseable.
438
442
  */
439
443
  parseStateFromCache() {
@@ -451,6 +455,8 @@ export class GistStateStore {
451
455
  obj = migrateV1ToV2(record);
452
456
  if (obj.version === 2)
453
457
  obj = migrateV2ToV3(obj);
458
+ if (obj.version === 3)
459
+ obj = migrateV3ToV4(obj);
454
460
  }
455
461
  return AgentStateSchema.parse(obj);
456
462
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Per-repo guidelines persistence on top of the Gist freeform-document API
3
+ * (#867 PR 2).
4
+ *
5
+ * Each repo gets one markdown file named `guidelines--{owner}--{repo}.md`
6
+ * stored in the user's oss-autopilot Gist alongside `state.json`. The file
7
+ * holds extracted guidance from past PR review feedback. Reads and writes go
8
+ * through the in-memory cache that `GistStateStore` already maintains; the
9
+ * file persists on the next `push()`.
10
+ *
11
+ * The byte cap (8 KB) is enforced at write time to keep claim-time context
12
+ * injection small. Larger guidance should be split across categories or
13
+ * deferred to a follow-up consolidation pass.
14
+ */
15
+ import type { GistStateStore } from './gist-state-store.js';
16
+ import { OssAutopilotError } from './errors.js';
17
+ /** Filename prefix shared by every guidelines file in the Gist. */
18
+ export declare const GUIDELINES_FILE_PREFIX = "guidelines--";
19
+ /** Hard byte budget for a single guidelines file (#867 design log §1). */
20
+ export declare const GUIDELINES_MAX_BYTES = 8192;
21
+ /**
22
+ * Convert an `owner/repo` pair into the filename used inside the Gist.
23
+ * Slashes are escaped as `--` so the filename is filesystem-safe and
24
+ * unambiguous when parsing back to a repo string.
25
+ */
26
+ export declare function guidelinesFilename(repo: string): string;
27
+ /**
28
+ * Inverse of {@link guidelinesFilename}. Returns null when the filename
29
+ * doesn't match the guidelines convention.
30
+ */
31
+ export declare function repoFromGuidelinesFilename(filename: string): string | null;
32
+ /**
33
+ * Thrown by {@link setGuidelines} / {@link deleteGuidelines} when the
34
+ * StateManager is not in Gist mode. Catch + degrade gracefully when surfacing
35
+ * to user-facing flows: per-repo guidelines simply aren't available without a
36
+ * Gist to store them in.
37
+ */
38
+ export declare class GuidelinesNotAvailableError extends OssAutopilotError {
39
+ constructor(message?: string);
40
+ }
41
+ /**
42
+ * Thrown by {@link setGuidelines} when content exceeds {@link GUIDELINES_MAX_BYTES}.
43
+ * Surfaced separately from generic validation errors so consumers can prompt the
44
+ * user with a "trim or split" UX rather than a generic shape rejection.
45
+ */
46
+ export declare class GuidelinesTooLargeError extends OssAutopilotError {
47
+ constructor(byteSize: number, max?: number);
48
+ }
49
+ /**
50
+ * Read the guidelines file for a repo from the Gist cache. Returns null when
51
+ * the store is not in Gist mode, the file does not exist, or the file is
52
+ * present but empty (treated as a tombstone left by {@link deleteGuidelines}).
53
+ */
54
+ export declare function getGuidelines(store: GistStateStore | null, repo: string): string | null;
55
+ /**
56
+ * Write or replace the guidelines file for a repo. Throws if the store is not
57
+ * in Gist mode or the content exceeds the byte budget.
58
+ */
59
+ export declare function setGuidelines(store: GistStateStore | null, repo: string, content: string): void;
60
+ /**
61
+ * Delete the guidelines file for a repo. No-op if the file doesn't exist.
62
+ * Implementation: write an empty string. The Gist API treats files with
63
+ * empty content as deletions on the next push, matching the existing
64
+ * single-source-of-truth model.
65
+ */
66
+ export declare function deleteGuidelines(store: GistStateStore | null, repo: string): void;
67
+ /**
68
+ * List every repo (as `owner/repo`) that has a non-empty guidelines file in
69
+ * the cache. Tombstoned (empty-content) files are excluded so the result
70
+ * matches what {@link getGuidelines} would actually return.
71
+ *
72
+ * Returns an empty array when the store is null or no files exist.
73
+ */
74
+ export declare function listGuidelinesRepos(store: GistStateStore | null): string[];
@@ -0,0 +1,130 @@
1
+ import { OssAutopilotError } from './errors.js';
2
+ /** Filename prefix shared by every guidelines file in the Gist. */
3
+ export const GUIDELINES_FILE_PREFIX = 'guidelines--';
4
+ /** Hard byte budget for a single guidelines file (#867 design log §1). */
5
+ export const GUIDELINES_MAX_BYTES = 8_192;
6
+ /** Suffix appended to the filename so it renders as markdown in Gist. */
7
+ const GUIDELINES_FILE_SUFFIX = '.md';
8
+ /**
9
+ * Convert an `owner/repo` pair into the filename used inside the Gist.
10
+ * Slashes are escaped as `--` so the filename is filesystem-safe and
11
+ * unambiguous when parsing back to a repo string.
12
+ */
13
+ export function guidelinesFilename(repo) {
14
+ if (!repo.includes('/')) {
15
+ throw new OssAutopilotError(`Invalid repo identifier "${repo}". Expected "owner/repo" format.`, 'INVALID_REPO_ID');
16
+ }
17
+ // GitHub forbids `/` in owner and repo, so the only `/` is the separator.
18
+ const [owner, name] = repo.split('/');
19
+ return `${GUIDELINES_FILE_PREFIX}${owner}--${name}${GUIDELINES_FILE_SUFFIX}`;
20
+ }
21
+ /**
22
+ * Inverse of {@link guidelinesFilename}. Returns null when the filename
23
+ * doesn't match the guidelines convention.
24
+ */
25
+ export function repoFromGuidelinesFilename(filename) {
26
+ if (!filename.startsWith(GUIDELINES_FILE_PREFIX))
27
+ return null;
28
+ if (!filename.endsWith(GUIDELINES_FILE_SUFFIX))
29
+ return null;
30
+ const middle = filename.slice(GUIDELINES_FILE_PREFIX.length, filename.length - GUIDELINES_FILE_SUFFIX.length);
31
+ // Only split on the FIRST `--` separator. Repo names with `--` are rare
32
+ // but legal; owner names cannot contain `--` per GitHub username rules.
33
+ const sep = middle.indexOf('--');
34
+ if (sep === -1)
35
+ return null;
36
+ const owner = middle.slice(0, sep);
37
+ const name = middle.slice(sep + 2);
38
+ if (!owner || !name)
39
+ return null;
40
+ return `${owner}/${name}`;
41
+ }
42
+ /**
43
+ * Thrown by {@link setGuidelines} / {@link deleteGuidelines} when the
44
+ * StateManager is not in Gist mode. Catch + degrade gracefully when surfacing
45
+ * to user-facing flows: per-repo guidelines simply aren't available without a
46
+ * Gist to store them in.
47
+ */
48
+ export class GuidelinesNotAvailableError extends OssAutopilotError {
49
+ constructor(message) {
50
+ super(message ??
51
+ 'Per-repo guidelines require Gist persistence. Run `oss-autopilot setup` to enable Gist sync, then retry.', 'GUIDELINES_NOT_AVAILABLE');
52
+ this.name = 'GuidelinesNotAvailableError';
53
+ }
54
+ }
55
+ /**
56
+ * Thrown by {@link setGuidelines} when content exceeds {@link GUIDELINES_MAX_BYTES}.
57
+ * Surfaced separately from generic validation errors so consumers can prompt the
58
+ * user with a "trim or split" UX rather than a generic shape rejection.
59
+ */
60
+ export class GuidelinesTooLargeError extends OssAutopilotError {
61
+ constructor(byteSize, max = GUIDELINES_MAX_BYTES) {
62
+ super(`Guidelines content is ${byteSize} bytes, exceeding the ${max}-byte cap. ` + `Trim or split across categories.`, 'GUIDELINES_TOO_LARGE');
63
+ this.name = 'GuidelinesTooLargeError';
64
+ }
65
+ }
66
+ /**
67
+ * Read the guidelines file for a repo from the Gist cache. Returns null when
68
+ * the store is not in Gist mode, the file does not exist, or the file is
69
+ * present but empty (treated as a tombstone left by {@link deleteGuidelines}).
70
+ */
71
+ export function getGuidelines(store, repo) {
72
+ if (!store)
73
+ return null;
74
+ const content = store.getDocument(guidelinesFilename(repo));
75
+ if (content === null || content === '')
76
+ return null;
77
+ return content;
78
+ }
79
+ /**
80
+ * Write or replace the guidelines file for a repo. Throws if the store is not
81
+ * in Gist mode or the content exceeds the byte budget.
82
+ */
83
+ export function setGuidelines(store, repo, content) {
84
+ if (!store)
85
+ throw new GuidelinesNotAvailableError();
86
+ const byteSize = Buffer.byteLength(content, 'utf-8');
87
+ if (byteSize > GUIDELINES_MAX_BYTES) {
88
+ throw new GuidelinesTooLargeError(byteSize);
89
+ }
90
+ store.setDocument(guidelinesFilename(repo), content);
91
+ }
92
+ /**
93
+ * Delete the guidelines file for a repo. No-op if the file doesn't exist.
94
+ * Implementation: write an empty string. The Gist API treats files with
95
+ * empty content as deletions on the next push, matching the existing
96
+ * single-source-of-truth model.
97
+ */
98
+ export function deleteGuidelines(store, repo) {
99
+ if (!store)
100
+ throw new GuidelinesNotAvailableError();
101
+ // setDocument('') is interpreted as deletion; the GistStateStore push path
102
+ // already strips empty-content files before sending to the Gist API.
103
+ store.setDocument(guidelinesFilename(repo), '');
104
+ }
105
+ /**
106
+ * List every repo (as `owner/repo`) that has a non-empty guidelines file in
107
+ * the cache. Tombstoned (empty-content) files are excluded so the result
108
+ * matches what {@link getGuidelines} would actually return.
109
+ *
110
+ * Returns an empty array when the store is null or no files exist.
111
+ */
112
+ export function listGuidelinesRepos(store) {
113
+ if (!store)
114
+ return [];
115
+ const filenames = store.listDocuments(GUIDELINES_FILE_PREFIX);
116
+ const repos = [];
117
+ for (const filename of filenames) {
118
+ const repo = repoFromGuidelinesFilename(filename);
119
+ // Skip files we can't decode (e.g. older formats, hand-edited) — better
120
+ // than throwing and breaking listGuidelinesRepos for everyone.
121
+ if (!repo)
122
+ continue;
123
+ // Skip tombstones — empty content means the user deleted these guidelines.
124
+ const content = store.getDocument(filename);
125
+ if (content === null || content === '')
126
+ continue;
127
+ repos.push(repo);
128
+ }
129
+ return repos.sort();
130
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, type Stats, } from './state.js';
6
6
  export { GistStateStore } from './gist-state-store.js';
7
+ export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX, GUIDELINES_MAX_BYTES, GuidelinesNotAvailableError, GuidelinesTooLargeError, } from './guidelines-store.js';
7
8
  export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
8
9
  export { IssueConversationMonitor } from './issue-conversation.js';
9
10
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, } from './state.js';
6
6
  export { GistStateStore } from './gist-state-store.js';
7
+ export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX, GUIDELINES_MAX_BYTES, GuidelinesNotAvailableError, GuidelinesTooLargeError, } from './guidelines-store.js';
7
8
  export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
8
9
  // Search/vetting now delegated to @oss-scout/core via commands/scout-bridge.ts
9
10
  export { IssueConversationMonitor } from './issue-conversation.js';
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Fetch the raw review-comment bundle for a PR (#867 PR 3).
3
+ *
4
+ * Returns reviews, inline review comments, and issue-level comments for a
5
+ * single PR with the contributor's own comments + bots filtered out. The
6
+ * `authorAssociation` field is preserved on every entry so the host's
7
+ * extraction prompt can weight maintainer voices (OWNER/MEMBER/COLLABORATOR)
8
+ * differently from community feedback (CONTRIBUTOR/NONE).
9
+ *
10
+ * No LLM calls happen here — this is the data layer feeding the host's
11
+ * `extract-learnings` prompt. The bundle structure is the contract; the
12
+ * extraction is the host's responsibility.
13
+ */
14
+ import type { Octokit } from '@octokit/rest';
15
+ /** A single review (top-level) on a PR. */
16
+ export interface PRReviewEntry {
17
+ author: string;
18
+ authorAssociation: string;
19
+ body: string;
20
+ submittedAt: string;
21
+ }
22
+ /** An inline review comment (anchored to a file/line) on a PR. */
23
+ export interface PRReviewCommentEntry {
24
+ author: string;
25
+ authorAssociation: string;
26
+ body: string;
27
+ path: string;
28
+ createdAt: string;
29
+ }
30
+ /** An issue-level comment posted on the PR thread. */
31
+ export interface PRIssueCommentEntry {
32
+ author: string;
33
+ authorAssociation: string;
34
+ body: string;
35
+ createdAt: string;
36
+ }
37
+ /**
38
+ * The full comment bundle returned for a single PR. Field order matches
39
+ * the typical narrative arc of a PR review (top-level reviews → inline
40
+ * comments → general thread chatter), so the host's extraction prompt can
41
+ * walk the bundle linearly.
42
+ */
43
+ export interface PRCommentBundle {
44
+ prUrl: string;
45
+ prTitle: string;
46
+ repo: string;
47
+ /** ISO-8601 timestamp the PR was merged or closed; whichever applies. */
48
+ mergedAt: string;
49
+ reviews: PRReviewEntry[];
50
+ reviewComments: PRReviewCommentEntry[];
51
+ issueComments: PRIssueCommentEntry[];
52
+ }
53
+ /**
54
+ * Fetch a single PR's comment bundle. Filters out the authenticated user's
55
+ * own comments and bots. Throws {@link ValidationError} on a non-PR URL.
56
+ */
57
+ export declare function fetchPRCommentBundle(octokit: Octokit, prUrl: string, githubUsername: string): Promise<PRCommentBundle>;
58
+ /**
59
+ * Fetch comment bundles for many PRs with a small concurrency cap (default 3).
60
+ *
61
+ * Failures on individual PRs are logged and skipped — the batch returns a
62
+ * shorter array rather than aborting. Rationale: extraction quality is
63
+ * already a partial-information problem (users contribute to many repos and
64
+ * many PRs), so a single 404 / rate limit on one PR should not deny the
65
+ * host the corpus from the other 4.
66
+ */
67
+ export declare function fetchPRCommentBundlesBatch(octokit: Octokit, prUrls: string[], githubUsername: string, concurrency?: number): Promise<PRCommentBundle[]>;