@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.
- package/dist/cli.bundle.cjs +78 -61
- package/dist/cli.js +401 -425
- 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.js +63 -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 +4 -5
- package/dist/core/bootstrap.d.ts +2 -2
- package/dist/core/bootstrap.js +5 -9
- package/dist/core/errors.d.ts +10 -0
- package/dist/core/errors.js +20 -5
- package/dist/core/feature-discovery.d.ts +13 -1
- package/dist/core/feature-discovery.js +104 -81
- package/dist/core/gist-state-store.d.ts +13 -12
- package/dist/core/gist-state-store.js +128 -53
- package/dist/core/http-cache.d.ts +32 -2
- package/dist/core/http-cache.js +74 -19
- package/dist/core/issue-discovery.d.ts +2 -0
- package/dist/core/issue-discovery.js +44 -29
- package/dist/core/issue-eligibility.d.ts +10 -4
- package/dist/core/issue-eligibility.js +119 -67
- package/dist/core/issue-graphql.d.ts +58 -0
- package/dist/core/issue-graphql.js +108 -0
- package/dist/core/issue-vetting.d.ts +105 -8
- package/dist/core/issue-vetting.js +234 -107
- package/dist/core/local-state.d.ts +6 -2
- package/dist/core/local-state.js +23 -5
- package/dist/core/logger.d.ts +12 -4
- package/dist/core/logger.js +33 -7
- package/dist/core/personalization.d.ts +15 -10
- package/dist/core/personalization.js +30 -22
- package/dist/core/preference-fields.d.ts +47 -0
- package/dist/core/preference-fields.js +178 -0
- package/dist/core/repo-health.js +31 -15
- package/dist/core/roadmap.js +17 -3
- package/dist/core/schemas.d.ts +144 -26
- package/dist/core/schemas.js +74 -17
- package/dist/core/search-budget.d.ts +9 -0
- package/dist/core/search-budget.js +36 -3
- package/dist/core/search-phases.d.ts +0 -18
- package/dist/core/search-phases.js +27 -82
- package/dist/core/types.d.ts +136 -38
- package/dist/core/utils.js +60 -26
- package/dist/formatters/markdown.d.ts +10 -0
- package/dist/formatters/markdown.js +31 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +8 -0
- package/dist/scout.d.ts +59 -10
- package/dist/scout.js +244 -20
- package/package.json +1 -1
|
@@ -27,39 +27,47 @@
|
|
|
27
27
|
export const REPO_BOOST = 20;
|
|
28
28
|
export const LANGUAGE_BOOST = 10;
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
* The personalization sort weight of a candidate: its boost score, or 0 when it
|
|
31
|
+
* is not boosted (unboosted or a diversity slot). Reads the structural
|
|
32
|
+
* `personalization` field (#158) so callers never poke at the old loose
|
|
33
|
+
* `boostScore` field.
|
|
34
|
+
*/
|
|
35
|
+
export function boostScoreOf(candidate) {
|
|
36
|
+
return candidate.personalization?.kind === "boosted"
|
|
37
|
+
? candidate.personalization.score
|
|
38
|
+
: 0;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Return a new candidate list where each candidate that matches a
|
|
42
|
+
* caller-supplied preference carries `personalization: { kind: "boosted", ... }`.
|
|
43
|
+
* Does NOT mutate the input candidates (#158) — matched candidates are shallow
|
|
44
|
+
* copies with the field set; unmatched candidates are passed through unchanged.
|
|
45
|
+
* The caller re-sorts the returned array.
|
|
37
46
|
*
|
|
38
|
-
* No-op when both preference lists are empty or undefined:
|
|
39
|
-
*
|
|
47
|
+
* No-op when both preference lists are empty or undefined: the input array is
|
|
48
|
+
* returned as-is and the sort tier collapses to 0 for every candidate.
|
|
40
49
|
*/
|
|
41
50
|
export function annotateBoost(candidates, preferLanguages, preferRepos) {
|
|
42
51
|
const langSet = new Set((preferLanguages ?? []).map((l) => l.trim().toLowerCase()).filter(Boolean));
|
|
43
|
-
const repoSet = new Set((preferRepos ?? []).map((r) => r.trim()).filter(Boolean));
|
|
52
|
+
const repoSet = new Set((preferRepos ?? []).map((r) => r.trim().toLowerCase()).filter(Boolean));
|
|
44
53
|
if (langSet.size === 0 && repoSet.size === 0)
|
|
45
|
-
return;
|
|
46
|
-
|
|
54
|
+
return candidates;
|
|
55
|
+
return candidates.map((c) => {
|
|
47
56
|
let score = 0;
|
|
48
57
|
const reasons = [];
|
|
49
|
-
if (repoSet.size > 0 && repoSet.has(c.issue.repo)) {
|
|
58
|
+
if (repoSet.size > 0 && repoSet.has(c.issue.repo.toLowerCase())) {
|
|
50
59
|
score += REPO_BOOST;
|
|
51
60
|
reasons.push(`repo affinity: ${c.issue.repo}`);
|
|
52
61
|
}
|
|
53
|
-
const lang = c.projectHealth.language;
|
|
62
|
+
const lang = c.projectHealth.checkFailed ? null : c.projectHealth.language;
|
|
54
63
|
if (langSet.size > 0 && lang && langSet.has(lang.toLowerCase())) {
|
|
55
64
|
score += LANGUAGE_BOOST;
|
|
56
65
|
reasons.push(`language match: ${lang}`);
|
|
57
66
|
}
|
|
58
|
-
if (score
|
|
59
|
-
c
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
67
|
+
if (score === 0)
|
|
68
|
+
return c;
|
|
69
|
+
return { ...c, personalization: { kind: "boosted", score, reasons } };
|
|
70
|
+
});
|
|
63
71
|
}
|
|
64
72
|
/**
|
|
65
73
|
* Apply a diversity-counterweight pass over a pre-sorted candidate list
|
|
@@ -108,10 +116,10 @@ export function applyDiversityRatio(candidates, maxResults, diversityRatio) {
|
|
|
108
116
|
break;
|
|
109
117
|
if (seen.has(c.issue.url))
|
|
110
118
|
continue;
|
|
111
|
-
if (c
|
|
119
|
+
if (boostScoreOf(c) > 0)
|
|
112
120
|
continue;
|
|
113
|
-
|
|
114
|
-
picks.push(c);
|
|
121
|
+
// Tag a shallow copy rather than mutating the shared candidate (#158).
|
|
122
|
+
picks.push({ ...c, personalization: { kind: "diversity" } });
|
|
115
123
|
seen.add(c.issue.url);
|
|
116
124
|
}
|
|
117
125
|
for (const c of candidates) {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared preference-field metadata and value parsing.
|
|
3
|
+
*
|
|
4
|
+
* The CLI (`commands/config.ts`) and the MCP `config-set` tool both update a
|
|
5
|
+
* single preference from a raw string. They used to carry separate, drifting
|
|
6
|
+
* copies of the key tables and parse logic — the CLI was missing the SLM
|
|
7
|
+
* triage keys, the MCP side lacked the `scope` special case and the +/- array
|
|
8
|
+
* syntax. This module is the single source of truth both drive (#153).
|
|
9
|
+
*/
|
|
10
|
+
import type { ScoutPreferences } from "./schemas.js";
|
|
11
|
+
export type FieldConfig = {
|
|
12
|
+
type: "array" | "number" | "float" | "boolean" | "string";
|
|
13
|
+
} | {
|
|
14
|
+
type: "enum" | "enum-array";
|
|
15
|
+
validValues: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
export declare const FIELD_CONFIGS: Record<string, FieldConfig>;
|
|
18
|
+
/**
|
|
19
|
+
* Every configurable preference key, derived from the schema so a new
|
|
20
|
+
* preference can't be silently left unconfigurable. `assertFieldConfigsCover`
|
|
21
|
+
* (exercised by a unit test) fails loudly if FIELD_CONFIGS drifts from this.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PREFERENCE_KEYS: readonly string[];
|
|
24
|
+
/** Sorted key list for "unknown key" error messages and help text. */
|
|
25
|
+
export declare const SORTED_PREFERENCE_KEYS: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* Throw if any schema preference lacks a FIELD_CONFIG entry. Called from a
|
|
28
|
+
* test so adding a preference to the schema without teaching config-set how to
|
|
29
|
+
* parse it is caught in CI rather than at a user's first `config set newKey`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function assertFieldConfigsCover(): void;
|
|
32
|
+
/**
|
|
33
|
+
* Apply an array update: plain set, +append, or -remove.
|
|
34
|
+
*
|
|
35
|
+
* The -remove form starts with a dash, which commander rejects as an unknown
|
|
36
|
+
* option unless escaped: `config set excludeRepos -- "-spam/repo"`. The MCP
|
|
37
|
+
* tool has no commander layer so it can pass `-spam/repo` directly. Documented
|
|
38
|
+
* in the CLI help and README (#132).
|
|
39
|
+
*/
|
|
40
|
+
export declare function updateArray(current: string[], value: string): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Apply a single key/value update to a preferences object and return the
|
|
43
|
+
* fully validated result. The raw string `value` is the form both the CLI and
|
|
44
|
+
* the MCP tool receive; arrays accept comma-separated values and the +add /
|
|
45
|
+
* -remove syntax. Throws ValidationError on an unknown key or a bad value.
|
|
46
|
+
*/
|
|
47
|
+
export declare function applyPreferenceField(preferences: ScoutPreferences, key: string, value: string): ScoutPreferences;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared preference-field metadata and value parsing.
|
|
3
|
+
*
|
|
4
|
+
* The CLI (`commands/config.ts`) and the MCP `config-set` tool both update a
|
|
5
|
+
* single preference from a raw string. They used to carry separate, drifting
|
|
6
|
+
* copies of the key tables and parse logic — the CLI was missing the SLM
|
|
7
|
+
* triage keys, the MCP side lacked the `scope` special case and the +/- array
|
|
8
|
+
* syntax. This module is the single source of truth both drive (#153).
|
|
9
|
+
*/
|
|
10
|
+
import { ScoutPreferencesSchema, IssueScopeSchema, ProjectCategorySchema, PersistenceModeSchema, SearchStrategySchema, } from "./schemas.js";
|
|
11
|
+
import { ValidationError } from "./errors.js";
|
|
12
|
+
export const FIELD_CONFIGS = {
|
|
13
|
+
githubUsername: { type: "string" },
|
|
14
|
+
languages: { type: "array" },
|
|
15
|
+
labels: { type: "array" },
|
|
16
|
+
scope: { type: "enum-array", validValues: IssueScopeSchema.options },
|
|
17
|
+
excludeRepos: { type: "array" },
|
|
18
|
+
excludeOrgs: { type: "array" },
|
|
19
|
+
aiPolicyBlocklist: { type: "array" },
|
|
20
|
+
projectCategories: {
|
|
21
|
+
type: "enum-array",
|
|
22
|
+
validValues: ProjectCategorySchema.options,
|
|
23
|
+
},
|
|
24
|
+
minStars: { type: "number" },
|
|
25
|
+
maxIssueAgeDays: { type: "number" },
|
|
26
|
+
includeDocIssues: { type: "boolean" },
|
|
27
|
+
minRepoScoreThreshold: { type: "number" },
|
|
28
|
+
interPhaseDelayMs: { type: "number" },
|
|
29
|
+
persistence: { type: "enum", validValues: PersistenceModeSchema.options },
|
|
30
|
+
defaultStrategy: {
|
|
31
|
+
type: "enum-array",
|
|
32
|
+
validValues: SearchStrategySchema.options,
|
|
33
|
+
},
|
|
34
|
+
broadPhaseDelayMs: { type: "number" },
|
|
35
|
+
skipBroadWhenSufficientResults: { type: "number" },
|
|
36
|
+
preferLanguages: { type: "array" },
|
|
37
|
+
preferRepos: { type: "array" },
|
|
38
|
+
diversityRatio: { type: "float" },
|
|
39
|
+
slmTriageModel: { type: "string" },
|
|
40
|
+
slmTriageHost: { type: "string" },
|
|
41
|
+
featuresAnchorThreshold: { type: "number" },
|
|
42
|
+
featuresSplitRatio: { type: "float" },
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Every configurable preference key, derived from the schema so a new
|
|
46
|
+
* preference can't be silently left unconfigurable. `assertFieldConfigsCover`
|
|
47
|
+
* (exercised by a unit test) fails loudly if FIELD_CONFIGS drifts from this.
|
|
48
|
+
*/
|
|
49
|
+
export const PREFERENCE_KEYS = Object.keys(ScoutPreferencesSchema.shape);
|
|
50
|
+
/** Sorted key list for "unknown key" error messages and help text. */
|
|
51
|
+
export const SORTED_PREFERENCE_KEYS = [
|
|
52
|
+
...PREFERENCE_KEYS,
|
|
53
|
+
].sort();
|
|
54
|
+
/**
|
|
55
|
+
* Throw if any schema preference lacks a FIELD_CONFIG entry. Called from a
|
|
56
|
+
* test so adding a preference to the schema without teaching config-set how to
|
|
57
|
+
* parse it is caught in CI rather than at a user's first `config set newKey`.
|
|
58
|
+
*/
|
|
59
|
+
export function assertFieldConfigsCover() {
|
|
60
|
+
const missing = PREFERENCE_KEYS.filter((k) => !(k in FIELD_CONFIGS));
|
|
61
|
+
if (missing.length > 0) {
|
|
62
|
+
throw new Error(`FIELD_CONFIGS is missing entries for preference keys: ${missing.join(", ")}`);
|
|
63
|
+
}
|
|
64
|
+
const extra = Object.keys(FIELD_CONFIGS).filter((k) => !PREFERENCE_KEYS.includes(k));
|
|
65
|
+
if (extra.length > 0) {
|
|
66
|
+
throw new Error(`FIELD_CONFIGS has entries for unknown preference keys: ${extra.join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function parseBoolean(value) {
|
|
70
|
+
const lower = value.toLowerCase();
|
|
71
|
+
if (lower === "true" || lower === "yes")
|
|
72
|
+
return true;
|
|
73
|
+
if (lower === "false" || lower === "no")
|
|
74
|
+
return false;
|
|
75
|
+
throw new ValidationError(`Invalid boolean value: "${value}". Use true/false or yes/no.`);
|
|
76
|
+
}
|
|
77
|
+
function parseIntValue(value, key) {
|
|
78
|
+
const num = parseInt(value, 10);
|
|
79
|
+
if (isNaN(num)) {
|
|
80
|
+
throw new ValidationError(`Invalid number for "${key}": "${value}"`);
|
|
81
|
+
}
|
|
82
|
+
return num;
|
|
83
|
+
}
|
|
84
|
+
function parseFloatValue(value, key) {
|
|
85
|
+
const num = Number.parseFloat(value);
|
|
86
|
+
if (isNaN(num)) {
|
|
87
|
+
throw new ValidationError(`Invalid number for "${key}": "${value}"`);
|
|
88
|
+
}
|
|
89
|
+
return num;
|
|
90
|
+
}
|
|
91
|
+
function parseArrayValue(value) {
|
|
92
|
+
return value
|
|
93
|
+
.split(",")
|
|
94
|
+
.map((s) => s.trim())
|
|
95
|
+
.filter((s) => s.length > 0);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Apply an array update: plain set, +append, or -remove.
|
|
99
|
+
*
|
|
100
|
+
* The -remove form starts with a dash, which commander rejects as an unknown
|
|
101
|
+
* option unless escaped: `config set excludeRepos -- "-spam/repo"`. The MCP
|
|
102
|
+
* tool has no commander layer so it can pass `-spam/repo` directly. Documented
|
|
103
|
+
* in the CLI help and README (#132).
|
|
104
|
+
*/
|
|
105
|
+
export function updateArray(current, value) {
|
|
106
|
+
if (value.startsWith("+")) {
|
|
107
|
+
const toAdd = parseArrayValue(value.slice(1));
|
|
108
|
+
const merged = [...current];
|
|
109
|
+
for (const item of toAdd) {
|
|
110
|
+
if (!merged.includes(item))
|
|
111
|
+
merged.push(item);
|
|
112
|
+
}
|
|
113
|
+
return merged;
|
|
114
|
+
}
|
|
115
|
+
if (value.startsWith("-")) {
|
|
116
|
+
const toRemove = new Set(parseArrayValue(value.slice(1)));
|
|
117
|
+
return current.filter((item) => !toRemove.has(item));
|
|
118
|
+
}
|
|
119
|
+
return parseArrayValue(value);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Apply a single key/value update to a preferences object and return the
|
|
123
|
+
* fully validated result. The raw string `value` is the form both the CLI and
|
|
124
|
+
* the MCP tool receive; arrays accept comma-separated values and the +add /
|
|
125
|
+
* -remove syntax. Throws ValidationError on an unknown key or a bad value.
|
|
126
|
+
*/
|
|
127
|
+
export function applyPreferenceField(preferences, key, value) {
|
|
128
|
+
const field = FIELD_CONFIGS[key];
|
|
129
|
+
if (!field) {
|
|
130
|
+
throw new ValidationError(`Unknown config key: "${key}". Valid keys: ${SORTED_PREFERENCE_KEYS.join(", ")}`);
|
|
131
|
+
}
|
|
132
|
+
const prefs = { ...preferences };
|
|
133
|
+
switch (field.type) {
|
|
134
|
+
case "string":
|
|
135
|
+
prefs[key] = value;
|
|
136
|
+
break;
|
|
137
|
+
case "boolean":
|
|
138
|
+
prefs[key] = parseBoolean(value);
|
|
139
|
+
break;
|
|
140
|
+
case "number":
|
|
141
|
+
prefs[key] = parseIntValue(value, key);
|
|
142
|
+
break;
|
|
143
|
+
case "float":
|
|
144
|
+
prefs[key] = parseFloatValue(value, key);
|
|
145
|
+
break;
|
|
146
|
+
case "array": {
|
|
147
|
+
const current = prefs[key] ?? [];
|
|
148
|
+
prefs[key] = updateArray(current, value);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "enum": {
|
|
152
|
+
const validValues = field.validValues;
|
|
153
|
+
if (!validValues.includes(value)) {
|
|
154
|
+
throw new ValidationError(`Invalid value for "${key}": "${value}". Valid: ${validValues.join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
prefs[key] = value;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "enum-array": {
|
|
160
|
+
const current = prefs[key] ?? [];
|
|
161
|
+
const updated = updateArray(current, value);
|
|
162
|
+
const validValues = field.validValues;
|
|
163
|
+
const invalid = updated.filter((s) => !validValues.includes(s));
|
|
164
|
+
if (invalid.length > 0) {
|
|
165
|
+
throw new ValidationError(`Invalid value(s) for "${key}": ${invalid.join(", ")}. Valid: ${validValues.join(", ")}`);
|
|
166
|
+
}
|
|
167
|
+
// For 'scope', an empty array means undefined (all scopes).
|
|
168
|
+
if (key === "scope") {
|
|
169
|
+
prefs[key] = updated.length > 0 ? updated : undefined;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
prefs[key] = updated;
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return ScoutPreferencesSchema.parse(prefs);
|
|
178
|
+
}
|
package/dist/core/repo-health.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* from issue-level eligibility logic.
|
|
6
6
|
*/
|
|
7
7
|
import { daysBetween } from "./utils.js";
|
|
8
|
-
import { errorMessage, getHttpStatusCode, isRateLimitError } from "./errors.js";
|
|
8
|
+
import { errorMessage, getHttpStatusCode, isRateLimitError, rethrowIfFatal, } from "./errors.js";
|
|
9
9
|
import { warn } from "./logger.js";
|
|
10
10
|
import { getHttpCache, cachedRequest, cachedTimeBased } from "./http-cache.js";
|
|
11
11
|
const MODULE = "repo-health";
|
|
@@ -73,19 +73,14 @@ export async function checkProjectHealth(octokit, owner, repo) {
|
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
75
|
catch (error) {
|
|
76
|
-
|
|
77
|
-
throw error;
|
|
78
|
-
}
|
|
76
|
+
rethrowIfFatal(error);
|
|
79
77
|
const errMsg = errorMessage(error);
|
|
80
78
|
warn(MODULE, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
|
|
79
|
+
// The check failed: only the repo and the reason are known. The
|
|
80
|
+
// discriminated ProjectHealth type intentionally has no place for the
|
|
81
|
+
// neutral-default snapshot fields this used to fabricate (#158).
|
|
81
82
|
return {
|
|
82
83
|
repo: `${owner}/${repo}`,
|
|
83
|
-
lastCommitAt: "",
|
|
84
|
-
daysSinceLastCommit: 999,
|
|
85
|
-
openIssuesCount: 0,
|
|
86
|
-
avgIssueResponseDays: 0,
|
|
87
|
-
ciStatus: "unknown",
|
|
88
|
-
isActive: false,
|
|
89
84
|
checkFailed: true,
|
|
90
85
|
failureReason: errMsg,
|
|
91
86
|
};
|
|
@@ -104,6 +99,22 @@ export async function fetchContributionGuidelines(octokit, owner, repo) {
|
|
|
104
99
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
105
100
|
return cached.guidelines;
|
|
106
101
|
}
|
|
102
|
+
// Concurrent vets of issues from one repo share a single probe (#124)
|
|
103
|
+
const inflight = guidelinesInflight.get(cacheKey);
|
|
104
|
+
if (inflight)
|
|
105
|
+
return inflight;
|
|
106
|
+
const promise = fetchContributionGuidelinesUncached(octokit, owner, repo);
|
|
107
|
+
guidelinesInflight.set(cacheKey, promise);
|
|
108
|
+
try {
|
|
109
|
+
return await promise;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
guidelinesInflight.delete(cacheKey);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const guidelinesInflight = new Map();
|
|
116
|
+
async function fetchContributionGuidelinesUncached(octokit, owner, repo) {
|
|
117
|
+
const cacheKey = `${owner}/${repo}`;
|
|
107
118
|
const filesToCheck = [
|
|
108
119
|
"CONTRIBUTING.md",
|
|
109
120
|
".github/CONTRIBUTING.md",
|
|
@@ -160,9 +171,13 @@ function parseContributionGuidelines(content) {
|
|
|
160
171
|
rawContent: content,
|
|
161
172
|
};
|
|
162
173
|
const lowerContent = content.toLowerCase();
|
|
163
|
-
// Detect branch naming conventions
|
|
174
|
+
// Detect branch naming conventions. CONTRIBUTING.md is attacker-controlled
|
|
175
|
+
// (it belongs to the repo being vetted): the unbounded [^\n]* pair forced
|
|
176
|
+
// quadratic backtracking on a long quote-less line, stalling the vet
|
|
177
|
+
// (#152). Bounded quantifiers keep the scan linear-ish; real conventions
|
|
178
|
+
// sit well inside 200 chars of their keyword.
|
|
164
179
|
if (lowerContent.includes("branch")) {
|
|
165
|
-
const branchMatch = content.match(/branch[^\n]
|
|
180
|
+
const branchMatch = content.match(/branch[^\n]{0,200}?(?:named?|format|convention)[^\n]{0,200}?[`"]([^`"\n]{1,100})[`"]/i);
|
|
166
181
|
if (branchMatch) {
|
|
167
182
|
guidelines.branchNamingConvention = branchMatch[1];
|
|
168
183
|
}
|
|
@@ -172,7 +187,7 @@ function parseContributionGuidelines(content) {
|
|
|
172
187
|
guidelines.commitMessageFormat = "conventional commits";
|
|
173
188
|
}
|
|
174
189
|
else if (lowerContent.includes("commit message")) {
|
|
175
|
-
const commitMatch = content.match(/commit message[^\n]
|
|
190
|
+
const commitMatch = content.match(/commit message[^\n]{0,200}?[`"]([^`"\n]{1,100})[`"]/i);
|
|
176
191
|
if (commitMatch) {
|
|
177
192
|
guidelines.commitMessageFormat = commitMatch[1];
|
|
178
193
|
}
|
|
@@ -193,8 +208,9 @@ function parseContributionGuidelines(content) {
|
|
|
193
208
|
guidelines.linter = "RuboCop";
|
|
194
209
|
else if (lowerContent.includes("prettier"))
|
|
195
210
|
guidelines.formatter = "Prettier";
|
|
196
|
-
// Detect CLA requirement
|
|
197
|
-
|
|
211
|
+
// Detect CLA requirement. Word boundary matters: a bare substring check
|
|
212
|
+
// matches "class", "clang", "clarify", etc. and flags nearly every doc.
|
|
213
|
+
if (/\bcla\b/.test(lowerContent) ||
|
|
198
214
|
lowerContent.includes("contributor license agreement")) {
|
|
199
215
|
guidelines.claRequired = true;
|
|
200
216
|
}
|
package/dist/core/roadmap.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Auth (401) and rate-limit errors propagate, matching the rest of the
|
|
11
11
|
* codebase's error strategy. Other errors degrade gracefully (warn + empty).
|
|
12
12
|
*/
|
|
13
|
-
import { errorMessage, getHttpStatusCode,
|
|
13
|
+
import { errorMessage, getHttpStatusCode, rethrowIfFatal } from "./errors.js";
|
|
14
14
|
import { warn } from "./logger.js";
|
|
15
15
|
const MODULE = "roadmap";
|
|
16
16
|
/** TTL for roadmap fetch results (1 hour). */
|
|
@@ -97,6 +97,21 @@ export async function fetchRoadmapIssueRefs(octokit, owner, repo) {
|
|
|
97
97
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
98
98
|
return cached.refs;
|
|
99
99
|
}
|
|
100
|
+
// Concurrent feature vets of issues from one repo share a probe (#124)
|
|
101
|
+
const inflight = roadmapInflight.get(cacheKey);
|
|
102
|
+
if (inflight)
|
|
103
|
+
return inflight;
|
|
104
|
+
const promise = fetchRoadmapIssueRefsUncached(octokit, owner, repo, cacheKey);
|
|
105
|
+
roadmapInflight.set(cacheKey, promise);
|
|
106
|
+
try {
|
|
107
|
+
return await promise;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
roadmapInflight.delete(cacheKey);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const roadmapInflight = new Map();
|
|
114
|
+
async function fetchRoadmapIssueRefsUncached(octokit, owner, repo, cacheKey) {
|
|
100
115
|
for (const path of ROADMAP_PATHS) {
|
|
101
116
|
try {
|
|
102
117
|
const { data } = await octokit.repos.getContent({ owner, repo, path });
|
|
@@ -109,8 +124,7 @@ export async function fetchRoadmapIssueRefs(octokit, owner, repo) {
|
|
|
109
124
|
return refs;
|
|
110
125
|
}
|
|
111
126
|
catch (err) {
|
|
112
|
-
|
|
113
|
-
throw err;
|
|
127
|
+
rethrowIfFatal(err);
|
|
114
128
|
const status = getHttpStatusCode(err);
|
|
115
129
|
if (status === 404)
|
|
116
130
|
continue; // path missing — try next
|