@oss-scout/core 0.11.0 → 1.1.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 (68) hide show
  1. package/dist/cli.bundle.cjs +89 -66
  2. package/dist/cli.js +302 -436
  3. package/dist/commands/command-scout.d.ts +21 -0
  4. package/dist/commands/command-scout.js +21 -0
  5. package/dist/commands/config.js +10 -128
  6. package/dist/commands/features.js +15 -28
  7. package/dist/commands/results.d.ts +13 -2
  8. package/dist/commands/results.js +29 -2
  9. package/dist/commands/search.d.ts +4 -0
  10. package/dist/commands/search.js +65 -70
  11. package/dist/commands/setup.d.ts +2 -0
  12. package/dist/commands/setup.js +35 -6
  13. package/dist/commands/skip.d.ts +4 -0
  14. package/dist/commands/skip.js +45 -55
  15. package/dist/commands/sync.d.ts +10 -0
  16. package/dist/commands/sync.js +10 -0
  17. package/dist/commands/vet-list.js +3 -19
  18. package/dist/commands/vet.js +18 -25
  19. package/dist/commands/with-scout.d.ts +32 -0
  20. package/dist/commands/with-scout.js +41 -0
  21. package/dist/core/anti-llm-policy.js +5 -33
  22. package/dist/core/bootstrap.d.ts +2 -2
  23. package/dist/core/bootstrap.js +5 -9
  24. package/dist/core/errors.d.ts +10 -0
  25. package/dist/core/errors.js +20 -5
  26. package/dist/core/feature-discovery.d.ts +13 -1
  27. package/dist/core/feature-discovery.js +104 -81
  28. package/dist/core/gist-state-store.d.ts +13 -12
  29. package/dist/core/gist-state-store.js +128 -53
  30. package/dist/core/http-cache.d.ts +32 -2
  31. package/dist/core/http-cache.js +74 -19
  32. package/dist/core/issue-discovery.d.ts +12 -1
  33. package/dist/core/issue-discovery.js +94 -67
  34. package/dist/core/issue-eligibility.d.ts +11 -4
  35. package/dist/core/issue-eligibility.js +124 -69
  36. package/dist/core/issue-graphql.d.ts +58 -0
  37. package/dist/core/issue-graphql.js +108 -0
  38. package/dist/core/issue-vetting.d.ts +115 -9
  39. package/dist/core/issue-vetting.js +246 -109
  40. package/dist/core/local-state.d.ts +6 -2
  41. package/dist/core/local-state.js +23 -5
  42. package/dist/core/logger.d.ts +12 -4
  43. package/dist/core/logger.js +33 -7
  44. package/dist/core/personalization.d.ts +30 -10
  45. package/dist/core/personalization.js +64 -24
  46. package/dist/core/preference-fields.d.ts +47 -0
  47. package/dist/core/preference-fields.js +180 -0
  48. package/dist/core/probe-repo-file.d.ts +47 -0
  49. package/dist/core/probe-repo-file.js +57 -0
  50. package/dist/core/repo-health.js +40 -32
  51. package/dist/core/roadmap.js +26 -22
  52. package/dist/core/schemas.d.ts +148 -26
  53. package/dist/core/schemas.js +83 -17
  54. package/dist/core/search-budget.d.ts +9 -0
  55. package/dist/core/search-budget.js +36 -3
  56. package/dist/core/search-phases.d.ts +4 -21
  57. package/dist/core/search-phases.js +37 -89
  58. package/dist/core/types.d.ts +151 -38
  59. package/dist/core/utils.js +60 -26
  60. package/dist/formatters/human.d.ts +60 -0
  61. package/dist/formatters/human.js +199 -0
  62. package/dist/formatters/markdown.d.ts +10 -0
  63. package/dist/formatters/markdown.js +31 -0
  64. package/dist/index.d.ts +6 -2
  65. package/dist/index.js +8 -0
  66. package/dist/scout.d.ts +75 -12
  67. package/dist/scout.js +265 -26
  68. package/package.json +1 -1
@@ -6,10 +6,10 @@
6
6
  * Extracted from issue-vetting.ts to isolate eligibility logic.
7
7
  */
8
8
  import { paginateAll } from "./pagination.js";
9
- import { errorMessage, getHttpStatusCode, isRateLimitError } from "./errors.js";
9
+ import { errorMessage, rethrowIfFatal } from "./errors.js";
10
10
  import { warn } from "./logger.js";
11
- import { getHttpCache } from "./http-cache.js";
12
- import { getSearchBudgetTracker } from "./search-budget.js";
11
+ import { getHttpCache, withInflightDedup, versionedCacheKey, } from "./http-cache.js";
12
+ import { getSearchBudgetTracker, } from "./search-budget.js";
13
13
  function isLinkedPREvent(e) {
14
14
  return e.event === "cross-referenced" && !!e.source?.issue?.pull_request;
15
15
  }
@@ -50,24 +50,65 @@ function buildLinkedPRFromTimelineEvent(e, context) {
50
50
  };
51
51
  }
52
52
  const MODULE = "issue-eligibility";
53
- /** Phrases that indicate someone has already claimed an issue. */
54
- const CLAIM_PHRASES = [
55
- "i'm working on this",
56
- "i am working on this",
57
- "i'll take this",
58
- "i will take this",
59
- "working on it",
60
- "i'd like to work on",
61
- "i would like to work on",
62
- "can i work on",
63
- "may i work on",
64
- "assigned to me",
65
- "i'm on it",
66
- "i'll submit a pr",
67
- "i will submit a pr",
68
- "working on a fix",
69
- "working on a pr",
53
+ /**
54
+ * Claim detection, applied per clause (sentence). Plain substring matching
55
+ * flagged questions ("is anyone working on it?") and negations ("no one is
56
+ * working on this") as claims. Rules:
57
+ *
58
+ * - A clause ending in "?" is never a claim, EXCEPT a permission request
59
+ * ("can I work on this?"), which is the author asking to take the issue.
60
+ * - A declarative clause with an indefinite or negated subject (anyone,
61
+ * someone, nobody, not, ...) is never a claim.
62
+ * - Otherwise declarative claim patterns match, including third-person
63
+ * ("Bob is working on it" means the issue is taken).
64
+ */
65
+ /**
66
+ * Object that refers to the issue at hand: this/it/that, "the <thing>",
67
+ * "#123", "issue ...". Deliberately excludes "a <thing>" ("can I work on a
68
+ * repro?" introduces new work, it does not claim the issue). The numeric
69
+ * branch requires the # prefix: a bare number collides with quantity idioms
70
+ * ("can I take 5 minutes"). Residual misses: gerund objects ("work on
71
+ * fixing the bug") and bare numbers ("work on 126").
72
+ */
73
+ const ISSUE_OBJECT = String.raw `(?:this\b|it\b|that\b|the\b|#\d+|issue\b)`;
74
+ /** Explicit first-person claims; not subject to the subject guard. */
75
+ const FIRST_PERSON_CLAIM_PATTERNS = [
76
+ new RegExp(String.raw `\bi\s*(?:'ll|will) take ${ISSUE_OBJECT}`),
77
+ new RegExp(String.raw `\bi\s*(?:'d|would) (?:like|love) to work on ${ISSUE_OBJECT}`),
78
+ /\bi\s*(?:'m|am) on it\b/,
79
+ /\bi\s*(?:'ll|will) submit a pr\b/,
80
+ /\bassigned to me\b/,
81
+ ];
82
+ /**
83
+ * Generic "working on ..." phrasings. These also match third-person claims
84
+ * ("Bob is working on it"), so they need the subject guard below to avoid
85
+ * flagging indefinite or negated subjects.
86
+ */
87
+ const GENERIC_WORKING_PATTERNS = [
88
+ /\bworking on (?:this|it)\b/,
89
+ /\bworking on a (?:fix|pr)\b/,
70
90
  ];
91
+ /** Asking to take the issue counts as a claim even phrased as a question. */
92
+ const PERMISSION_CLAIM_PATTERN = new RegExp(String.raw `\b(?:can|may|could) i (?:work on|take) ${ISSUE_OBJECT}`);
93
+ /** Subjects/negations that make a "working on ..." clause a non-claim. */
94
+ const NON_CLAIM_SUBJECTS = /\b(?:anyone|anybody|someone|somebody|who|whoever|nobody|no[- ]?one|not)\b/;
95
+ /** True when a single comment body claims the issue. */
96
+ export function commentClaimsIssue(body) {
97
+ const clauses = body.toLowerCase().split(/(?<=[.!?])|\n+/);
98
+ for (const clause of clauses) {
99
+ if (PERMISSION_CLAIM_PATTERN.test(clause))
100
+ return true;
101
+ if (clause.trimEnd().endsWith("?"))
102
+ continue;
103
+ if (FIRST_PERSON_CLAIM_PATTERNS.some((p) => p.test(clause)))
104
+ return true;
105
+ if (NON_CLAIM_SUBJECTS.test(clause))
106
+ continue;
107
+ if (GENERIC_WORKING_PATTERNS.some((p) => p.test(clause)))
108
+ return true;
109
+ }
110
+ return false;
111
+ }
71
112
  /**
72
113
  * Check whether an open PR already exists for the given issue.
73
114
  * Uses the timeline API (REST) to detect cross-referenced PRs, avoiding
@@ -106,9 +147,7 @@ export async function checkNoExistingPR(octokit, owner, repo, issueNumber) {
106
147
  return { passed: linkedPRCount === 0, linkedPR };
107
148
  }
108
149
  catch (error) {
109
- if (getHttpStatusCode(error) === 401 || isRateLimitError(error)) {
110
- throw error;
111
- }
150
+ rethrowIfFatal(error);
112
151
  const errMsg = errorMessage(error);
113
152
  warn(MODULE, `Failed to check for existing PRs on ${owner}/${repo}#${issueNumber}: ${errMsg}. Assuming no existing PR.`);
114
153
  return { passed: true, inconclusive: true, reason: errMsg, linkedPR: null };
@@ -122,42 +161,51 @@ const MERGED_PR_CACHE_TTL_MS = 15 * 60 * 1000;
122
161
  * Results are cached per-repo for 15 minutes to avoid redundant Search API
123
162
  * calls when multiple issues from the same repo are vetted.
124
163
  */
125
- export async function checkUserMergedPRsInRepo(octokit, owner, repo) {
164
+ export async function checkUserMergedPRsInRepo(octokit, owner, repo,
165
+ // Optional injected budget tracker. Defaults to the shared singleton so
166
+ // existing callers keep the same global budget accounting; a host wanting
167
+ // per-search isolation threads its own tracker down from IssueVetter.
168
+ tracker = getSearchBudgetTracker()) {
126
169
  const cache = getHttpCache();
127
- const cacheKey = `merged-prs:${owner}/${repo}`;
128
- // Manual cache check do not use cachedTimeBased because we must NOT cache
129
- // error-path fallback values (a transient failure returning 0 would poison the
130
- // cache for 15 minutes, hiding that the user has merged PRs in the repo).
131
- const cached = cache.getIfFresh(cacheKey, MERGED_PR_CACHE_TTL_MS);
132
- if (cached != null && typeof cached === "number") {
133
- return cached;
134
- }
135
- try {
136
- const tracker = getSearchBudgetTracker();
137
- await tracker.waitForBudget();
138
- try {
139
- // Use @me to search as the authenticated user
140
- const { data } = await octokit.search.issuesAndPullRequests({
141
- q: `repo:${owner}/${repo} is:pr is:merged author:@me`,
142
- per_page: 1, // We only need total_count
143
- });
144
- // Only cache successful results
145
- cache.set(cacheKey, "", data.total_count);
146
- return data.total_count;
170
+ const cacheKey = versionedCacheKey(`merged-prs:${owner}/${repo}`);
171
+ // In-flight dedup: parallel vetting frequently hits several issues from
172
+ // one repo at once, and each used to pay a separate Search API call
173
+ // before the first populated the cache (#124).
174
+ return withInflightDedup(cache, cacheKey, async () => {
175
+ // Manual cache check do not use cachedTimeBased because we must NOT
176
+ // cache error-path fallback values (a transient failure returning 0
177
+ // would poison the cache for 15 minutes, hiding that the user has
178
+ // merged PRs in the repo).
179
+ const cached = cache.getIfFresh(cacheKey, MERGED_PR_CACHE_TTL_MS);
180
+ if (cached != null && typeof cached === "number") {
181
+ return cached;
147
182
  }
148
- finally {
149
- // Always record the call — failed requests still consume GitHub rate limit points
150
- tracker.recordCall();
183
+ try {
184
+ await tracker.waitForBudget();
185
+ try {
186
+ // Use @me to search as the authenticated user
187
+ const { data } = await octokit.search.issuesAndPullRequests({
188
+ q: `repo:${owner}/${repo} is:pr is:merged author:@me`,
189
+ per_page: 1, // We only need total_count
190
+ });
191
+ // Only cache successful results
192
+ cache.set(cacheKey, "", data.total_count);
193
+ return data.total_count;
194
+ }
195
+ finally {
196
+ // Always record the call — failed requests still consume GitHub rate limit points
197
+ tracker.recordCall();
198
+ }
151
199
  }
152
- }
153
- catch (error) {
154
- if (getHttpStatusCode(error) === 401 || isRateLimitError(error)) {
155
- throw error;
200
+ catch (error) {
201
+ rethrowIfFatal(error);
202
+ const errMsg = errorMessage(error);
203
+ warn(MODULE, `Could not check merged PRs in ${owner}/${repo}: ${errMsg}. Treating as unknown.`);
204
+ // null (not 0) so callers can tell a transient failure from a real zero
205
+ // and avoid caching verdicts built on it. Not cached — next call retries.
206
+ return null;
156
207
  }
157
- const errMsg = errorMessage(error);
158
- warn(MODULE, `Could not check merged PRs in ${owner}/${repo}: ${errMsg}. Defaulting to 0.`);
159
- return 0; // Not cached — next call will retry
160
- }
208
+ });
161
209
  }
162
210
  /**
163
211
  * Check whether an issue has been claimed by another contributor
@@ -167,27 +215,34 @@ export async function checkNotClaimed(octokit, owner, repo, issueNumber, comment
167
215
  if (commentCount === 0)
168
216
  return { passed: true };
169
217
  try {
170
- // Paginate through all comments
171
- const comments = await octokit.paginate(octokit.issues.listComments, {
172
- owner,
173
- repo,
174
- issue_number: issueNumber,
175
- per_page: 100,
176
- }, (response) => response.data);
177
- // Limit to last 100 comments to avoid excessive processing
178
- const recentComments = comments.slice(-100);
218
+ // Fetch only the newest comments. Walking every page cost a
219
+ // 2,000-comment issue 20 list calls per vet, then discarded all but the
220
+ // tail anyway. Claims live in recent activity, so fetch the last page
221
+ // (plus its predecessor so a short last page still yields ~100+
222
+ // comments): at most 2 calls.
223
+ const PER_PAGE = 100;
224
+ const lastPage = Math.max(1, Math.ceil(commentCount / PER_PAGE));
225
+ const pagesToFetch = lastPage > 1 ? [lastPage - 1, lastPage] : [1];
226
+ const recentComments = [];
227
+ for (const page of pagesToFetch) {
228
+ const response = await octokit.issues.listComments({
229
+ owner,
230
+ repo,
231
+ issue_number: issueNumber,
232
+ per_page: PER_PAGE,
233
+ page,
234
+ });
235
+ recentComments.push(...response.data);
236
+ }
179
237
  for (const comment of recentComments) {
180
- const body = (comment.body || "").toLowerCase();
181
- if (CLAIM_PHRASES.some((phrase) => body.includes(phrase))) {
238
+ if (commentClaimsIssue(comment.body || "")) {
182
239
  return { passed: false };
183
240
  }
184
241
  }
185
242
  return { passed: true };
186
243
  }
187
244
  catch (error) {
188
- if (getHttpStatusCode(error) === 401 || isRateLimitError(error)) {
189
- throw error;
190
- }
245
+ rethrowIfFatal(error);
191
246
  const errMsg = errorMessage(error);
192
247
  warn(MODULE, `Failed to check claim status on ${owner}/${repo}#${issueNumber}: ${errMsg}. Assuming not claimed.`);
193
248
  return { passed: true, inconclusive: true, reason: errMsg };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Batched GraphQL prefetch of issue "core" data (#169).
3
+ *
4
+ * `vetIssue` re-fetches each issue's basic fields (title, body, state, labels,
5
+ * timestamps, comment count) via a per-issue REST `issues.get`. When a search
6
+ * surfaces N issues that all need vetting, that is N separate REST calls before
7
+ * any of the deeper checks even start.
8
+ *
9
+ * `prefetchIssueCores` collapses those N calls into ONE aliased GraphQL query.
10
+ * The result is a map keyed by `owner/repo#number`; `vetIssue` consumes a hit
11
+ * instead of calling `issues.get`, and falls back to REST for any miss (a
12
+ * deleted issue, a permission error on one repo, or a non-fatal GraphQL blip).
13
+ *
14
+ * Scope is deliberately limited to the `issues.get` fields. The other vetting
15
+ * calls (timeline-based PR detection, claim scanning, project health,
16
+ * contribution guidelines) stay REST — batching those has pagination-semantics
17
+ * divergence risk and is left as a follow-up.
18
+ */
19
+ import type { Octokit } from "@octokit/rest";
20
+ /**
21
+ * Normalized issue fields equivalent to the subset of a REST `issues.get`
22
+ * response that `vetIssue` reads. Produced from either GraphQL (prefetch) or
23
+ * REST (fallback) so the two paths are interchangeable.
24
+ */
25
+ export interface PrefetchedIssueCore {
26
+ /** GitHub numeric database id (REST `id` / GraphQL `databaseId`). */
27
+ id: number;
28
+ title: string;
29
+ /** Empty string when the issue has no body (matches REST `body || ""`). */
30
+ body: string;
31
+ state: "open" | "closed";
32
+ /** Label names, in declared order. */
33
+ labels: string[];
34
+ /** Total comment count (REST `comments` / GraphQL `comments.totalCount`). */
35
+ commentCount: number;
36
+ createdAt: string;
37
+ updatedAt: string;
38
+ }
39
+ /** A single issue to prefetch. */
40
+ export interface IssueRef {
41
+ owner: string;
42
+ repo: string;
43
+ number: number;
44
+ }
45
+ /** Map key for a prefetched core, also used by callers to look one up. */
46
+ export declare function issueCoreKey(owner: string, repo: string, number: number): string;
47
+ /**
48
+ * Batch-fetch issue core data with one aliased GraphQL query. Returns a map of
49
+ * `owner/repo#number` to the normalized core. Issues that the query could not
50
+ * resolve are simply absent — the caller is expected to fall back to REST for
51
+ * any key not in the map.
52
+ *
53
+ * Failure handling mirrors the rest of the vetter: fatal errors (401 / rate
54
+ * limit) propagate via `rethrowIfFatal`; a partial-data GraphQL error (one bad
55
+ * issue in the batch) keeps the aliases that did resolve; any other non-fatal
56
+ * error returns whatever resolved so the caller degrades to all-REST.
57
+ */
58
+ export declare function prefetchIssueCores(octokit: Octokit, issues: IssueRef[]): Promise<Map<string, PrefetchedIssueCore>>;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Batched GraphQL prefetch of issue "core" data (#169).
3
+ *
4
+ * `vetIssue` re-fetches each issue's basic fields (title, body, state, labels,
5
+ * timestamps, comment count) via a per-issue REST `issues.get`. When a search
6
+ * surfaces N issues that all need vetting, that is N separate REST calls before
7
+ * any of the deeper checks even start.
8
+ *
9
+ * `prefetchIssueCores` collapses those N calls into ONE aliased GraphQL query.
10
+ * The result is a map keyed by `owner/repo#number`; `vetIssue` consumes a hit
11
+ * instead of calling `issues.get`, and falls back to REST for any miss (a
12
+ * deleted issue, a permission error on one repo, or a non-fatal GraphQL blip).
13
+ *
14
+ * Scope is deliberately limited to the `issues.get` fields. The other vetting
15
+ * calls (timeline-based PR detection, claim scanning, project health,
16
+ * contribution guidelines) stay REST — batching those has pagination-semantics
17
+ * divergence risk and is left as a follow-up.
18
+ */
19
+ import { rethrowIfFatal, errorMessage } from "./errors.js";
20
+ import { warn } from "./logger.js";
21
+ const MODULE = "issue-graphql";
22
+ /** Map key for a prefetched core, also used by callers to look one up. */
23
+ export function issueCoreKey(owner, repo, number) {
24
+ return `${owner}/${repo}#${number}`;
25
+ }
26
+ function normalizeNode(node) {
27
+ return {
28
+ id: node.databaseId,
29
+ title: node.title,
30
+ body: node.body ?? "",
31
+ state: node.state === "CLOSED" ? "closed" : "open",
32
+ labels: node.labels.nodes.map((l) => l.name),
33
+ commentCount: node.comments.totalCount,
34
+ createdAt: node.createdAt,
35
+ updatedAt: node.updatedAt,
36
+ };
37
+ }
38
+ /**
39
+ * Batch-fetch issue core data with one aliased GraphQL query. Returns a map of
40
+ * `owner/repo#number` to the normalized core. Issues that the query could not
41
+ * resolve are simply absent — the caller is expected to fall back to REST for
42
+ * any key not in the map.
43
+ *
44
+ * Failure handling mirrors the rest of the vetter: fatal errors (401 / rate
45
+ * limit) propagate via `rethrowIfFatal`; a partial-data GraphQL error (one bad
46
+ * issue in the batch) keeps the aliases that did resolve; any other non-fatal
47
+ * error returns whatever resolved so the caller degrades to all-REST.
48
+ */
49
+ export async function prefetchIssueCores(octokit, issues) {
50
+ const result = new Map();
51
+ if (issues.length === 0)
52
+ return result;
53
+ // Dedup by key so a repeated issue does not allocate a redundant alias.
54
+ const unique = [
55
+ ...new Map(issues.map((i) => [issueCoreKey(i.owner, i.repo, i.number), i])).values(),
56
+ ];
57
+ // Build a parameterized query — owner/repo/number go through GraphQL
58
+ // variables, never string-interpolated into the query body, so there is no
59
+ // injection surface even though parseGitHubUrl already validates them.
60
+ const varDefs = [];
61
+ const selections = [];
62
+ const variables = {};
63
+ unique.forEach((iss, i) => {
64
+ varDefs.push(`$o${i}: String!, $n${i}: String!, $num${i}: Int!`);
65
+ variables[`o${i}`] = iss.owner;
66
+ variables[`n${i}`] = iss.repo;
67
+ variables[`num${i}`] = iss.number;
68
+ selections.push(`i${i}: repository(owner: $o${i}, name: $n${i}) {
69
+ issue(number: $num${i}) {
70
+ databaseId
71
+ title
72
+ body
73
+ state
74
+ labels(first: 100) { nodes { name } }
75
+ comments { totalCount }
76
+ createdAt
77
+ updatedAt
78
+ }
79
+ }`);
80
+ });
81
+ const query = `query batchIssueCores(${varDefs.join(", ")}) {\n${selections.join("\n")}\n}`;
82
+ let data;
83
+ try {
84
+ data = await octokit.graphql(query, variables);
85
+ }
86
+ catch (err) {
87
+ rethrowIfFatal(err);
88
+ // octokit's GraphqlResponseError attaches the resolved aliases to `.data`
89
+ // when only some issues in the batch errored (e.g. one was deleted).
90
+ const partial = err.data;
91
+ if (partial) {
92
+ data = partial;
93
+ }
94
+ else {
95
+ warn(MODULE, `GraphQL prefetch failed, falling back to REST: ${errorMessage(err)}`);
96
+ return result;
97
+ }
98
+ }
99
+ unique.forEach((iss, i) => {
100
+ const node = data?.[`i${i}`]?.issue;
101
+ // A null node (deleted/inaccessible) or a null databaseId leaves the key
102
+ // absent so the caller fetches it via REST.
103
+ if (!node || node.databaseId == null)
104
+ return;
105
+ result.set(issueCoreKey(iss.owner, iss.repo, iss.number), normalizeNode(node));
106
+ });
107
+ return result;
108
+ }
@@ -7,7 +7,9 @@
7
7
  * - repo-health.ts — project health, contribution guidelines
8
8
  */
9
9
  import { Octokit } from "@octokit/rest";
10
- import { type SearchPriority, type IssueCandidate, type ProjectCategory } from "./types.js";
10
+ import { type SearchPriority, type IssueCandidate, type ProjectCategory, type ScoutPreferences, type ScoutState, type MergedPRRecord, type ClosedPRRecord, type OpenPRRecord } from "./types.js";
11
+ import { type PrefetchedIssueCore } from "./issue-graphql.js";
12
+ import { type SearchBudgetTracker } from "./search-budget.js";
11
13
  /**
12
14
  * Feature-mode signals supplied by the caller (orchestrator) — the vetter
13
15
  * does NOT extract these from the GitHub issue itself. When passed, they
@@ -30,6 +32,15 @@ export type FeatureSignals = {
30
32
  */
31
33
  wontfixNoContributor?: boolean;
32
34
  };
35
+ /**
36
+ * SLM pre-triage configuration (oss-autopilot#1122). `host` is the Ollama
37
+ * endpoint; an empty `host` means "use the triage default". A `null` config
38
+ * (not this shape) means SLM triage is disabled (#158).
39
+ */
40
+ export interface SLMConfig {
41
+ model: string;
42
+ host: string;
43
+ }
33
44
  /**
34
45
  * Read-only interface for accessing scout state during issue vetting.
35
46
  * Implementations may be backed by gist persistence, in-memory state, etc.
@@ -46,19 +57,101 @@ export interface ScoutStateReader {
46
57
  /** Numeric quality score for a repo, or null if not evaluated. */
47
58
  getRepoScore(repo: string): number | null;
48
59
  /**
49
- * SLM pre-triage config (oss-autopilot#1122). Returns the configured
50
- * model id and Ollama host, or empty strings when not configured —
51
- * vetIssue treats either of these as "skip the SLM call".
60
+ * SLM pre-triage config (oss-autopilot#1122). Returns the configured model
61
+ * id and Ollama host, or `null` when SLM triage is not configured — vetIssue
62
+ * skips the SLM call on `null`. Required (#158): the old optional method with
63
+ * an empty-string sentinel could not distinguish "not configured" from "host
64
+ * defaulted"; `SLMConfig | null` makes the absence explicit.
65
+ */
66
+ getSLMTriageConfig(): SLMConfig | null;
67
+ /**
68
+ * Number of the user's PRs closed without merge in this repo (#125).
69
+ * Optional so existing implementations keep compiling; absent reads as 0.
70
+ */
71
+ getClosedWithoutMergeCount?(repo: string): number;
72
+ /**
73
+ * The configured GitHub username, used to tell the user's own in-flight PR
74
+ * apart from a competing one (#166). Optional; absent reads as "unknown".
75
+ */
76
+ getGitHubUsername?(): string;
77
+ }
78
+ /**
79
+ * Write side of the scout state, consumed by the bootstrap flow. Defined here
80
+ * next to ScoutStateReader so core/bootstrap.ts can depend on this contract
81
+ * instead of importing the OssScout facade from the package root (an upward
82
+ * dependency). OssScout implements it (#156).
83
+ */
84
+ export interface ScoutStateWriter {
85
+ /** Read current preferences (bootstrap reads githubUsername). */
86
+ getPreferences(): Readonly<ScoutPreferences>;
87
+ /** Replace the cached starred-repo list. */
88
+ setStarredRepos(repos: string[]): void;
89
+ /** Record a merged PR (deduplicated by URL). */
90
+ recordMergedPR(pr: MergedPRRecord): void;
91
+ /** Record a PR closed without merge (deduplicated by URL). */
92
+ recordClosedPR(pr: ClosedPRRecord): void;
93
+ /** Record an open PR (deduplicated by URL). */
94
+ recordOpenPR(pr: OpenPRRecord): void;
95
+ /** Snapshot the current state (bootstrap reports counts from it). */
96
+ getState(): Readonly<ScoutState>;
97
+ }
98
+ /**
99
+ * Inputs to deriveRecommendation: the already-computed check results and
100
+ * affinity signals. Kept as a flat record of primitives so the derivation is a
101
+ * pure function, independently unit-testable (#157).
102
+ */
103
+ export interface RecommendationInput {
104
+ noExistingPR: boolean;
105
+ /**
106
+ * The linked PR is the user's own open PR (#166). When true, the existing-PR
107
+ * block is reframed as "you're already on it" — a skip with a clear reason —
108
+ * instead of a competing-PR penalty.
52
109
  */
53
- getSLMTriageConfig?(): {
54
- model: string;
55
- host: string;
56
- };
110
+ ownPR: boolean;
111
+ notClaimed: boolean;
112
+ clearRequirements: boolean;
113
+ contributionGuidelinesFound: boolean;
114
+ projectIsActive: boolean;
115
+ projectCheckFailed: boolean;
116
+ projectFailureReason?: string;
117
+ existingPRInconclusive: boolean;
118
+ existingPRReason?: string;
119
+ claimInconclusive: boolean;
120
+ claimReason?: string;
121
+ mergedCountInconclusive: boolean;
122
+ effectiveMergedCount: number;
123
+ orgName: string;
124
+ orgHasMergedPRs: boolean;
125
+ matchesCategory: boolean;
126
+ issueClosed: boolean;
127
+ /** noExistingPR && notClaimed && projectActive && clearRequirements. */
128
+ passedAllChecks: boolean;
129
+ }
130
+ export interface RecommendationOutput {
131
+ notes: string[];
132
+ reasonsToApprove: string[];
133
+ reasonsToSkip: string[];
134
+ recommendation: "approve" | "skip" | "needs_review";
57
135
  }
136
+ /**
137
+ * Derive the human-readable notes, approve/skip reasons, and the final
138
+ * recommendation from a vet's check results. Pure: no I/O, no state reads — the
139
+ * caller computes the inputs and threads them in. Extracted from vetIssue so
140
+ * the recommendation logic is testable in isolation (#157).
141
+ */
142
+ export declare function deriveRecommendation(input: RecommendationInput): RecommendationOutput;
58
143
  export declare class IssueVetter {
59
144
  private octokit;
60
145
  private stateReader;
61
- constructor(octokit: Octokit, stateReader: ScoutStateReader);
146
+ private budgetTracker;
147
+ /**
148
+ * @param octokit - Authenticated Octokit instance
149
+ * @param stateReader - Read-only scout state interface
150
+ * @param budgetTracker - Search budget tracker. Defaults to the shared
151
+ * singleton so existing callers behave identically; inject a per-search
152
+ * instance to isolate budget accounting in a long-lived concurrent host.
153
+ */
154
+ constructor(octokit: Octokit, stateReader: ScoutStateReader, budgetTracker?: SearchBudgetTracker);
62
155
  /**
63
156
  * Vet a specific issue — runs all checks and computes recommendation + viability score.
64
157
  * Results are cached for 15 minutes to avoid redundant API calls on repeated searches.
@@ -70,7 +163,20 @@ export declare class IssueVetter {
70
163
  */
71
164
  vetIssue(issueUrl: string, opts?: {
72
165
  featureSignals?: FeatureSignals;
166
+ /**
167
+ * Issue core data already fetched in a batch GraphQL query (#169). When
168
+ * present it replaces the per-issue REST `issues.get`; otherwise the
169
+ * core is fetched via REST. The two paths are normalized to the same
170
+ * shape so behaviour is identical either way.
171
+ */
172
+ prefetched?: PrefetchedIssueCore;
73
173
  }): Promise<IssueCandidate>;
174
+ /**
175
+ * Fetch a single issue's core fields via REST and normalize them to the same
176
+ * shape as a GraphQL prefetch (#169). The REST fallback path when no
177
+ * prefetched core was supplied.
178
+ */
179
+ private fetchIssueCore;
74
180
  /**
75
181
  * Vet multiple issues in parallel with concurrency limit
76
182
  */