@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.
- package/dist/cli.bundle.cjs +89 -66
- package/dist/cli.js +302 -436
- package/dist/commands/command-scout.d.ts +21 -0
- package/dist/commands/command-scout.js +21 -0
- package/dist/commands/config.js +10 -128
- package/dist/commands/features.js +15 -28
- package/dist/commands/results.d.ts +13 -2
- package/dist/commands/results.js +29 -2
- package/dist/commands/search.d.ts +4 -0
- package/dist/commands/search.js +65 -70
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +35 -6
- package/dist/commands/skip.d.ts +4 -0
- package/dist/commands/skip.js +45 -55
- package/dist/commands/sync.d.ts +10 -0
- package/dist/commands/sync.js +10 -0
- package/dist/commands/vet-list.js +3 -19
- package/dist/commands/vet.js +18 -25
- package/dist/commands/with-scout.d.ts +32 -0
- package/dist/commands/with-scout.js +41 -0
- package/dist/core/anti-llm-policy.js +5 -33
- package/dist/core/bootstrap.d.ts +2 -2
- package/dist/core/bootstrap.js +5 -9
- package/dist/core/errors.d.ts +10 -0
- package/dist/core/errors.js +20 -5
- package/dist/core/feature-discovery.d.ts +13 -1
- package/dist/core/feature-discovery.js +104 -81
- package/dist/core/gist-state-store.d.ts +13 -12
- package/dist/core/gist-state-store.js +128 -53
- package/dist/core/http-cache.d.ts +32 -2
- package/dist/core/http-cache.js +74 -19
- package/dist/core/issue-discovery.d.ts +12 -1
- package/dist/core/issue-discovery.js +94 -67
- package/dist/core/issue-eligibility.d.ts +11 -4
- package/dist/core/issue-eligibility.js +124 -69
- package/dist/core/issue-graphql.d.ts +58 -0
- package/dist/core/issue-graphql.js +108 -0
- package/dist/core/issue-vetting.d.ts +115 -9
- package/dist/core/issue-vetting.js +246 -109
- package/dist/core/local-state.d.ts +6 -2
- package/dist/core/local-state.js +23 -5
- package/dist/core/logger.d.ts +12 -4
- package/dist/core/logger.js +33 -7
- package/dist/core/personalization.d.ts +30 -10
- package/dist/core/personalization.js +64 -24
- package/dist/core/preference-fields.d.ts +47 -0
- package/dist/core/preference-fields.js +180 -0
- package/dist/core/probe-repo-file.d.ts +47 -0
- package/dist/core/probe-repo-file.js +57 -0
- package/dist/core/repo-health.js +40 -32
- package/dist/core/roadmap.js +26 -22
- package/dist/core/schemas.d.ts +148 -26
- package/dist/core/schemas.js +83 -17
- package/dist/core/search-budget.d.ts +9 -0
- package/dist/core/search-budget.js +36 -3
- package/dist/core/search-phases.d.ts +4 -21
- package/dist/core/search-phases.js +37 -89
- package/dist/core/types.d.ts +151 -38
- package/dist/core/utils.js +60 -26
- package/dist/formatters/human.d.ts +60 -0
- package/dist/formatters/human.js +199 -0
- package/dist/formatters/markdown.d.ts +10 -0
- package/dist/formatters/markdown.js +31 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +8 -0
- package/dist/scout.d.ts +75 -12
- package/dist/scout.js +265 -26
- 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,
|
|
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
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const recentComments =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
*/
|