@oss-scout/core 0.11.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.
Files changed (63) hide show
  1. package/dist/cli.bundle.cjs +78 -61
  2. package/dist/cli.js +401 -425
  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.js +63 -70
  10. package/dist/commands/setup.d.ts +2 -0
  11. package/dist/commands/setup.js +35 -6
  12. package/dist/commands/skip.d.ts +4 -0
  13. package/dist/commands/skip.js +45 -55
  14. package/dist/commands/sync.d.ts +10 -0
  15. package/dist/commands/sync.js +10 -0
  16. package/dist/commands/vet-list.js +3 -19
  17. package/dist/commands/vet.js +18 -25
  18. package/dist/commands/with-scout.d.ts +32 -0
  19. package/dist/commands/with-scout.js +41 -0
  20. package/dist/core/anti-llm-policy.js +4 -5
  21. package/dist/core/bootstrap.d.ts +2 -2
  22. package/dist/core/bootstrap.js +5 -9
  23. package/dist/core/errors.d.ts +10 -0
  24. package/dist/core/errors.js +20 -5
  25. package/dist/core/feature-discovery.d.ts +13 -1
  26. package/dist/core/feature-discovery.js +104 -81
  27. package/dist/core/gist-state-store.d.ts +13 -12
  28. package/dist/core/gist-state-store.js +128 -53
  29. package/dist/core/http-cache.d.ts +32 -2
  30. package/dist/core/http-cache.js +74 -19
  31. package/dist/core/issue-discovery.d.ts +2 -0
  32. package/dist/core/issue-discovery.js +44 -29
  33. package/dist/core/issue-eligibility.d.ts +10 -4
  34. package/dist/core/issue-eligibility.js +119 -67
  35. package/dist/core/issue-graphql.d.ts +58 -0
  36. package/dist/core/issue-graphql.js +108 -0
  37. package/dist/core/issue-vetting.d.ts +105 -8
  38. package/dist/core/issue-vetting.js +234 -107
  39. package/dist/core/local-state.d.ts +6 -2
  40. package/dist/core/local-state.js +23 -5
  41. package/dist/core/logger.d.ts +12 -4
  42. package/dist/core/logger.js +33 -7
  43. package/dist/core/personalization.d.ts +15 -10
  44. package/dist/core/personalization.js +30 -22
  45. package/dist/core/preference-fields.d.ts +47 -0
  46. package/dist/core/preference-fields.js +178 -0
  47. package/dist/core/repo-health.js +31 -15
  48. package/dist/core/roadmap.js +17 -3
  49. package/dist/core/schemas.d.ts +144 -26
  50. package/dist/core/schemas.js +74 -17
  51. package/dist/core/search-budget.d.ts +9 -0
  52. package/dist/core/search-budget.js +36 -3
  53. package/dist/core/search-phases.d.ts +0 -18
  54. package/dist/core/search-phases.js +27 -82
  55. package/dist/core/types.d.ts +136 -38
  56. package/dist/core/utils.js +60 -26
  57. package/dist/formatters/markdown.d.ts +10 -0
  58. package/dist/formatters/markdown.js +31 -0
  59. package/dist/index.d.ts +6 -2
  60. package/dist/index.js +8 -0
  61. package/dist/scout.d.ts +59 -10
  62. package/dist/scout.js +244 -20
  63. 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 { getHttpCache } from "./http-cache.js";
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
- // Fetch issue data
63
- const { data: ghIssue } = await this.octokit.issues.get({
64
- owner,
65
- repo,
66
- issue_number: number,
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, ghIssue.comments),
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(ghIssue.body || "");
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
- // 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
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: ghIssue.id,
217
+ id: core.id,
134
218
  url: issueUrl,
135
219
  repo: repoFullName,
136
220
  number,
137
- title: ghIssue.title,
221
+ title: core.title,
138
222
  status: "candidate",
139
- labels: ghIssue.labels.map((l) => typeof l === "string" ? l : l.name || ""),
140
- createdAt: ghIssue.created_at,
141
- updatedAt: ghIssue.updated_at,
223
+ labels: core.labels,
224
+ createdAt: core.createdAt,
225
+ updatedAt: core.updatedAt,
142
226
  vetted: true,
143
227
  vettingResult,
144
228
  };
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)
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
- 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.
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
- 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);
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: ghIssue.updated_at,
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
- const slmConfig = this.stateReader.getSLMTriageConfig?.() ?? {
237
- model: "",
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.model) {
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: ghIssue.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 searches
263
- cache.set(cacheKey, "", result);
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
- for (const url of urls) {
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 task = this.vetIssue(url)
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 .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
  }
@@ -28,18 +28,23 @@ import type { IssueCandidate } from "./types.js";
28
28
  export declare const REPO_BOOST = 20;
29
29
  export declare const LANGUAGE_BOOST = 10;
30
30
  /**
31
- * Annotate each candidate with `boostScore` and `boostReasons` based on
32
- * the caller-supplied preference lists. Mutates the array in place; the
33
- * caller is responsible for re-sorting afterwards.
34
- *
35
- * Mutation (rather than returning new objects) keeps the personalization
36
- * step a single linear pass over the array the caller already holds —
37
- * the sort step reads back from the same objects.
31
+ * The personalization sort weight of a candidate: its boost score, or 0 when it
32
+ * is not boosted (unboosted or a diversity slot). Reads the structural
33
+ * `personalization` field (#158) so callers never poke at the old loose
34
+ * `boostScore` field.
35
+ */
36
+ export declare function boostScoreOf(candidate: IssueCandidate): number;
37
+ /**
38
+ * Return a new candidate list where each candidate that matches a
39
+ * caller-supplied preference carries `personalization: { kind: "boosted", ... }`.
40
+ * Does NOT mutate the input candidates (#158) — matched candidates are shallow
41
+ * copies with the field set; unmatched candidates are passed through unchanged.
42
+ * The caller re-sorts the returned array.
38
43
  *
39
- * No-op when both preference lists are empty or undefined: candidates
40
- * retain `boostScore: undefined` and the sort tier collapses to 0.
44
+ * No-op when both preference lists are empty or undefined: the input array is
45
+ * returned as-is and the sort tier collapses to 0 for every candidate.
41
46
  */
42
- export declare function annotateBoost(candidates: IssueCandidate[], preferLanguages?: string[], preferRepos?: string[]): void;
47
+ export declare function annotateBoost(candidates: IssueCandidate[], preferLanguages?: string[], preferRepos?: string[]): IssueCandidate[];
43
48
  /**
44
49
  * Apply a diversity-counterweight pass over a pre-sorted candidate list
45
50
  * (#1244). Returns the first `maxResults` picks in priority order: