@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
|
@@ -14,19 +14,124 @@ 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";
|
|
19
|
+
import { getSearchBudgetTracker, } from "./search-budget.js";
|
|
18
20
|
import { triageWithSLM, buildTriageInput, } from "./slm-triage.js";
|
|
19
21
|
const MODULE = "issue-vetting";
|
|
20
22
|
/** Vetting concurrency: kept low to reduce burst pressure on GitHub's secondary rate limit. */
|
|
21
23
|
const MAX_CONCURRENT_VETTING = 3;
|
|
22
24
|
/** TTL for cached vetting results (15 minutes). Kept short so config changes take effect quickly. */
|
|
23
25
|
const VETTING_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
26
|
+
/**
|
|
27
|
+
* Derive the human-readable notes, approve/skip reasons, and the final
|
|
28
|
+
* recommendation from a vet's check results. Pure: no I/O, no state reads — the
|
|
29
|
+
* caller computes the inputs and threads them in. Extracted from vetIssue so
|
|
30
|
+
* the recommendation logic is testable in isolation (#157).
|
|
31
|
+
*/
|
|
32
|
+
export function deriveRecommendation(input) {
|
|
33
|
+
const notes = [];
|
|
34
|
+
const reasonsToApprove = [];
|
|
35
|
+
const reasonsToSkip = [];
|
|
36
|
+
// Notes (order preserved from the original vetIssue body).
|
|
37
|
+
if (!input.noExistingPR)
|
|
38
|
+
notes.push(input.ownPR
|
|
39
|
+
? "Your PR is already in flight for this issue"
|
|
40
|
+
: "Existing PR found for this issue");
|
|
41
|
+
if (!input.notClaimed)
|
|
42
|
+
notes.push("Issue appears to be claimed by someone");
|
|
43
|
+
if (input.existingPRInconclusive) {
|
|
44
|
+
notes.push(`Could not verify absence of existing PRs: ${input.existingPRReason || "API error"}`);
|
|
45
|
+
}
|
|
46
|
+
if (input.claimInconclusive) {
|
|
47
|
+
notes.push(`Could not verify claim status: ${input.claimReason || "API error"}`);
|
|
48
|
+
}
|
|
49
|
+
if (input.projectCheckFailed) {
|
|
50
|
+
notes.push(`Could not verify project activity: ${input.projectFailureReason || "API error"}`);
|
|
51
|
+
}
|
|
52
|
+
else if (!input.projectIsActive) {
|
|
53
|
+
notes.push("Project may be inactive");
|
|
54
|
+
}
|
|
55
|
+
if (!input.clearRequirements)
|
|
56
|
+
notes.push("Issue requirements are unclear");
|
|
57
|
+
if (!input.contributionGuidelinesFound)
|
|
58
|
+
notes.push("No CONTRIBUTING.md found");
|
|
59
|
+
// Reasons to skip / approve.
|
|
60
|
+
if (!input.noExistingPR)
|
|
61
|
+
reasonsToSkip.push(input.ownPR ? "You already have a PR in flight" : "Has existing PR");
|
|
62
|
+
if (!input.notClaimed)
|
|
63
|
+
reasonsToSkip.push("Already claimed");
|
|
64
|
+
if (!input.projectIsActive && !input.projectCheckFailed)
|
|
65
|
+
reasonsToSkip.push("Inactive project");
|
|
66
|
+
if (!input.clearRequirements)
|
|
67
|
+
reasonsToSkip.push("Unclear requirements");
|
|
68
|
+
if (input.noExistingPR)
|
|
69
|
+
reasonsToApprove.push("No existing PR");
|
|
70
|
+
if (input.notClaimed)
|
|
71
|
+
reasonsToApprove.push("Not claimed");
|
|
72
|
+
if (input.projectIsActive && !input.projectCheckFailed)
|
|
73
|
+
reasonsToApprove.push("Active project");
|
|
74
|
+
if (input.clearRequirements)
|
|
75
|
+
reasonsToApprove.push("Clear requirements");
|
|
76
|
+
if (input.contributionGuidelinesFound)
|
|
77
|
+
reasonsToApprove.push("Has contribution guidelines");
|
|
78
|
+
if (input.effectiveMergedCount > 0) {
|
|
79
|
+
reasonsToApprove.push(`Trusted project (${input.effectiveMergedCount} PR${input.effectiveMergedCount > 1 ? "s" : ""} merged)`);
|
|
80
|
+
}
|
|
81
|
+
if (input.orgHasMergedPRs) {
|
|
82
|
+
reasonsToApprove.push(`Org affinity (merged PRs in other ${input.orgName} repos)`);
|
|
83
|
+
}
|
|
84
|
+
if (input.matchesCategory) {
|
|
85
|
+
reasonsToApprove.push("Matches preferred project category");
|
|
86
|
+
}
|
|
87
|
+
if (input.issueClosed) {
|
|
88
|
+
reasonsToSkip.push("Issue is closed");
|
|
89
|
+
}
|
|
90
|
+
// Recommendation.
|
|
91
|
+
let recommendation;
|
|
92
|
+
if (input.issueClosed) {
|
|
93
|
+
recommendation = "skip";
|
|
94
|
+
}
|
|
95
|
+
else if (input.ownPR) {
|
|
96
|
+
// You're already working on this; don't re-surface it as competition.
|
|
97
|
+
recommendation = "skip";
|
|
98
|
+
}
|
|
99
|
+
else if (input.passedAllChecks) {
|
|
100
|
+
recommendation = "approve";
|
|
101
|
+
}
|
|
102
|
+
else if (reasonsToSkip.length > 2) {
|
|
103
|
+
recommendation = "skip";
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
recommendation = "needs_review";
|
|
107
|
+
}
|
|
108
|
+
// Downgrade an "approve" when any check was inconclusive — "approve" should
|
|
109
|
+
// only be given when checks actually passed, not when they were skipped.
|
|
110
|
+
const hasInconclusiveChecks = input.projectCheckFailed ||
|
|
111
|
+
input.existingPRInconclusive ||
|
|
112
|
+
input.claimInconclusive ||
|
|
113
|
+
input.mergedCountInconclusive;
|
|
114
|
+
if (recommendation === "approve" && hasInconclusiveChecks) {
|
|
115
|
+
recommendation = "needs_review";
|
|
116
|
+
notes.push("Recommendation downgraded: one or more checks were inconclusive");
|
|
117
|
+
}
|
|
118
|
+
return { notes, reasonsToApprove, reasonsToSkip, recommendation };
|
|
119
|
+
}
|
|
24
120
|
export class IssueVetter {
|
|
25
121
|
octokit;
|
|
26
122
|
stateReader;
|
|
27
|
-
|
|
123
|
+
budgetTracker;
|
|
124
|
+
/**
|
|
125
|
+
* @param octokit - Authenticated Octokit instance
|
|
126
|
+
* @param stateReader - Read-only scout state interface
|
|
127
|
+
* @param budgetTracker - Search budget tracker. Defaults to the shared
|
|
128
|
+
* singleton so existing callers behave identically; inject a per-search
|
|
129
|
+
* instance to isolate budget accounting in a long-lived concurrent host.
|
|
130
|
+
*/
|
|
131
|
+
constructor(octokit, stateReader, budgetTracker = getSearchBudgetTracker()) {
|
|
28
132
|
this.octokit = octokit;
|
|
29
133
|
this.stateReader = stateReader;
|
|
134
|
+
this.budgetTracker = budgetTracker;
|
|
30
135
|
}
|
|
31
136
|
/**
|
|
32
137
|
* Vet a specific issue — runs all checks and computes recommendation + viability score.
|
|
@@ -43,7 +148,7 @@ export class IssueVetter {
|
|
|
43
148
|
const sigKey = opts?.featureSignals
|
|
44
149
|
? `: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
150
|
: "";
|
|
46
|
-
const cacheKey = `vet:${issueUrl}${sigKey}
|
|
151
|
+
const cacheKey = versionedCacheKey(`vet:${issueUrl}${sigKey}`);
|
|
47
152
|
const cached = cache.getIfFresh(cacheKey, VETTING_CACHE_TTL_MS);
|
|
48
153
|
if (cached &&
|
|
49
154
|
typeof cached === "object" &&
|
|
@@ -59,24 +164,23 @@ export class IssueVetter {
|
|
|
59
164
|
}
|
|
60
165
|
const { owner, repo, number } = parsed;
|
|
61
166
|
const repoFullName = `${owner}/${repo}`;
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
167
|
+
// Issue core data: use the batch-prefetched GraphQL result when the caller
|
|
168
|
+
// supplied one (#169), otherwise fetch it per-issue via REST. Both paths
|
|
169
|
+
// normalize to the same shape (fetchIssueCore), so downstream logic is
|
|
170
|
+
// identical regardless of source.
|
|
171
|
+
const core = opts?.prefetched ?? (await this.fetchIssueCore(owner, repo, number));
|
|
68
172
|
// Check if the user already has merged PRs in this repo (skip the Search API call)
|
|
69
173
|
const reposWithMergedPRs = this.stateReader.getReposWithMergedPRs();
|
|
70
174
|
const hasMergedPRsInRepo = reposWithMergedPRs.includes(repoFullName);
|
|
71
175
|
// Run all vetting checks in parallel — delegates to standalone functions
|
|
72
176
|
const [existingPRCheck, claimCheck, projectHealth, contributionGuidelines, userMergedPRCount,] = await Promise.all([
|
|
73
177
|
checkNoExistingPR(this.octokit, owner, repo, number),
|
|
74
|
-
checkNotClaimed(this.octokit, owner, repo, number,
|
|
178
|
+
checkNotClaimed(this.octokit, owner, repo, number, core.commentCount),
|
|
75
179
|
checkProjectHealth(this.octokit, owner, repo),
|
|
76
180
|
fetchContributionGuidelines(this.octokit, owner, repo),
|
|
77
181
|
hasMergedPRsInRepo
|
|
78
182
|
? Promise.resolve(0)
|
|
79
|
-
: checkUserMergedPRsInRepo(this.octokit, owner, repo),
|
|
183
|
+
: checkUserMergedPRsInRepo(this.octokit, owner, repo, this.budgetTracker),
|
|
80
184
|
]);
|
|
81
185
|
// Anti-LLM scan reuses the CONTRIBUTING text just fetched above —
|
|
82
186
|
// dedup'd to avoid 4 redundant getContent calls on cold-cache repos.
|
|
@@ -87,8 +191,18 @@ export class IssueVetter {
|
|
|
87
191
|
const antiLLMPolicy = await fetchAndScanAntiLLMPolicy(this.octokit, owner, repo, { contributingText: contributionGuidelines?.rawContent });
|
|
88
192
|
const noExistingPR = existingPRCheck.passed;
|
|
89
193
|
const notClaimed = claimCheck.passed;
|
|
194
|
+
// Is the linked PR the user's own open PR (#166)? If so the issue is
|
|
195
|
+
// "you're already on it", not competition.
|
|
196
|
+
const username = this.stateReader.getGitHubUsername?.() ?? "";
|
|
197
|
+
const linkedPR = existingPRCheck.linkedPR;
|
|
198
|
+
const ownPR = !noExistingPR &&
|
|
199
|
+
!!linkedPR &&
|
|
200
|
+
linkedPR.state === "open" &&
|
|
201
|
+
!linkedPR.merged &&
|
|
202
|
+
username !== "" &&
|
|
203
|
+
linkedPR.author.toLowerCase() === username.toLowerCase();
|
|
90
204
|
// Analyze issue quality
|
|
91
|
-
const clearRequirements = analyzeRequirements(
|
|
205
|
+
const clearRequirements = analyzeRequirements(core.body);
|
|
92
206
|
// When the health check itself failed (API error), use a neutral default:
|
|
93
207
|
// don't penalize the repo as inactive, but don't credit it as active either.
|
|
94
208
|
const projectActive = projectHealth.checkFailed
|
|
@@ -107,104 +221,76 @@ export class IssueVetter {
|
|
|
107
221
|
linkedPR: existingPRCheck.linkedPR,
|
|
108
222
|
notes: [],
|
|
109
223
|
};
|
|
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
|
|
224
|
+
// Create tracked issue. It holds a reference to vettingResult, whose
|
|
225
|
+
// notes are filled in by deriveRecommendation below.
|
|
132
226
|
const trackedIssue = {
|
|
133
|
-
id:
|
|
227
|
+
id: core.id,
|
|
134
228
|
url: issueUrl,
|
|
135
229
|
repo: repoFullName,
|
|
136
230
|
number,
|
|
137
|
-
title:
|
|
231
|
+
title: core.title,
|
|
138
232
|
status: "candidate",
|
|
139
|
-
labels:
|
|
140
|
-
createdAt:
|
|
141
|
-
updatedAt:
|
|
233
|
+
labels: core.labels,
|
|
234
|
+
createdAt: core.createdAt,
|
|
235
|
+
updatedAt: core.updatedAt,
|
|
142
236
|
vetted: true,
|
|
143
237
|
vettingResult,
|
|
144
238
|
};
|
|
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)
|
|
239
|
+
// Effective merged PR count: prefer local state (authoritative if present),
|
|
240
|
+
// fall back to live GitHub API count to detect contributions made before
|
|
241
|
+
// using oss-scout. null means the live check transiently failed: score as
|
|
242
|
+
// 0 but treat the result as inconclusive so it is not cached.
|
|
243
|
+
const mergedCountInconclusive = !hasMergedPRsInRepo && userMergedPRCount === null;
|
|
244
|
+
const effectiveMergedCount = hasMergedPRsInRepo
|
|
245
|
+
? 1
|
|
246
|
+
: (userMergedPRCount ?? 0);
|
|
247
|
+
// Org-level affinity (user has merged PRs in another repo under same org).
|
|
173
248
|
const orgName = repoFullName.split("/")[0];
|
|
174
249
|
let orgHasMergedPRs = false;
|
|
175
250
|
if (orgName && repoFullName.includes("/")) {
|
|
176
251
|
orgHasMergedPRs = reposWithMergedPRs.some((r) => r.startsWith(orgName + "/") && r !== repoFullName);
|
|
177
252
|
}
|
|
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.
|
|
253
|
+
const matchesCategory = repoBelongsToCategory(repoFullName, this.stateReader.getProjectCategories());
|
|
254
|
+
// GitHub answers 200 for closed issues, so without an explicit state
|
|
255
|
+
// check a closed issue would vet as still available (#120).
|
|
256
|
+
const issueClosed = core.state === "closed";
|
|
257
|
+
// Never cache a verdict built on inconclusive/failed checks (e.g. a
|
|
258
|
+
// transient 5xx): that would pin a degraded result for the whole TTL.
|
|
199
259
|
const hasInconclusiveChecks = projectHealth.checkFailed ||
|
|
200
|
-
existingPRCheck.inconclusive ||
|
|
201
|
-
claimCheck.inconclusive
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
260
|
+
!!existingPRCheck.inconclusive ||
|
|
261
|
+
!!claimCheck.inconclusive ||
|
|
262
|
+
mergedCountInconclusive;
|
|
263
|
+
const { notes, reasonsToApprove, reasonsToSkip, recommendation } = deriveRecommendation({
|
|
264
|
+
noExistingPR,
|
|
265
|
+
ownPR,
|
|
266
|
+
notClaimed,
|
|
267
|
+
clearRequirements,
|
|
268
|
+
contributionGuidelinesFound: !!contributionGuidelines,
|
|
269
|
+
projectIsActive: projectHealth.checkFailed
|
|
270
|
+
? false
|
|
271
|
+
: projectHealth.isActive,
|
|
272
|
+
projectCheckFailed: !!projectHealth.checkFailed,
|
|
273
|
+
projectFailureReason: projectHealth.failureReason,
|
|
274
|
+
existingPRInconclusive: !!existingPRCheck.inconclusive,
|
|
275
|
+
existingPRReason: existingPRCheck.inconclusive
|
|
276
|
+
? existingPRCheck.reason
|
|
277
|
+
: undefined,
|
|
278
|
+
claimInconclusive: !!claimCheck.inconclusive,
|
|
279
|
+
claimReason: claimCheck.inconclusive ? claimCheck.reason : undefined,
|
|
280
|
+
mergedCountInconclusive,
|
|
281
|
+
effectiveMergedCount,
|
|
282
|
+
orgName,
|
|
283
|
+
orgHasMergedPRs,
|
|
284
|
+
matchesCategory,
|
|
285
|
+
issueClosed,
|
|
286
|
+
passedAllChecks: vettingResult.passedAllChecks,
|
|
287
|
+
});
|
|
288
|
+
vettingResult.notes = notes;
|
|
289
|
+
// Calculate repo quality bonus from star/fork counts. A failed health
|
|
290
|
+
// check carries no counts (#158), so the bonus is 0 in that case.
|
|
291
|
+
const repoQualityBonus = projectHealth.checkFailed
|
|
292
|
+
? 0
|
|
293
|
+
: calculateRepoQualityBonus(projectHealth.stargazersCount ?? 0, projectHealth.forksCount ?? 0);
|
|
208
294
|
if (projectHealth.checkFailed && repoQualityBonus === 0) {
|
|
209
295
|
vettingResult.notes.push("Repo quality bonus unavailable: could not fetch star/fork counts due to API error");
|
|
210
296
|
}
|
|
@@ -215,8 +301,8 @@ export class IssueVetter {
|
|
|
215
301
|
isClaimed: !notClaimed,
|
|
216
302
|
clearRequirements,
|
|
217
303
|
hasContributionGuidelines: !!contributionGuidelines,
|
|
218
|
-
issueUpdatedAt:
|
|
219
|
-
closedWithoutMergeCount: 0,
|
|
304
|
+
issueUpdatedAt: core.updatedAt,
|
|
305
|
+
closedWithoutMergeCount: this.stateReader.getClosedWithoutMergeCount?.(repoFullName) ?? 0,
|
|
220
306
|
mergedPRCount: effectiveMergedCount,
|
|
221
307
|
orgHasMergedPRs,
|
|
222
308
|
repoQualityBonus,
|
|
@@ -232,23 +318,22 @@ export class IssueVetter {
|
|
|
232
318
|
searchPriority = "starred";
|
|
233
319
|
}
|
|
234
320
|
// 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
|
-
};
|
|
321
|
+
// path returns null and the rest of the pipeline is unaffected. A null
|
|
322
|
+
// config means SLM triage is disabled (#158).
|
|
323
|
+
const slmConfig = this.stateReader.getSLMTriageConfig();
|
|
240
324
|
let slmTriage = null;
|
|
241
|
-
if (slmConfig
|
|
325
|
+
if (slmConfig) {
|
|
242
326
|
const slmOpts = { model: slmConfig.model };
|
|
243
327
|
if (slmConfig.host)
|
|
244
328
|
slmOpts.host = slmConfig.host;
|
|
245
329
|
slmTriage = await triageWithSLM(buildTriageInput({
|
|
246
|
-
issue: { ...trackedIssue, body:
|
|
330
|
+
issue: { ...trackedIssue, body: core.body },
|
|
247
331
|
linkedPR: existingPRCheck.linkedPR ?? null,
|
|
248
332
|
}), slmOpts);
|
|
249
333
|
}
|
|
250
334
|
const result = {
|
|
251
335
|
issue: trackedIssue,
|
|
336
|
+
issueState: issueClosed ? "closed" : "open",
|
|
252
337
|
vettingResult,
|
|
253
338
|
projectHealth,
|
|
254
339
|
antiLLMPolicy,
|
|
@@ -259,10 +344,38 @@ export class IssueVetter {
|
|
|
259
344
|
viabilityScore,
|
|
260
345
|
searchPriority,
|
|
261
346
|
};
|
|
262
|
-
// Cache the vetting result to avoid redundant API calls on repeated
|
|
263
|
-
cache
|
|
347
|
+
// Cache the vetting result to avoid redundant API calls on repeated
|
|
348
|
+
// searches — but never cache results built on inconclusive/failed checks
|
|
349
|
+
// (e.g. a transient 5xx): that would pin a degraded verdict for the whole
|
|
350
|
+
// TTL. Next vet retries instead. Mirrors the error-path rule in
|
|
351
|
+
// issue-eligibility's checkUserMergedPRsInRepo.
|
|
352
|
+
if (!hasInconclusiveChecks) {
|
|
353
|
+
cache.set(cacheKey, "", result);
|
|
354
|
+
}
|
|
264
355
|
return result;
|
|
265
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* Fetch a single issue's core fields via REST and normalize them to the same
|
|
359
|
+
* shape as a GraphQL prefetch (#169). The REST fallback path when no
|
|
360
|
+
* prefetched core was supplied.
|
|
361
|
+
*/
|
|
362
|
+
async fetchIssueCore(owner, repo, number) {
|
|
363
|
+
const { data: ghIssue } = await this.octokit.issues.get({
|
|
364
|
+
owner,
|
|
365
|
+
repo,
|
|
366
|
+
issue_number: number,
|
|
367
|
+
});
|
|
368
|
+
return {
|
|
369
|
+
id: ghIssue.id,
|
|
370
|
+
title: ghIssue.title,
|
|
371
|
+
body: ghIssue.body || "",
|
|
372
|
+
state: ghIssue.state === "closed" ? "closed" : "open",
|
|
373
|
+
labels: ghIssue.labels.map((l) => typeof l === "string" ? l : l.name || ""),
|
|
374
|
+
commentCount: ghIssue.comments,
|
|
375
|
+
createdAt: ghIssue.created_at,
|
|
376
|
+
updatedAt: ghIssue.updated_at,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
266
379
|
/**
|
|
267
380
|
* Vet multiple issues in parallel with concurrency limit
|
|
268
381
|
*/
|
|
@@ -277,13 +390,37 @@ export class IssueVetter {
|
|
|
277
390
|
// the token is invalid and no other issue will succeed either —
|
|
278
391
|
// continuing to log per-issue warnings buries the actual problem.
|
|
279
392
|
let firstAuthError = null;
|
|
280
|
-
|
|
393
|
+
// Dedup defensively: the pending map is keyed by URL, so a duplicate
|
|
394
|
+
// input would overwrite the in-flight entry and its finally-cleanup
|
|
395
|
+
// would deregister the second task, letting allSettled return while a
|
|
396
|
+
// vet still runs (#129). Callers dedup today, but nothing enforced it.
|
|
397
|
+
const uniqueUrls = [...new Set(urls)];
|
|
398
|
+
// Batch-prefetch issue core data in one GraphQL query (#169), replacing N
|
|
399
|
+
// per-issue REST `issues.get` calls. Any URL the prefetch can't resolve
|
|
400
|
+
// (parse failure, deleted issue, non-fatal GraphQL error) is simply absent
|
|
401
|
+
// from the map and vetIssue falls back to REST for it. A fatal error (401 /
|
|
402
|
+
// rate limit) propagates out of prefetchIssueCores and aborts the batch,
|
|
403
|
+
// matching the per-issue auth-error handling below.
|
|
404
|
+
const prefetched = await prefetchIssueCores(this.octokit, uniqueUrls.flatMap((url) => {
|
|
405
|
+
const p = parseGitHubUrl(url);
|
|
406
|
+
return p && p.type === "issues"
|
|
407
|
+
? [{ owner: p.owner, repo: p.repo, number: p.number }]
|
|
408
|
+
: [];
|
|
409
|
+
}));
|
|
410
|
+
const prefetchFor = (url) => {
|
|
411
|
+
const p = parseGitHubUrl(url);
|
|
412
|
+
return p && p.type === "issues"
|
|
413
|
+
? prefetched.get(issueCoreKey(p.owner, p.repo, p.number))
|
|
414
|
+
: undefined;
|
|
415
|
+
};
|
|
416
|
+
for (const url of uniqueUrls) {
|
|
281
417
|
if (candidates.length >= maxResults)
|
|
282
418
|
break;
|
|
283
419
|
if (firstAuthError)
|
|
284
420
|
break; // stop scheduling once auth has failed
|
|
285
421
|
attemptedCount++;
|
|
286
|
-
const
|
|
422
|
+
const core = prefetchFor(url);
|
|
423
|
+
const task = this.vetIssue(url, core ? { prefetched: core } : undefined)
|
|
287
424
|
.then((candidate) => {
|
|
288
425
|
if (candidates.length < maxResults) {
|
|
289
426
|
// 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
|
}
|