@oss-scout/core 0.11.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/cli.bundle.cjs +89 -66
  2. package/dist/cli.js +302 -436
  3. package/dist/commands/command-scout.d.ts +21 -0
  4. package/dist/commands/command-scout.js +21 -0
  5. package/dist/commands/config.js +10 -128
  6. package/dist/commands/features.js +15 -28
  7. package/dist/commands/results.d.ts +13 -2
  8. package/dist/commands/results.js +29 -2
  9. package/dist/commands/search.d.ts +4 -0
  10. package/dist/commands/search.js +65 -70
  11. package/dist/commands/setup.d.ts +2 -0
  12. package/dist/commands/setup.js +35 -6
  13. package/dist/commands/skip.d.ts +4 -0
  14. package/dist/commands/skip.js +45 -55
  15. package/dist/commands/sync.d.ts +10 -0
  16. package/dist/commands/sync.js +10 -0
  17. package/dist/commands/vet-list.js +3 -19
  18. package/dist/commands/vet.js +18 -25
  19. package/dist/commands/with-scout.d.ts +32 -0
  20. package/dist/commands/with-scout.js +41 -0
  21. package/dist/core/anti-llm-policy.js +5 -33
  22. package/dist/core/bootstrap.d.ts +2 -2
  23. package/dist/core/bootstrap.js +5 -9
  24. package/dist/core/errors.d.ts +10 -0
  25. package/dist/core/errors.js +20 -5
  26. package/dist/core/feature-discovery.d.ts +13 -1
  27. package/dist/core/feature-discovery.js +104 -81
  28. package/dist/core/gist-state-store.d.ts +13 -12
  29. package/dist/core/gist-state-store.js +128 -53
  30. package/dist/core/http-cache.d.ts +32 -2
  31. package/dist/core/http-cache.js +74 -19
  32. package/dist/core/issue-discovery.d.ts +12 -1
  33. package/dist/core/issue-discovery.js +94 -67
  34. package/dist/core/issue-eligibility.d.ts +11 -4
  35. package/dist/core/issue-eligibility.js +124 -69
  36. package/dist/core/issue-graphql.d.ts +58 -0
  37. package/dist/core/issue-graphql.js +108 -0
  38. package/dist/core/issue-vetting.d.ts +115 -9
  39. package/dist/core/issue-vetting.js +246 -109
  40. package/dist/core/local-state.d.ts +6 -2
  41. package/dist/core/local-state.js +23 -5
  42. package/dist/core/logger.d.ts +12 -4
  43. package/dist/core/logger.js +33 -7
  44. package/dist/core/personalization.d.ts +30 -10
  45. package/dist/core/personalization.js +64 -24
  46. package/dist/core/preference-fields.d.ts +47 -0
  47. package/dist/core/preference-fields.js +180 -0
  48. package/dist/core/probe-repo-file.d.ts +47 -0
  49. package/dist/core/probe-repo-file.js +57 -0
  50. package/dist/core/repo-health.js +40 -32
  51. package/dist/core/roadmap.js +26 -22
  52. package/dist/core/schemas.d.ts +148 -26
  53. package/dist/core/schemas.js +83 -17
  54. package/dist/core/search-budget.d.ts +9 -0
  55. package/dist/core/search-budget.js +36 -3
  56. package/dist/core/search-phases.d.ts +4 -21
  57. package/dist/core/search-phases.js +37 -89
  58. package/dist/core/types.d.ts +151 -38
  59. package/dist/core/utils.js +60 -26
  60. package/dist/formatters/human.d.ts +60 -0
  61. package/dist/formatters/human.js +199 -0
  62. package/dist/formatters/markdown.d.ts +10 -0
  63. package/dist/formatters/markdown.js +31 -0
  64. package/dist/index.d.ts +6 -2
  65. package/dist/index.js +8 -0
  66. package/dist/scout.d.ts +75 -12
  67. package/dist/scout.js +265 -26
  68. package/package.json +1 -1
@@ -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 { getHttpCache } from "./http-cache.js";
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
- constructor(octokit, stateReader) {
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
- // Fetch issue data
63
- const { data: ghIssue } = await this.octokit.issues.get({
64
- owner,
65
- repo,
66
- issue_number: number,
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, ghIssue.comments),
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(ghIssue.body || "");
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
- // Build notes
111
- if (!noExistingPR)
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: ghIssue.id,
227
+ id: core.id,
134
228
  url: issueUrl,
135
229
  repo: repoFullName,
136
230
  number,
137
- title: ghIssue.title,
231
+ title: core.title,
138
232
  status: "candidate",
139
- labels: ghIssue.labels.map((l) => typeof l === "string" ? l : l.name || ""),
140
- createdAt: ghIssue.created_at,
141
- updatedAt: ghIssue.updated_at,
233
+ labels: core.labels,
234
+ createdAt: core.createdAt,
235
+ updatedAt: core.updatedAt,
142
236
  vetted: true,
143
237
  vettingResult,
144
238
  };
145
- // Determine recommendation
146
- const reasonsToSkip = [];
147
- const reasonsToApprove = [];
148
- if (!noExistingPR)
149
- reasonsToSkip.push("Has existing PR");
150
- if (!notClaimed)
151
- reasonsToSkip.push("Already claimed");
152
- if (!projectHealth.isActive && !projectHealth.checkFailed)
153
- reasonsToSkip.push("Inactive project");
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
- if (orgHasMergedPRs) {
179
- reasonsToApprove.push(`Org affinity (merged PRs in other ${orgName} repos)`);
180
- }
181
- // Check for category preference match
182
- const projectCategories = this.stateReader.getProjectCategories();
183
- const matchesCategory = repoBelongsToCategory(repoFullName, projectCategories);
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
- if (recommendation === "approve" && hasInconclusiveChecks) {
203
- recommendation = "needs_review";
204
- vettingResult.notes.push("Recommendation downgraded: one or more checks were inconclusive");
205
- }
206
- // Calculate repo quality bonus from star/fork counts
207
- const repoQualityBonus = calculateRepoQualityBonus(projectHealth.stargazersCount ?? 0, projectHealth.forksCount ?? 0);
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: ghIssue.updated_at,
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
- const slmConfig = this.stateReader.getSLMTriageConfig?.() ?? {
237
- model: "",
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.model) {
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: ghIssue.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 searches
263
- cache.set(cacheKey, "", result);
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
- for (const url of urls) {
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 task = this.vetIssue(url)
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 .tmp, then rename).
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;
@@ -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 ScoutStateSchema.parse(JSON.parse(raw));
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 .tmp, then rename).
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 + ".tmp";
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
- fs.renameSync(tmpPath, statePath);
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
  }
@@ -1,10 +1,18 @@
1
1
  /**
2
- * Lightweight debug logger for oss-scout.
3
- * Activated by the global --debug CLI flag.
2
+ * Lightweight leveled logger for oss-scout.
4
3
  *
5
- * All debug/warn output goes to stderr so it never contaminates
6
- * the --json stdout contract.
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;
@@ -1,25 +1,51 @@
1
1
  /**
2
- * Lightweight debug logger for oss-scout.
3
- * Activated by the global --debug CLI flag.
2
+ * Lightweight leveled logger for oss-scout.
4
3
  *
5
- * All debug/warn output goes to stderr so it never contaminates
6
- * the --json stdout contract.
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
- let debugEnabled = false;
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
- debugEnabled = true;
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 (!debugEnabled)
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
  }