@oss-scout/core 0.10.0 → 1.0.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 +77 -60
- package/dist/cli.js +403 -416
- 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 +7 -0
- package/dist/commands/search.js +63 -68
- 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 +4 -5
- 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 +3 -0
- package/dist/core/issue-discovery.js +51 -31
- package/dist/core/issue-eligibility.d.ts +10 -4
- package/dist/core/issue-eligibility.js +119 -67
- package/dist/core/issue-graphql.d.ts +58 -0
- package/dist/core/issue-graphql.js +108 -0
- package/dist/core/issue-vetting.d.ts +105 -8
- package/dist/core/issue-vetting.js +234 -107
- 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 +51 -18
- package/dist/core/personalization.js +101 -27
- package/dist/core/preference-fields.d.ts +47 -0
- package/dist/core/preference-fields.js +178 -0
- package/dist/core/repo-health.js +31 -15
- package/dist/core/roadmap.js +17 -3
- package/dist/core/schemas.d.ts +144 -26
- package/dist/core/schemas.js +74 -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 +0 -18
- package/dist/core/search-phases.js +27 -82
- package/dist/core/types.d.ts +146 -30
- package/dist/core/utils.js +60 -26
- 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 +59 -10
- package/dist/scout.js +244 -19
- package/package.json +1 -1
|
@@ -14,13 +14,108 @@ import { repoBelongsToCategory } from "./category-mapping.js";
|
|
|
14
14
|
import { checkNoExistingPR, checkNotClaimed, checkUserMergedPRsInRepo, analyzeRequirements, } from "./issue-eligibility.js";
|
|
15
15
|
import { checkProjectHealth, fetchContributionGuidelines, } from "./repo-health.js";
|
|
16
16
|
import { fetchAndScanAntiLLMPolicy } from "./anti-llm-policy.js";
|
|
17
|
-
import {
|
|
17
|
+
import { prefetchIssueCores, issueCoreKey, } from "./issue-graphql.js";
|
|
18
|
+
import { getHttpCache, versionedCacheKey } from "./http-cache.js";
|
|
18
19
|
import { triageWithSLM, buildTriageInput, } from "./slm-triage.js";
|
|
19
20
|
const MODULE = "issue-vetting";
|
|
20
21
|
/** Vetting concurrency: kept low to reduce burst pressure on GitHub's secondary rate limit. */
|
|
21
22
|
const MAX_CONCURRENT_VETTING = 3;
|
|
22
23
|
/** TTL for cached vetting results (15 minutes). Kept short so config changes take effect quickly. */
|
|
23
24
|
const VETTING_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* Derive the human-readable notes, approve/skip reasons, and the final
|
|
27
|
+
* recommendation from a vet's check results. Pure: no I/O, no state reads — the
|
|
28
|
+
* caller computes the inputs and threads them in. Extracted from vetIssue so
|
|
29
|
+
* the recommendation logic is testable in isolation (#157).
|
|
30
|
+
*/
|
|
31
|
+
export function deriveRecommendation(input) {
|
|
32
|
+
const notes = [];
|
|
33
|
+
const reasonsToApprove = [];
|
|
34
|
+
const reasonsToSkip = [];
|
|
35
|
+
// Notes (order preserved from the original vetIssue body).
|
|
36
|
+
if (!input.noExistingPR)
|
|
37
|
+
notes.push(input.ownPR
|
|
38
|
+
? "Your PR is already in flight for this issue"
|
|
39
|
+
: "Existing PR found for this issue");
|
|
40
|
+
if (!input.notClaimed)
|
|
41
|
+
notes.push("Issue appears to be claimed by someone");
|
|
42
|
+
if (input.existingPRInconclusive) {
|
|
43
|
+
notes.push(`Could not verify absence of existing PRs: ${input.existingPRReason || "API error"}`);
|
|
44
|
+
}
|
|
45
|
+
if (input.claimInconclusive) {
|
|
46
|
+
notes.push(`Could not verify claim status: ${input.claimReason || "API error"}`);
|
|
47
|
+
}
|
|
48
|
+
if (input.projectCheckFailed) {
|
|
49
|
+
notes.push(`Could not verify project activity: ${input.projectFailureReason || "API error"}`);
|
|
50
|
+
}
|
|
51
|
+
else if (!input.projectIsActive) {
|
|
52
|
+
notes.push("Project may be inactive");
|
|
53
|
+
}
|
|
54
|
+
if (!input.clearRequirements)
|
|
55
|
+
notes.push("Issue requirements are unclear");
|
|
56
|
+
if (!input.contributionGuidelinesFound)
|
|
57
|
+
notes.push("No CONTRIBUTING.md found");
|
|
58
|
+
// Reasons to skip / approve.
|
|
59
|
+
if (!input.noExistingPR)
|
|
60
|
+
reasonsToSkip.push(input.ownPR ? "You already have a PR in flight" : "Has existing PR");
|
|
61
|
+
if (!input.notClaimed)
|
|
62
|
+
reasonsToSkip.push("Already claimed");
|
|
63
|
+
if (!input.projectIsActive && !input.projectCheckFailed)
|
|
64
|
+
reasonsToSkip.push("Inactive project");
|
|
65
|
+
if (!input.clearRequirements)
|
|
66
|
+
reasonsToSkip.push("Unclear requirements");
|
|
67
|
+
if (input.noExistingPR)
|
|
68
|
+
reasonsToApprove.push("No existing PR");
|
|
69
|
+
if (input.notClaimed)
|
|
70
|
+
reasonsToApprove.push("Not claimed");
|
|
71
|
+
if (input.projectIsActive && !input.projectCheckFailed)
|
|
72
|
+
reasonsToApprove.push("Active project");
|
|
73
|
+
if (input.clearRequirements)
|
|
74
|
+
reasonsToApprove.push("Clear requirements");
|
|
75
|
+
if (input.contributionGuidelinesFound)
|
|
76
|
+
reasonsToApprove.push("Has contribution guidelines");
|
|
77
|
+
if (input.effectiveMergedCount > 0) {
|
|
78
|
+
reasonsToApprove.push(`Trusted project (${input.effectiveMergedCount} PR${input.effectiveMergedCount > 1 ? "s" : ""} merged)`);
|
|
79
|
+
}
|
|
80
|
+
if (input.orgHasMergedPRs) {
|
|
81
|
+
reasonsToApprove.push(`Org affinity (merged PRs in other ${input.orgName} repos)`);
|
|
82
|
+
}
|
|
83
|
+
if (input.matchesCategory) {
|
|
84
|
+
reasonsToApprove.push("Matches preferred project category");
|
|
85
|
+
}
|
|
86
|
+
if (input.issueClosed) {
|
|
87
|
+
reasonsToSkip.push("Issue is closed");
|
|
88
|
+
}
|
|
89
|
+
// Recommendation.
|
|
90
|
+
let recommendation;
|
|
91
|
+
if (input.issueClosed) {
|
|
92
|
+
recommendation = "skip";
|
|
93
|
+
}
|
|
94
|
+
else if (input.ownPR) {
|
|
95
|
+
// You're already working on this; don't re-surface it as competition.
|
|
96
|
+
recommendation = "skip";
|
|
97
|
+
}
|
|
98
|
+
else if (input.passedAllChecks) {
|
|
99
|
+
recommendation = "approve";
|
|
100
|
+
}
|
|
101
|
+
else if (reasonsToSkip.length > 2) {
|
|
102
|
+
recommendation = "skip";
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
recommendation = "needs_review";
|
|
106
|
+
}
|
|
107
|
+
// Downgrade an "approve" when any check was inconclusive — "approve" should
|
|
108
|
+
// only be given when checks actually passed, not when they were skipped.
|
|
109
|
+
const hasInconclusiveChecks = input.projectCheckFailed ||
|
|
110
|
+
input.existingPRInconclusive ||
|
|
111
|
+
input.claimInconclusive ||
|
|
112
|
+
input.mergedCountInconclusive;
|
|
113
|
+
if (recommendation === "approve" && hasInconclusiveChecks) {
|
|
114
|
+
recommendation = "needs_review";
|
|
115
|
+
notes.push("Recommendation downgraded: one or more checks were inconclusive");
|
|
116
|
+
}
|
|
117
|
+
return { notes, reasonsToApprove, reasonsToSkip, recommendation };
|
|
118
|
+
}
|
|
24
119
|
export class IssueVetter {
|
|
25
120
|
octokit;
|
|
26
121
|
stateReader;
|
|
@@ -43,7 +138,7 @@ export class IssueVetter {
|
|
|
43
138
|
const sigKey = opts?.featureSignals
|
|
44
139
|
? `:r${opts.featureSignals.reactions}c${opts.featureSignals.comments}m${opts.featureSignals.hasMilestone ? 1 : 0}o${opts.featureSignals.onRoadmap ? 1 : 0}w${opts.featureSignals.wontfixNoContributor ? 1 : 0}`
|
|
45
140
|
: "";
|
|
46
|
-
const cacheKey = `vet:${issueUrl}${sigKey}
|
|
141
|
+
const cacheKey = versionedCacheKey(`vet:${issueUrl}${sigKey}`);
|
|
47
142
|
const cached = cache.getIfFresh(cacheKey, VETTING_CACHE_TTL_MS);
|
|
48
143
|
if (cached &&
|
|
49
144
|
typeof cached === "object" &&
|
|
@@ -59,19 +154,18 @@ export class IssueVetter {
|
|
|
59
154
|
}
|
|
60
155
|
const { owner, repo, number } = parsed;
|
|
61
156
|
const repoFullName = `${owner}/${repo}`;
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
157
|
+
// Issue core data: use the batch-prefetched GraphQL result when the caller
|
|
158
|
+
// supplied one (#169), otherwise fetch it per-issue via REST. Both paths
|
|
159
|
+
// normalize to the same shape (fetchIssueCore), so downstream logic is
|
|
160
|
+
// identical regardless of source.
|
|
161
|
+
const core = opts?.prefetched ?? (await this.fetchIssueCore(owner, repo, number));
|
|
68
162
|
// Check if the user already has merged PRs in this repo (skip the Search API call)
|
|
69
163
|
const reposWithMergedPRs = this.stateReader.getReposWithMergedPRs();
|
|
70
164
|
const hasMergedPRsInRepo = reposWithMergedPRs.includes(repoFullName);
|
|
71
165
|
// Run all vetting checks in parallel — delegates to standalone functions
|
|
72
166
|
const [existingPRCheck, claimCheck, projectHealth, contributionGuidelines, userMergedPRCount,] = await Promise.all([
|
|
73
167
|
checkNoExistingPR(this.octokit, owner, repo, number),
|
|
74
|
-
checkNotClaimed(this.octokit, owner, repo, number,
|
|
168
|
+
checkNotClaimed(this.octokit, owner, repo, number, core.commentCount),
|
|
75
169
|
checkProjectHealth(this.octokit, owner, repo),
|
|
76
170
|
fetchContributionGuidelines(this.octokit, owner, repo),
|
|
77
171
|
hasMergedPRsInRepo
|
|
@@ -87,8 +181,18 @@ export class IssueVetter {
|
|
|
87
181
|
const antiLLMPolicy = await fetchAndScanAntiLLMPolicy(this.octokit, owner, repo, { contributingText: contributionGuidelines?.rawContent });
|
|
88
182
|
const noExistingPR = existingPRCheck.passed;
|
|
89
183
|
const notClaimed = claimCheck.passed;
|
|
184
|
+
// Is the linked PR the user's own open PR (#166)? If so the issue is
|
|
185
|
+
// "you're already on it", not competition.
|
|
186
|
+
const username = this.stateReader.getGitHubUsername?.() ?? "";
|
|
187
|
+
const linkedPR = existingPRCheck.linkedPR;
|
|
188
|
+
const ownPR = !noExistingPR &&
|
|
189
|
+
!!linkedPR &&
|
|
190
|
+
linkedPR.state === "open" &&
|
|
191
|
+
!linkedPR.merged &&
|
|
192
|
+
username !== "" &&
|
|
193
|
+
linkedPR.author.toLowerCase() === username.toLowerCase();
|
|
90
194
|
// Analyze issue quality
|
|
91
|
-
const clearRequirements = analyzeRequirements(
|
|
195
|
+
const clearRequirements = analyzeRequirements(core.body);
|
|
92
196
|
// When the health check itself failed (API error), use a neutral default:
|
|
93
197
|
// don't penalize the repo as inactive, but don't credit it as active either.
|
|
94
198
|
const projectActive = projectHealth.checkFailed
|
|
@@ -107,104 +211,76 @@ export class IssueVetter {
|
|
|
107
211
|
linkedPR: existingPRCheck.linkedPR,
|
|
108
212
|
notes: [],
|
|
109
213
|
};
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
vettingResult.notes.push("Existing PR found for this issue");
|
|
113
|
-
if (!notClaimed)
|
|
114
|
-
vettingResult.notes.push("Issue appears to be claimed by someone");
|
|
115
|
-
if (existingPRCheck.inconclusive) {
|
|
116
|
-
vettingResult.notes.push(`Could not verify absence of existing PRs: ${existingPRCheck.reason || "API error"}`);
|
|
117
|
-
}
|
|
118
|
-
if (claimCheck.inconclusive) {
|
|
119
|
-
vettingResult.notes.push(`Could not verify claim status: ${claimCheck.reason || "API error"}`);
|
|
120
|
-
}
|
|
121
|
-
if (projectHealth.checkFailed) {
|
|
122
|
-
vettingResult.notes.push(`Could not verify project activity: ${projectHealth.failureReason || "API error"}`);
|
|
123
|
-
}
|
|
124
|
-
else if (!projectHealth.isActive) {
|
|
125
|
-
vettingResult.notes.push("Project may be inactive");
|
|
126
|
-
}
|
|
127
|
-
if (!clearRequirements)
|
|
128
|
-
vettingResult.notes.push("Issue requirements are unclear");
|
|
129
|
-
if (!contributionGuidelines)
|
|
130
|
-
vettingResult.notes.push("No CONTRIBUTING.md found");
|
|
131
|
-
// Create tracked issue
|
|
214
|
+
// Create tracked issue. It holds a reference to vettingResult, whose
|
|
215
|
+
// notes are filled in by deriveRecommendation below.
|
|
132
216
|
const trackedIssue = {
|
|
133
|
-
id:
|
|
217
|
+
id: core.id,
|
|
134
218
|
url: issueUrl,
|
|
135
219
|
repo: repoFullName,
|
|
136
220
|
number,
|
|
137
|
-
title:
|
|
221
|
+
title: core.title,
|
|
138
222
|
status: "candidate",
|
|
139
|
-
labels:
|
|
140
|
-
createdAt:
|
|
141
|
-
updatedAt:
|
|
223
|
+
labels: core.labels,
|
|
224
|
+
createdAt: core.createdAt,
|
|
225
|
+
updatedAt: core.updatedAt,
|
|
142
226
|
vetted: true,
|
|
143
227
|
vettingResult,
|
|
144
228
|
};
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (!clearRequirements)
|
|
155
|
-
reasonsToSkip.push("Unclear requirements");
|
|
156
|
-
if (noExistingPR)
|
|
157
|
-
reasonsToApprove.push("No existing PR");
|
|
158
|
-
if (notClaimed)
|
|
159
|
-
reasonsToApprove.push("Not claimed");
|
|
160
|
-
if (projectHealth.isActive && !projectHealth.checkFailed)
|
|
161
|
-
reasonsToApprove.push("Active project");
|
|
162
|
-
if (clearRequirements)
|
|
163
|
-
reasonsToApprove.push("Clear requirements");
|
|
164
|
-
if (contributionGuidelines)
|
|
165
|
-
reasonsToApprove.push("Has contribution guidelines");
|
|
166
|
-
// Determine effective merged PR count: prefer local state (authoritative if present),
|
|
167
|
-
// fall back to live GitHub API count to detect contributions made before using oss-scout
|
|
168
|
-
const effectiveMergedCount = hasMergedPRsInRepo ? 1 : userMergedPRCount;
|
|
169
|
-
if (effectiveMergedCount > 0) {
|
|
170
|
-
reasonsToApprove.push(`Trusted project (${effectiveMergedCount} PR${effectiveMergedCount > 1 ? "s" : ""} merged)`);
|
|
171
|
-
}
|
|
172
|
-
// Check for org-level affinity (user has merged PRs in another repo under same org)
|
|
229
|
+
// Effective merged PR count: prefer local state (authoritative if present),
|
|
230
|
+
// fall back to live GitHub API count to detect contributions made before
|
|
231
|
+
// using oss-scout. null means the live check transiently failed: score as
|
|
232
|
+
// 0 but treat the result as inconclusive so it is not cached.
|
|
233
|
+
const mergedCountInconclusive = !hasMergedPRsInRepo && userMergedPRCount === null;
|
|
234
|
+
const effectiveMergedCount = hasMergedPRsInRepo
|
|
235
|
+
? 1
|
|
236
|
+
: (userMergedPRCount ?? 0);
|
|
237
|
+
// Org-level affinity (user has merged PRs in another repo under same org).
|
|
173
238
|
const orgName = repoFullName.split("/")[0];
|
|
174
239
|
let orgHasMergedPRs = false;
|
|
175
240
|
if (orgName && repoFullName.includes("/")) {
|
|
176
241
|
orgHasMergedPRs = reposWithMergedPRs.some((r) => r.startsWith(orgName + "/") && r !== repoFullName);
|
|
177
242
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (matchesCategory) {
|
|
185
|
-
reasonsToApprove.push("Matches preferred project category");
|
|
186
|
-
}
|
|
187
|
-
let recommendation;
|
|
188
|
-
if (vettingResult.passedAllChecks) {
|
|
189
|
-
recommendation = "approve";
|
|
190
|
-
}
|
|
191
|
-
else if (reasonsToSkip.length > 2) {
|
|
192
|
-
recommendation = "skip";
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
recommendation = "needs_review";
|
|
196
|
-
}
|
|
197
|
-
// Downgrade to needs_review if any check was inconclusive —
|
|
198
|
-
// "approve" should only be given when all checks actually passed, not when they were skipped.
|
|
243
|
+
const matchesCategory = repoBelongsToCategory(repoFullName, this.stateReader.getProjectCategories());
|
|
244
|
+
// GitHub answers 200 for closed issues, so without an explicit state
|
|
245
|
+
// check a closed issue would vet as still available (#120).
|
|
246
|
+
const issueClosed = core.state === "closed";
|
|
247
|
+
// Never cache a verdict built on inconclusive/failed checks (e.g. a
|
|
248
|
+
// transient 5xx): that would pin a degraded result for the whole TTL.
|
|
199
249
|
const hasInconclusiveChecks = projectHealth.checkFailed ||
|
|
200
|
-
existingPRCheck.inconclusive ||
|
|
201
|
-
claimCheck.inconclusive
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
250
|
+
!!existingPRCheck.inconclusive ||
|
|
251
|
+
!!claimCheck.inconclusive ||
|
|
252
|
+
mergedCountInconclusive;
|
|
253
|
+
const { notes, reasonsToApprove, reasonsToSkip, recommendation } = deriveRecommendation({
|
|
254
|
+
noExistingPR,
|
|
255
|
+
ownPR,
|
|
256
|
+
notClaimed,
|
|
257
|
+
clearRequirements,
|
|
258
|
+
contributionGuidelinesFound: !!contributionGuidelines,
|
|
259
|
+
projectIsActive: projectHealth.checkFailed
|
|
260
|
+
? false
|
|
261
|
+
: projectHealth.isActive,
|
|
262
|
+
projectCheckFailed: !!projectHealth.checkFailed,
|
|
263
|
+
projectFailureReason: projectHealth.failureReason,
|
|
264
|
+
existingPRInconclusive: !!existingPRCheck.inconclusive,
|
|
265
|
+
existingPRReason: existingPRCheck.inconclusive
|
|
266
|
+
? existingPRCheck.reason
|
|
267
|
+
: undefined,
|
|
268
|
+
claimInconclusive: !!claimCheck.inconclusive,
|
|
269
|
+
claimReason: claimCheck.inconclusive ? claimCheck.reason : undefined,
|
|
270
|
+
mergedCountInconclusive,
|
|
271
|
+
effectiveMergedCount,
|
|
272
|
+
orgName,
|
|
273
|
+
orgHasMergedPRs,
|
|
274
|
+
matchesCategory,
|
|
275
|
+
issueClosed,
|
|
276
|
+
passedAllChecks: vettingResult.passedAllChecks,
|
|
277
|
+
});
|
|
278
|
+
vettingResult.notes = notes;
|
|
279
|
+
// Calculate repo quality bonus from star/fork counts. A failed health
|
|
280
|
+
// check carries no counts (#158), so the bonus is 0 in that case.
|
|
281
|
+
const repoQualityBonus = projectHealth.checkFailed
|
|
282
|
+
? 0
|
|
283
|
+
: calculateRepoQualityBonus(projectHealth.stargazersCount ?? 0, projectHealth.forksCount ?? 0);
|
|
208
284
|
if (projectHealth.checkFailed && repoQualityBonus === 0) {
|
|
209
285
|
vettingResult.notes.push("Repo quality bonus unavailable: could not fetch star/fork counts due to API error");
|
|
210
286
|
}
|
|
@@ -215,8 +291,8 @@ export class IssueVetter {
|
|
|
215
291
|
isClaimed: !notClaimed,
|
|
216
292
|
clearRequirements,
|
|
217
293
|
hasContributionGuidelines: !!contributionGuidelines,
|
|
218
|
-
issueUpdatedAt:
|
|
219
|
-
closedWithoutMergeCount: 0,
|
|
294
|
+
issueUpdatedAt: core.updatedAt,
|
|
295
|
+
closedWithoutMergeCount: this.stateReader.getClosedWithoutMergeCount?.(repoFullName) ?? 0,
|
|
220
296
|
mergedPRCount: effectiveMergedCount,
|
|
221
297
|
orgHasMergedPRs,
|
|
222
298
|
repoQualityBonus,
|
|
@@ -232,23 +308,22 @@ export class IssueVetter {
|
|
|
232
308
|
searchPriority = "starred";
|
|
233
309
|
}
|
|
234
310
|
// Optional SLM pre-triage (oss-autopilot#1122). Fail-open: any error
|
|
235
|
-
// path returns null and the rest of the pipeline is unaffected.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
host: "",
|
|
239
|
-
};
|
|
311
|
+
// path returns null and the rest of the pipeline is unaffected. A null
|
|
312
|
+
// config means SLM triage is disabled (#158).
|
|
313
|
+
const slmConfig = this.stateReader.getSLMTriageConfig();
|
|
240
314
|
let slmTriage = null;
|
|
241
|
-
if (slmConfig
|
|
315
|
+
if (slmConfig) {
|
|
242
316
|
const slmOpts = { model: slmConfig.model };
|
|
243
317
|
if (slmConfig.host)
|
|
244
318
|
slmOpts.host = slmConfig.host;
|
|
245
319
|
slmTriage = await triageWithSLM(buildTriageInput({
|
|
246
|
-
issue: { ...trackedIssue, body:
|
|
320
|
+
issue: { ...trackedIssue, body: core.body },
|
|
247
321
|
linkedPR: existingPRCheck.linkedPR ?? null,
|
|
248
322
|
}), slmOpts);
|
|
249
323
|
}
|
|
250
324
|
const result = {
|
|
251
325
|
issue: trackedIssue,
|
|
326
|
+
issueState: issueClosed ? "closed" : "open",
|
|
252
327
|
vettingResult,
|
|
253
328
|
projectHealth,
|
|
254
329
|
antiLLMPolicy,
|
|
@@ -259,10 +334,38 @@ export class IssueVetter {
|
|
|
259
334
|
viabilityScore,
|
|
260
335
|
searchPriority,
|
|
261
336
|
};
|
|
262
|
-
// Cache the vetting result to avoid redundant API calls on repeated
|
|
263
|
-
cache
|
|
337
|
+
// Cache the vetting result to avoid redundant API calls on repeated
|
|
338
|
+
// searches — but never cache results built on inconclusive/failed checks
|
|
339
|
+
// (e.g. a transient 5xx): that would pin a degraded verdict for the whole
|
|
340
|
+
// TTL. Next vet retries instead. Mirrors the error-path rule in
|
|
341
|
+
// issue-eligibility's checkUserMergedPRsInRepo.
|
|
342
|
+
if (!hasInconclusiveChecks) {
|
|
343
|
+
cache.set(cacheKey, "", result);
|
|
344
|
+
}
|
|
264
345
|
return result;
|
|
265
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Fetch a single issue's core fields via REST and normalize them to the same
|
|
349
|
+
* shape as a GraphQL prefetch (#169). The REST fallback path when no
|
|
350
|
+
* prefetched core was supplied.
|
|
351
|
+
*/
|
|
352
|
+
async fetchIssueCore(owner, repo, number) {
|
|
353
|
+
const { data: ghIssue } = await this.octokit.issues.get({
|
|
354
|
+
owner,
|
|
355
|
+
repo,
|
|
356
|
+
issue_number: number,
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
id: ghIssue.id,
|
|
360
|
+
title: ghIssue.title,
|
|
361
|
+
body: ghIssue.body || "",
|
|
362
|
+
state: ghIssue.state === "closed" ? "closed" : "open",
|
|
363
|
+
labels: ghIssue.labels.map((l) => typeof l === "string" ? l : l.name || ""),
|
|
364
|
+
commentCount: ghIssue.comments,
|
|
365
|
+
createdAt: ghIssue.created_at,
|
|
366
|
+
updatedAt: ghIssue.updated_at,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
266
369
|
/**
|
|
267
370
|
* Vet multiple issues in parallel with concurrency limit
|
|
268
371
|
*/
|
|
@@ -277,13 +380,37 @@ export class IssueVetter {
|
|
|
277
380
|
// the token is invalid and no other issue will succeed either —
|
|
278
381
|
// continuing to log per-issue warnings buries the actual problem.
|
|
279
382
|
let firstAuthError = null;
|
|
280
|
-
|
|
383
|
+
// Dedup defensively: the pending map is keyed by URL, so a duplicate
|
|
384
|
+
// input would overwrite the in-flight entry and its finally-cleanup
|
|
385
|
+
// would deregister the second task, letting allSettled return while a
|
|
386
|
+
// vet still runs (#129). Callers dedup today, but nothing enforced it.
|
|
387
|
+
const uniqueUrls = [...new Set(urls)];
|
|
388
|
+
// Batch-prefetch issue core data in one GraphQL query (#169), replacing N
|
|
389
|
+
// per-issue REST `issues.get` calls. Any URL the prefetch can't resolve
|
|
390
|
+
// (parse failure, deleted issue, non-fatal GraphQL error) is simply absent
|
|
391
|
+
// from the map and vetIssue falls back to REST for it. A fatal error (401 /
|
|
392
|
+
// rate limit) propagates out of prefetchIssueCores and aborts the batch,
|
|
393
|
+
// matching the per-issue auth-error handling below.
|
|
394
|
+
const prefetched = await prefetchIssueCores(this.octokit, uniqueUrls.flatMap((url) => {
|
|
395
|
+
const p = parseGitHubUrl(url);
|
|
396
|
+
return p && p.type === "issues"
|
|
397
|
+
? [{ owner: p.owner, repo: p.repo, number: p.number }]
|
|
398
|
+
: [];
|
|
399
|
+
}));
|
|
400
|
+
const prefetchFor = (url) => {
|
|
401
|
+
const p = parseGitHubUrl(url);
|
|
402
|
+
return p && p.type === "issues"
|
|
403
|
+
? prefetched.get(issueCoreKey(p.owner, p.repo, p.number))
|
|
404
|
+
: undefined;
|
|
405
|
+
};
|
|
406
|
+
for (const url of uniqueUrls) {
|
|
281
407
|
if (candidates.length >= maxResults)
|
|
282
408
|
break;
|
|
283
409
|
if (firstAuthError)
|
|
284
410
|
break; // stop scheduling once auth has failed
|
|
285
411
|
attemptedCount++;
|
|
286
|
-
const
|
|
412
|
+
const core = prefetchFor(url);
|
|
413
|
+
const task = this.vetIssue(url, core ? { prefetched: core } : undefined)
|
|
287
414
|
.then((candidate) => {
|
|
288
415
|
if (candidates.length < maxResults) {
|
|
289
416
|
// Override the priority if provided
|
|
@@ -11,6 +11,10 @@ export declare function hasLocalState(): boolean;
|
|
|
11
11
|
*/
|
|
12
12
|
export declare function loadLocalState(): ScoutState;
|
|
13
13
|
/**
|
|
14
|
-
* Save state to local file using atomic write (write to
|
|
14
|
+
* Save state to local file using atomic write (write to a unique tmp file,
|
|
15
|
+
* then rename). A fixed ".tmp" path let two concurrent processes interleave
|
|
16
|
+
* write/rename and crash one of them with ENOENT. The load-mutate-save cycle
|
|
17
|
+
* is still last-writer-wins (no lock), but each save is now atomic and
|
|
18
|
+
* crash-free on its own.
|
|
15
19
|
*/
|
|
16
|
-
export declare function saveLocalState(state: ScoutState): void;
|
|
20
|
+
export declare function saveLocalState(state: Readonly<ScoutState>): void;
|
package/dist/core/local-state.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as fs from "fs";
|
|
5
5
|
import * as path from "path";
|
|
6
|
-
import { ScoutStateSchema } from "./schemas.js";
|
|
6
|
+
import { ScoutStateSchema, parseScoutState } from "./schemas.js";
|
|
7
7
|
import { getDataDir } from "./utils.js";
|
|
8
8
|
import { debug, warn } from "./logger.js";
|
|
9
9
|
import { errorMessage } from "./errors.js";
|
|
@@ -24,7 +24,7 @@ export function loadLocalState() {
|
|
|
24
24
|
const statePath = getStatePath();
|
|
25
25
|
try {
|
|
26
26
|
const raw = fs.readFileSync(statePath, "utf-8");
|
|
27
|
-
return
|
|
27
|
+
return parseScoutState(JSON.parse(raw));
|
|
28
28
|
}
|
|
29
29
|
catch (err) {
|
|
30
30
|
const code = err?.code;
|
|
@@ -45,14 +45,32 @@ export function loadLocalState() {
|
|
|
45
45
|
return ScoutStateSchema.parse({ version: 1 });
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
/** Monotonic counter so two saves in one process never share a tmp path. */
|
|
49
|
+
let tmpCounter = 0;
|
|
48
50
|
/**
|
|
49
|
-
* Save state to local file using atomic write (write to
|
|
51
|
+
* Save state to local file using atomic write (write to a unique tmp file,
|
|
52
|
+
* then rename). A fixed ".tmp" path let two concurrent processes interleave
|
|
53
|
+
* write/rename and crash one of them with ENOENT. The load-mutate-save cycle
|
|
54
|
+
* is still last-writer-wins (no lock), but each save is now atomic and
|
|
55
|
+
* crash-free on its own.
|
|
50
56
|
*/
|
|
51
57
|
export function saveLocalState(state) {
|
|
52
58
|
const statePath = getStatePath();
|
|
53
|
-
const tmpPath = statePath
|
|
59
|
+
const tmpPath = `${statePath}.tmp.${process.pid}.${tmpCounter++}`;
|
|
54
60
|
const data = JSON.stringify(state, null, 2) + "\n";
|
|
55
61
|
fs.writeFileSync(tmpPath, data, { mode: 0o600 });
|
|
56
|
-
|
|
62
|
+
try {
|
|
63
|
+
fs.renameSync(tmpPath, statePath);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
// Do not leave an orphan tmp file behind on a failed rename
|
|
67
|
+
try {
|
|
68
|
+
fs.unlinkSync(tmpPath);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// already gone
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
57
75
|
debug(MODULE, "State saved");
|
|
58
76
|
}
|
package/dist/core/logger.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Lightweight
|
|
3
|
-
* Activated by the global --debug CLI flag.
|
|
2
|
+
* Lightweight leveled logger for oss-scout.
|
|
4
3
|
*
|
|
5
|
-
* All
|
|
6
|
-
* the --
|
|
4
|
+
* All output goes to stderr so it never contaminates the --json stdout
|
|
5
|
+
* contract. The CLI raises the level to "debug" via the global --debug flag;
|
|
6
|
+
* library hosts that don't want oss-scout's "[INFO] Phase 0..." chatter can
|
|
7
|
+
* lower it with setLogLevel (or ScoutConfig.logLevel) — including to "silent"
|
|
8
|
+
* (#156).
|
|
7
9
|
*/
|
|
10
|
+
export type LogLevel = "silent" | "warn" | "info" | "debug";
|
|
11
|
+
/** Set the minimum level that will be emitted. */
|
|
12
|
+
export declare function setLogLevel(level: LogLevel): void;
|
|
13
|
+
/** Current minimum emitted level. */
|
|
14
|
+
export declare function getLogLevel(): LogLevel;
|
|
15
|
+
/** Raise the level to "debug" (used by the CLI --debug flag). */
|
|
8
16
|
export declare function enableDebug(): void;
|
|
9
17
|
export declare function debug(module: string, message: string, ...args: unknown[]): void;
|
|
10
18
|
export declare function info(module: string, message: string, ...args: unknown[]): void;
|
package/dist/core/logger.js
CHANGED
|
@@ -1,25 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Lightweight
|
|
3
|
-
* Activated by the global --debug CLI flag.
|
|
2
|
+
* Lightweight leveled logger for oss-scout.
|
|
4
3
|
*
|
|
5
|
-
* All
|
|
6
|
-
* the --
|
|
4
|
+
* All output goes to stderr so it never contaminates the --json stdout
|
|
5
|
+
* contract. The CLI raises the level to "debug" via the global --debug flag;
|
|
6
|
+
* library hosts that don't want oss-scout's "[INFO] Phase 0..." chatter can
|
|
7
|
+
* lower it with setLogLevel (or ScoutConfig.logLevel) — including to "silent"
|
|
8
|
+
* (#156).
|
|
7
9
|
*/
|
|
8
|
-
|
|
10
|
+
const LEVEL_RANK = {
|
|
11
|
+
silent: 0,
|
|
12
|
+
warn: 1,
|
|
13
|
+
info: 2,
|
|
14
|
+
debug: 3,
|
|
15
|
+
};
|
|
16
|
+
// Default "info" preserves the historical behavior: info + warn emit, debug is
|
|
17
|
+
// suppressed until --debug (enableDebug) or setLogLevel raises the level.
|
|
18
|
+
let currentLevel = "info";
|
|
19
|
+
/** Set the minimum level that will be emitted. */
|
|
20
|
+
export function setLogLevel(level) {
|
|
21
|
+
currentLevel = level;
|
|
22
|
+
}
|
|
23
|
+
/** Current minimum emitted level. */
|
|
24
|
+
export function getLogLevel() {
|
|
25
|
+
return currentLevel;
|
|
26
|
+
}
|
|
27
|
+
/** Raise the level to "debug" (used by the CLI --debug flag). */
|
|
9
28
|
export function enableDebug() {
|
|
10
|
-
|
|
29
|
+
currentLevel = "debug";
|
|
30
|
+
}
|
|
31
|
+
function shouldLog(level) {
|
|
32
|
+
return LEVEL_RANK[currentLevel] >= LEVEL_RANK[level];
|
|
11
33
|
}
|
|
12
34
|
export function debug(module, message, ...args) {
|
|
13
|
-
if (!
|
|
35
|
+
if (!shouldLog("debug"))
|
|
14
36
|
return;
|
|
15
37
|
const timestamp = new Date().toISOString();
|
|
16
38
|
console.error(`[${timestamp}] [DEBUG] [${module}] ${message}`, ...args);
|
|
17
39
|
}
|
|
18
40
|
export function info(module, message, ...args) {
|
|
41
|
+
if (!shouldLog("info"))
|
|
42
|
+
return;
|
|
19
43
|
const timestamp = new Date().toISOString();
|
|
20
44
|
console.error(`[${timestamp}] [INFO] [${module}] ${message}`, ...args);
|
|
21
45
|
}
|
|
22
46
|
export function warn(module, message, ...args) {
|
|
47
|
+
if (!shouldLog("warn"))
|
|
48
|
+
return;
|
|
23
49
|
const timestamp = new Date().toISOString();
|
|
24
50
|
console.error(`[${timestamp}] [WARN] [${module}] ${message}`, ...args);
|
|
25
51
|
}
|