@oss-scout/core 0.2.0 → 0.3.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 +51 -47
- package/dist/cli.js +218 -87
- package/dist/commands/config.d.ts +2 -4
- package/dist/commands/config.js +76 -78
- package/dist/commands/results.d.ts +1 -1
- package/dist/commands/results.js +1 -1
- package/dist/commands/search.d.ts +2 -2
- package/dist/commands/search.js +16 -6
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +25 -25
- package/dist/commands/skip.d.ts +33 -0
- package/dist/commands/skip.js +89 -0
- package/dist/commands/validation.d.ts +1 -1
- package/dist/commands/validation.js +1 -1
- package/dist/commands/vet-list.d.ts +2 -2
- package/dist/commands/vet-list.js +12 -5
- package/dist/commands/vet.d.ts +3 -3
- package/dist/commands/vet.js +9 -5
- package/dist/core/bootstrap.d.ts +1 -1
- package/dist/core/bootstrap.js +20 -16
- package/dist/core/category-mapping.d.ts +1 -1
- package/dist/core/category-mapping.js +104 -13
- package/dist/core/errors.d.ts +8 -1
- package/dist/core/errors.js +31 -19
- package/dist/core/gist-state-store.d.ts +1 -1
- package/dist/core/gist-state-store.js +55 -28
- package/dist/core/github.d.ts +1 -1
- package/dist/core/github.js +5 -5
- package/dist/core/http-cache.js +26 -22
- package/dist/core/issue-discovery.d.ts +6 -6
- package/dist/core/issue-discovery.js +279 -286
- package/dist/core/issue-eligibility.d.ts +2 -2
- package/dist/core/issue-eligibility.js +26 -21
- package/dist/core/issue-filtering.js +23 -15
- package/dist/core/issue-scoring.js +1 -1
- package/dist/core/issue-vetting.d.ts +2 -4
- package/dist/core/issue-vetting.js +65 -56
- package/dist/core/local-state.d.ts +1 -1
- package/dist/core/local-state.js +16 -14
- package/dist/core/repo-health.d.ts +2 -2
- package/dist/core/repo-health.js +46 -35
- package/dist/core/schemas.d.ts +17 -9
- package/dist/core/schemas.js +47 -19
- package/dist/core/search-budget.js +3 -3
- package/dist/core/search-phases.d.ts +6 -6
- package/dist/core/search-phases.js +23 -19
- package/dist/core/types.d.ts +9 -9
- package/dist/core/types.js +15 -3
- package/dist/core/utils.d.ts +10 -1
- package/dist/core/utils.js +44 -25
- package/dist/formatters/json.d.ts +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.js +5 -5
- package/dist/scout.d.ts +30 -6
- package/dist/scout.js +141 -34
- package/package.json +7 -3
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Extracted from issue-vetting.ts to isolate eligibility logic.
|
|
7
7
|
*/
|
|
8
|
-
import { Octokit } from
|
|
9
|
-
import type { CheckResult } from
|
|
8
|
+
import { Octokit } from "@octokit/rest";
|
|
9
|
+
import type { CheckResult } from "./types.js";
|
|
10
10
|
/**
|
|
11
11
|
* Check whether an open PR already exists for the given issue.
|
|
12
12
|
* Uses the timeline API (REST) to detect cross-referenced PRs, avoiding
|
|
@@ -5,29 +5,29 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Extracted from issue-vetting.ts to isolate eligibility logic.
|
|
7
7
|
*/
|
|
8
|
-
import { paginateAll } from
|
|
9
|
-
import { errorMessage } from
|
|
10
|
-
import { warn } from
|
|
11
|
-
import { getHttpCache } from
|
|
12
|
-
import { getSearchBudgetTracker } from
|
|
13
|
-
const MODULE =
|
|
8
|
+
import { paginateAll } from "./pagination.js";
|
|
9
|
+
import { errorMessage } from "./errors.js";
|
|
10
|
+
import { warn } from "./logger.js";
|
|
11
|
+
import { getHttpCache } from "./http-cache.js";
|
|
12
|
+
import { getSearchBudgetTracker } from "./search-budget.js";
|
|
13
|
+
const MODULE = "issue-eligibility";
|
|
14
14
|
/** Phrases that indicate someone has already claimed an issue. */
|
|
15
15
|
const CLAIM_PHRASES = [
|
|
16
16
|
"i'm working on this",
|
|
17
|
-
|
|
17
|
+
"i am working on this",
|
|
18
18
|
"i'll take this",
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
"i will take this",
|
|
20
|
+
"working on it",
|
|
21
21
|
"i'd like to work on",
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
"i would like to work on",
|
|
23
|
+
"can i work on",
|
|
24
|
+
"may i work on",
|
|
25
|
+
"assigned to me",
|
|
26
26
|
"i'm on it",
|
|
27
27
|
"i'll submit a pr",
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
"i will submit a pr",
|
|
29
|
+
"working on a fix",
|
|
30
|
+
"working on a pr",
|
|
31
31
|
];
|
|
32
32
|
/**
|
|
33
33
|
* Check whether an open PR already exists for the given issue.
|
|
@@ -50,7 +50,7 @@ export async function checkNoExistingPR(octokit, owner, repo, issueNumber) {
|
|
|
50
50
|
}));
|
|
51
51
|
const linkedPRs = timeline.filter((event) => {
|
|
52
52
|
const e = event;
|
|
53
|
-
return e.event ===
|
|
53
|
+
return e.event === "cross-referenced" && e.source?.issue?.pull_request;
|
|
54
54
|
});
|
|
55
55
|
return { passed: linkedPRs.length === 0 };
|
|
56
56
|
}
|
|
@@ -75,7 +75,7 @@ export async function checkUserMergedPRsInRepo(octokit, owner, repo) {
|
|
|
75
75
|
// error-path fallback values (a transient failure returning 0 would poison the
|
|
76
76
|
// cache for 15 minutes, hiding that the user has merged PRs in the repo).
|
|
77
77
|
const cached = cache.getIfFresh(cacheKey, MERGED_PR_CACHE_TTL_MS);
|
|
78
|
-
if (cached != null && typeof cached ===
|
|
78
|
+
if (cached != null && typeof cached === "number") {
|
|
79
79
|
return cached;
|
|
80
80
|
}
|
|
81
81
|
try {
|
|
@@ -88,7 +88,7 @@ export async function checkUserMergedPRsInRepo(octokit, owner, repo) {
|
|
|
88
88
|
per_page: 1, // We only need total_count
|
|
89
89
|
});
|
|
90
90
|
// Only cache successful results
|
|
91
|
-
cache.set(cacheKey,
|
|
91
|
+
cache.set(cacheKey, "", data.total_count);
|
|
92
92
|
return data.total_count;
|
|
93
93
|
}
|
|
94
94
|
finally {
|
|
@@ -120,7 +120,7 @@ export async function checkNotClaimed(octokit, owner, repo, issueNumber, comment
|
|
|
120
120
|
// Limit to last 100 comments to avoid excessive processing
|
|
121
121
|
const recentComments = comments.slice(-100);
|
|
122
122
|
for (const comment of recentComments) {
|
|
123
|
-
const body = (comment.body ||
|
|
123
|
+
const body = (comment.body || "").toLowerCase();
|
|
124
124
|
if (CLAIM_PHRASES.some((phrase) => body.includes(phrase))) {
|
|
125
125
|
return { passed: false };
|
|
126
126
|
}
|
|
@@ -146,6 +146,11 @@ export function analyzeRequirements(body) {
|
|
|
146
146
|
const hasCodeBlock = /```/.test(body);
|
|
147
147
|
const hasExpectedBehavior = /expect|should|must|want/i.test(body);
|
|
148
148
|
// Must have at least two indicators of clarity
|
|
149
|
-
const indicators = [
|
|
149
|
+
const indicators = [
|
|
150
|
+
hasSteps,
|
|
151
|
+
hasCodeBlock,
|
|
152
|
+
hasExpectedBehavior,
|
|
153
|
+
body.length > 200,
|
|
154
|
+
];
|
|
150
155
|
return indicators.filter(Boolean).length >= 2;
|
|
151
156
|
}
|
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
* Extracted from issue-discovery.ts to isolate filtering logic:
|
|
5
5
|
* label farming detection, doc-only filtering, per-repo caps, templated title detection.
|
|
6
6
|
*/
|
|
7
|
+
import { extractRepoFromUrl } from "./utils.js";
|
|
7
8
|
/** Labels that indicate documentation-only issues. */
|
|
8
|
-
export const DOC_ONLY_LABELS = new Set([
|
|
9
|
+
export const DOC_ONLY_LABELS = new Set([
|
|
10
|
+
"documentation",
|
|
11
|
+
"docs",
|
|
12
|
+
"typo",
|
|
13
|
+
"spelling",
|
|
14
|
+
]);
|
|
9
15
|
/**
|
|
10
16
|
* Check if an issue's labels are ALL documentation-related.
|
|
11
17
|
* Issues with mixed labels (e.g., "good first issue" + "documentation") pass through.
|
|
@@ -14,7 +20,7 @@ export const DOC_ONLY_LABELS = new Set(['documentation', 'docs', 'typo', 'spelli
|
|
|
14
20
|
export function isDocOnlyIssue(item) {
|
|
15
21
|
if (!item.labels || !Array.isArray(item.labels) || item.labels.length === 0)
|
|
16
22
|
return false;
|
|
17
|
-
const labelNames = item.labels.map((l) => (typeof l ===
|
|
23
|
+
const labelNames = item.labels.map((l) => (typeof l === "string" ? l : l.name || "").toLowerCase());
|
|
18
24
|
// Filter out empty label names before checking
|
|
19
25
|
const nonEmptyLabels = labelNames.filter((n) => n.length > 0);
|
|
20
26
|
if (nonEmptyLabels.length === 0)
|
|
@@ -23,23 +29,23 @@ export function isDocOnlyIssue(item) {
|
|
|
23
29
|
}
|
|
24
30
|
/** Known beginner-type label names used to detect label-farming repos. */
|
|
25
31
|
export const BEGINNER_LABELS = new Set([
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
"good first issue",
|
|
33
|
+
"hacktoberfest",
|
|
34
|
+
"easy",
|
|
35
|
+
"up-for-grabs",
|
|
36
|
+
"first-timers-only",
|
|
37
|
+
"beginner-friendly",
|
|
38
|
+
"beginner",
|
|
39
|
+
"starter",
|
|
40
|
+
"newbie",
|
|
41
|
+
"low-hanging-fruit",
|
|
42
|
+
"community",
|
|
37
43
|
]);
|
|
38
44
|
/** Check if a single issue has an excessive number of beginner labels (>= 5). */
|
|
39
45
|
export function isLabelFarming(item) {
|
|
40
46
|
if (!item.labels || !Array.isArray(item.labels))
|
|
41
47
|
return false;
|
|
42
|
-
const labelNames = item.labels.map((l) => (typeof l ===
|
|
48
|
+
const labelNames = item.labels.map((l) => (typeof l === "string" ? l : l.name || "").toLowerCase());
|
|
43
49
|
const beginnerCount = labelNames.filter((n) => BEGINNER_LABELS.has(n)).length;
|
|
44
50
|
return beginnerCount >= 5;
|
|
45
51
|
}
|
|
@@ -64,7 +70,9 @@ export function detectLabelFarmingRepos(items) {
|
|
|
64
70
|
const spamRepos = new Set();
|
|
65
71
|
const repoSpamCounts = new Map();
|
|
66
72
|
for (const item of items) {
|
|
67
|
-
const repoFullName = item.repository_url
|
|
73
|
+
const repoFullName = extractRepoFromUrl(item.repository_url);
|
|
74
|
+
if (!repoFullName)
|
|
75
|
+
continue;
|
|
68
76
|
// Strong signal: single issue with 5+ beginner labels
|
|
69
77
|
if (isLabelFarming(item)) {
|
|
70
78
|
spamRepos.add(repoFullName);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Extracted from issue-discovery.ts to isolate scoring logic.
|
|
5
5
|
*/
|
|
6
|
-
import { daysBetween } from
|
|
6
|
+
import { daysBetween } from "./utils.js";
|
|
7
7
|
/**
|
|
8
8
|
* Calculate a quality bonus based on repo star and fork counts.
|
|
9
9
|
* Stars: <50 -> 0, 50-499 -> +3, 500-4999 -> +5, 5000+ -> +8
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* - issue-eligibility.ts — PR existence, claim detection, requirements analysis
|
|
7
7
|
* - repo-health.ts — project health, contribution guidelines
|
|
8
8
|
*/
|
|
9
|
-
import { Octokit } from
|
|
10
|
-
import { type SearchPriority, type IssueCandidate, type ProjectCategory } from
|
|
9
|
+
import { Octokit } from "@octokit/rest";
|
|
10
|
+
import { type SearchPriority, type IssueCandidate, type ProjectCategory } from "./types.js";
|
|
11
11
|
/**
|
|
12
12
|
* Read-only interface for accessing scout state during issue vetting.
|
|
13
13
|
* Implementations may be backed by gist persistence, in-memory state, etc.
|
|
@@ -17,8 +17,6 @@ export interface ScoutStateReader {
|
|
|
17
17
|
getReposWithMergedPRs(): string[];
|
|
18
18
|
/** User's starred repos (from GitHub). */
|
|
19
19
|
getStarredRepos(): string[];
|
|
20
|
-
/** Preferred GitHub orgs from user preferences. */
|
|
21
|
-
getPreferredOrgs(): string[];
|
|
22
20
|
/** Preferred project categories from user preferences. */
|
|
23
21
|
getProjectCategories(): ProjectCategory[];
|
|
24
22
|
/** Numeric quality score for a repo, or null if not evaluated. */
|
|
@@ -6,15 +6,15 @@
|
|
|
6
6
|
* - issue-eligibility.ts — PR existence, claim detection, requirements analysis
|
|
7
7
|
* - repo-health.ts — project health, contribution guidelines
|
|
8
8
|
*/
|
|
9
|
-
import { parseGitHubUrl } from
|
|
10
|
-
import { ValidationError, errorMessage, isRateLimitError } from
|
|
11
|
-
import { debug, warn } from
|
|
12
|
-
import { calculateRepoQualityBonus, calculateViabilityScore } from
|
|
13
|
-
import { repoBelongsToCategory } from
|
|
14
|
-
import { checkNoExistingPR, checkNotClaimed, checkUserMergedPRsInRepo, analyzeRequirements, } from
|
|
15
|
-
import { checkProjectHealth, fetchContributionGuidelines } from
|
|
16
|
-
import { getHttpCache } from
|
|
17
|
-
const MODULE =
|
|
9
|
+
import { parseGitHubUrl } from "./utils.js";
|
|
10
|
+
import { ValidationError, errorMessage, isRateLimitError } from "./errors.js";
|
|
11
|
+
import { debug, warn } from "./logger.js";
|
|
12
|
+
import { calculateRepoQualityBonus, calculateViabilityScore, } from "./issue-scoring.js";
|
|
13
|
+
import { repoBelongsToCategory } from "./category-mapping.js";
|
|
14
|
+
import { checkNoExistingPR, checkNotClaimed, checkUserMergedPRsInRepo, analyzeRequirements, } from "./issue-eligibility.js";
|
|
15
|
+
import { checkProjectHealth, fetchContributionGuidelines, } from "./repo-health.js";
|
|
16
|
+
import { getHttpCache } from "./http-cache.js";
|
|
17
|
+
const MODULE = "issue-vetting";
|
|
18
18
|
/** Vetting concurrency: kept low to reduce burst pressure on GitHub's secondary rate limit. */
|
|
19
19
|
const MAX_CONCURRENT_VETTING = 3;
|
|
20
20
|
/** TTL for cached vetting results (15 minutes). Kept short so config changes take effect quickly. */
|
|
@@ -35,13 +35,16 @@ export class IssueVetter {
|
|
|
35
35
|
const cache = getHttpCache();
|
|
36
36
|
const cacheKey = `vet:${issueUrl}`;
|
|
37
37
|
const cached = cache.getIfFresh(cacheKey, VETTING_CACHE_TTL_MS);
|
|
38
|
-
if (cached &&
|
|
38
|
+
if (cached &&
|
|
39
|
+
typeof cached === "object" &&
|
|
40
|
+
"issue" in cached &&
|
|
41
|
+
"viabilityScore" in cached) {
|
|
39
42
|
debug(MODULE, `Vetting cache hit for ${issueUrl}`);
|
|
40
43
|
return cached;
|
|
41
44
|
}
|
|
42
45
|
// Parse URL
|
|
43
46
|
const parsed = parseGitHubUrl(issueUrl);
|
|
44
|
-
if (!parsed || parsed.type !==
|
|
47
|
+
if (!parsed || parsed.type !== "issues") {
|
|
45
48
|
throw new ValidationError(`Invalid issue URL: ${issueUrl}`);
|
|
46
49
|
}
|
|
47
50
|
const { owner, repo, number } = parsed;
|
|
@@ -56,20 +59,24 @@ export class IssueVetter {
|
|
|
56
59
|
const reposWithMergedPRs = this.stateReader.getReposWithMergedPRs();
|
|
57
60
|
const hasMergedPRsInRepo = reposWithMergedPRs.includes(repoFullName);
|
|
58
61
|
// Run all vetting checks in parallel — delegates to standalone functions
|
|
59
|
-
const [existingPRCheck, claimCheck, projectHealth, contributionGuidelines, userMergedPRCount] = await Promise.all([
|
|
62
|
+
const [existingPRCheck, claimCheck, projectHealth, contributionGuidelines, userMergedPRCount,] = await Promise.all([
|
|
60
63
|
checkNoExistingPR(this.octokit, owner, repo, number),
|
|
61
64
|
checkNotClaimed(this.octokit, owner, repo, number, ghIssue.comments),
|
|
62
65
|
checkProjectHealth(this.octokit, owner, repo),
|
|
63
66
|
fetchContributionGuidelines(this.octokit, owner, repo),
|
|
64
|
-
hasMergedPRsInRepo
|
|
67
|
+
hasMergedPRsInRepo
|
|
68
|
+
? Promise.resolve(0)
|
|
69
|
+
: checkUserMergedPRsInRepo(this.octokit, owner, repo),
|
|
65
70
|
]);
|
|
66
71
|
const noExistingPR = existingPRCheck.passed;
|
|
67
72
|
const notClaimed = claimCheck.passed;
|
|
68
73
|
// Analyze issue quality
|
|
69
|
-
const clearRequirements = analyzeRequirements(ghIssue.body ||
|
|
74
|
+
const clearRequirements = analyzeRequirements(ghIssue.body || "");
|
|
70
75
|
// When the health check itself failed (API error), use a neutral default:
|
|
71
76
|
// don't penalize the repo as inactive, but don't credit it as active either.
|
|
72
|
-
const projectActive = projectHealth.checkFailed
|
|
77
|
+
const projectActive = projectHealth.checkFailed
|
|
78
|
+
? true
|
|
79
|
+
: projectHealth.isActive;
|
|
73
80
|
const vettingResult = {
|
|
74
81
|
passedAllChecks: noExistingPR && notClaimed && projectActive && clearRequirements,
|
|
75
82
|
checks: {
|
|
@@ -84,25 +91,25 @@ export class IssueVetter {
|
|
|
84
91
|
};
|
|
85
92
|
// Build notes
|
|
86
93
|
if (!noExistingPR)
|
|
87
|
-
vettingResult.notes.push(
|
|
94
|
+
vettingResult.notes.push("Existing PR found for this issue");
|
|
88
95
|
if (!notClaimed)
|
|
89
|
-
vettingResult.notes.push(
|
|
96
|
+
vettingResult.notes.push("Issue appears to be claimed by someone");
|
|
90
97
|
if (existingPRCheck.inconclusive) {
|
|
91
|
-
vettingResult.notes.push(`Could not verify absence of existing PRs: ${existingPRCheck.reason ||
|
|
98
|
+
vettingResult.notes.push(`Could not verify absence of existing PRs: ${existingPRCheck.reason || "API error"}`);
|
|
92
99
|
}
|
|
93
100
|
if (claimCheck.inconclusive) {
|
|
94
|
-
vettingResult.notes.push(`Could not verify claim status: ${claimCheck.reason ||
|
|
101
|
+
vettingResult.notes.push(`Could not verify claim status: ${claimCheck.reason || "API error"}`);
|
|
95
102
|
}
|
|
96
103
|
if (projectHealth.checkFailed) {
|
|
97
|
-
vettingResult.notes.push(`Could not verify project activity: ${projectHealth.failureReason ||
|
|
104
|
+
vettingResult.notes.push(`Could not verify project activity: ${projectHealth.failureReason || "API error"}`);
|
|
98
105
|
}
|
|
99
106
|
else if (!projectHealth.isActive) {
|
|
100
|
-
vettingResult.notes.push(
|
|
107
|
+
vettingResult.notes.push("Project may be inactive");
|
|
101
108
|
}
|
|
102
109
|
if (!clearRequirements)
|
|
103
|
-
vettingResult.notes.push(
|
|
110
|
+
vettingResult.notes.push("Issue requirements are unclear");
|
|
104
111
|
if (!contributionGuidelines)
|
|
105
|
-
vettingResult.notes.push(
|
|
112
|
+
vettingResult.notes.push("No CONTRIBUTING.md found");
|
|
106
113
|
// Create tracked issue
|
|
107
114
|
const trackedIssue = {
|
|
108
115
|
id: ghIssue.id,
|
|
@@ -110,8 +117,8 @@ export class IssueVetter {
|
|
|
110
117
|
repo: repoFullName,
|
|
111
118
|
number,
|
|
112
119
|
title: ghIssue.title,
|
|
113
|
-
status:
|
|
114
|
-
labels: ghIssue.labels.map((l) =>
|
|
120
|
+
status: "candidate",
|
|
121
|
+
labels: ghIssue.labels.map((l) => typeof l === "string" ? l : l.name || ""),
|
|
115
122
|
createdAt: ghIssue.created_at,
|
|
116
123
|
updatedAt: ghIssue.updated_at,
|
|
117
124
|
vetted: true,
|
|
@@ -121,34 +128,34 @@ export class IssueVetter {
|
|
|
121
128
|
const reasonsToSkip = [];
|
|
122
129
|
const reasonsToApprove = [];
|
|
123
130
|
if (!noExistingPR)
|
|
124
|
-
reasonsToSkip.push(
|
|
131
|
+
reasonsToSkip.push("Has existing PR");
|
|
125
132
|
if (!notClaimed)
|
|
126
|
-
reasonsToSkip.push(
|
|
133
|
+
reasonsToSkip.push("Already claimed");
|
|
127
134
|
if (!projectHealth.isActive && !projectHealth.checkFailed)
|
|
128
|
-
reasonsToSkip.push(
|
|
135
|
+
reasonsToSkip.push("Inactive project");
|
|
129
136
|
if (!clearRequirements)
|
|
130
|
-
reasonsToSkip.push(
|
|
137
|
+
reasonsToSkip.push("Unclear requirements");
|
|
131
138
|
if (noExistingPR)
|
|
132
|
-
reasonsToApprove.push(
|
|
139
|
+
reasonsToApprove.push("No existing PR");
|
|
133
140
|
if (notClaimed)
|
|
134
|
-
reasonsToApprove.push(
|
|
141
|
+
reasonsToApprove.push("Not claimed");
|
|
135
142
|
if (projectHealth.isActive && !projectHealth.checkFailed)
|
|
136
|
-
reasonsToApprove.push(
|
|
143
|
+
reasonsToApprove.push("Active project");
|
|
137
144
|
if (clearRequirements)
|
|
138
|
-
reasonsToApprove.push(
|
|
145
|
+
reasonsToApprove.push("Clear requirements");
|
|
139
146
|
if (contributionGuidelines)
|
|
140
|
-
reasonsToApprove.push(
|
|
147
|
+
reasonsToApprove.push("Has contribution guidelines");
|
|
141
148
|
// Determine effective merged PR count: prefer local state (authoritative if present),
|
|
142
149
|
// fall back to live GitHub API count to detect contributions made before using oss-scout
|
|
143
150
|
const effectiveMergedCount = hasMergedPRsInRepo ? 1 : userMergedPRCount;
|
|
144
151
|
if (effectiveMergedCount > 0) {
|
|
145
|
-
reasonsToApprove.push(`Trusted project (${effectiveMergedCount} PR${effectiveMergedCount > 1 ?
|
|
152
|
+
reasonsToApprove.push(`Trusted project (${effectiveMergedCount} PR${effectiveMergedCount > 1 ? "s" : ""} merged)`);
|
|
146
153
|
}
|
|
147
154
|
// Check for org-level affinity (user has merged PRs in another repo under same org)
|
|
148
|
-
const orgName = repoFullName.split(
|
|
155
|
+
const orgName = repoFullName.split("/")[0];
|
|
149
156
|
let orgHasMergedPRs = false;
|
|
150
|
-
if (orgName && repoFullName.includes(
|
|
151
|
-
orgHasMergedPRs = reposWithMergedPRs.some((r) => r.startsWith(orgName +
|
|
157
|
+
if (orgName && repoFullName.includes("/")) {
|
|
158
|
+
orgHasMergedPRs = reposWithMergedPRs.some((r) => r.startsWith(orgName + "/") && r !== repoFullName);
|
|
152
159
|
}
|
|
153
160
|
if (orgHasMergedPRs) {
|
|
154
161
|
reasonsToApprove.push(`Org affinity (merged PRs in other ${orgName} repos)`);
|
|
@@ -157,29 +164,31 @@ export class IssueVetter {
|
|
|
157
164
|
const projectCategories = this.stateReader.getProjectCategories();
|
|
158
165
|
const matchesCategory = repoBelongsToCategory(repoFullName, projectCategories);
|
|
159
166
|
if (matchesCategory) {
|
|
160
|
-
reasonsToApprove.push(
|
|
167
|
+
reasonsToApprove.push("Matches preferred project category");
|
|
161
168
|
}
|
|
162
169
|
let recommendation;
|
|
163
170
|
if (vettingResult.passedAllChecks) {
|
|
164
|
-
recommendation =
|
|
171
|
+
recommendation = "approve";
|
|
165
172
|
}
|
|
166
173
|
else if (reasonsToSkip.length > 2) {
|
|
167
|
-
recommendation =
|
|
174
|
+
recommendation = "skip";
|
|
168
175
|
}
|
|
169
176
|
else {
|
|
170
|
-
recommendation =
|
|
177
|
+
recommendation = "needs_review";
|
|
171
178
|
}
|
|
172
179
|
// Downgrade to needs_review if any check was inconclusive —
|
|
173
180
|
// "approve" should only be given when all checks actually passed, not when they were skipped.
|
|
174
|
-
const hasInconclusiveChecks = projectHealth.checkFailed ||
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
181
|
+
const hasInconclusiveChecks = projectHealth.checkFailed ||
|
|
182
|
+
existingPRCheck.inconclusive ||
|
|
183
|
+
claimCheck.inconclusive;
|
|
184
|
+
if (recommendation === "approve" && hasInconclusiveChecks) {
|
|
185
|
+
recommendation = "needs_review";
|
|
186
|
+
vettingResult.notes.push("Recommendation downgraded: one or more checks were inconclusive");
|
|
178
187
|
}
|
|
179
188
|
// Calculate repo quality bonus from star/fork counts
|
|
180
189
|
const repoQualityBonus = calculateRepoQualityBonus(projectHealth.stargazersCount ?? 0, projectHealth.forksCount ?? 0);
|
|
181
190
|
if (projectHealth.checkFailed && repoQualityBonus === 0) {
|
|
182
|
-
vettingResult.notes.push(
|
|
191
|
+
vettingResult.notes.push("Repo quality bonus unavailable: could not fetch star/fork counts due to API error");
|
|
183
192
|
}
|
|
184
193
|
const repoScore = this.stateReader.getRepoScore(repoFullName);
|
|
185
194
|
const viabilityScore = calculateViabilityScore({
|
|
@@ -196,16 +205,12 @@ export class IssueVetter {
|
|
|
196
205
|
matchesPreferredCategory: matchesCategory,
|
|
197
206
|
});
|
|
198
207
|
const starredRepos = this.stateReader.getStarredRepos();
|
|
199
|
-
|
|
200
|
-
let searchPriority = 'normal';
|
|
208
|
+
let searchPriority = "normal";
|
|
201
209
|
if (effectiveMergedCount > 0) {
|
|
202
|
-
searchPriority =
|
|
203
|
-
}
|
|
204
|
-
else if (preferredOrgs.some((o) => o.toLowerCase() === orgName?.toLowerCase())) {
|
|
205
|
-
searchPriority = 'preferred_org';
|
|
210
|
+
searchPriority = "merged_pr";
|
|
206
211
|
}
|
|
207
212
|
else if (starredRepos.includes(repoFullName)) {
|
|
208
|
-
searchPriority =
|
|
213
|
+
searchPriority = "starred";
|
|
209
214
|
}
|
|
210
215
|
const result = {
|
|
211
216
|
issue: trackedIssue,
|
|
@@ -218,7 +223,7 @@ export class IssueVetter {
|
|
|
218
223
|
searchPriority,
|
|
219
224
|
};
|
|
220
225
|
// Cache the vetting result to avoid redundant API calls on repeated searches
|
|
221
|
-
cache.set(cacheKey,
|
|
226
|
+
cache.set(cacheKey, "", result);
|
|
222
227
|
return result;
|
|
223
228
|
}
|
|
224
229
|
/**
|
|
@@ -265,6 +270,10 @@ export class IssueVetter {
|
|
|
265
270
|
warn(MODULE, `All ${attemptedCount} issue(s) failed vetting. ` +
|
|
266
271
|
`This may indicate a systemic issue (rate limit, auth, network).`);
|
|
267
272
|
}
|
|
268
|
-
return {
|
|
273
|
+
return {
|
|
274
|
+
candidates: candidates.slice(0, maxResults),
|
|
275
|
+
allFailed,
|
|
276
|
+
rateLimitHit: rateLimitFailures > 0,
|
|
277
|
+
};
|
|
269
278
|
}
|
|
270
279
|
}
|
package/dist/core/local-state.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local state persistence — reads/writes ScoutState to ~/.oss-scout/state.json.
|
|
3
3
|
*/
|
|
4
|
-
import * as fs from
|
|
5
|
-
import * as path from
|
|
6
|
-
import { ScoutStateSchema } from
|
|
7
|
-
import { getDataDir } from
|
|
8
|
-
import { debug, warn } from
|
|
9
|
-
import { errorMessage } from
|
|
10
|
-
const MODULE =
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { ScoutStateSchema } from "./schemas.js";
|
|
7
|
+
import { getDataDir } from "./utils.js";
|
|
8
|
+
import { debug, warn } from "./logger.js";
|
|
9
|
+
import { errorMessage } from "./errors.js";
|
|
10
|
+
const MODULE = "local-state";
|
|
11
11
|
function getStatePath() {
|
|
12
|
-
return path.join(getDataDir(),
|
|
12
|
+
return path.join(getDataDir(), "state.json");
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* Check if a local state file exists.
|
|
@@ -23,12 +23,12 @@ export function hasLocalState() {
|
|
|
23
23
|
export function loadLocalState() {
|
|
24
24
|
const statePath = getStatePath();
|
|
25
25
|
try {
|
|
26
|
-
const raw = fs.readFileSync(statePath,
|
|
26
|
+
const raw = fs.readFileSync(statePath, "utf-8");
|
|
27
27
|
return ScoutStateSchema.parse(JSON.parse(raw));
|
|
28
28
|
}
|
|
29
29
|
catch (err) {
|
|
30
30
|
const code = err?.code;
|
|
31
|
-
if (code ===
|
|
31
|
+
if (code === "ENOENT") {
|
|
32
32
|
return ScoutStateSchema.parse({ version: 1 });
|
|
33
33
|
}
|
|
34
34
|
// State file exists but is corrupt or unreadable
|
|
@@ -39,7 +39,9 @@ export function loadLocalState() {
|
|
|
39
39
|
fs.copyFileSync(statePath, backupPath);
|
|
40
40
|
warn(MODULE, `Corrupt state backed up to ${backupPath}`);
|
|
41
41
|
}
|
|
42
|
-
catch {
|
|
42
|
+
catch (backupErr) {
|
|
43
|
+
warn(MODULE, `Failed to back up corrupt state: ${errorMessage(backupErr)}`);
|
|
44
|
+
}
|
|
43
45
|
return ScoutStateSchema.parse({ version: 1 });
|
|
44
46
|
}
|
|
45
47
|
}
|
|
@@ -48,9 +50,9 @@ export function loadLocalState() {
|
|
|
48
50
|
*/
|
|
49
51
|
export function saveLocalState(state) {
|
|
50
52
|
const statePath = getStatePath();
|
|
51
|
-
const tmpPath = statePath +
|
|
52
|
-
const data = JSON.stringify(state, null, 2) +
|
|
53
|
+
const tmpPath = statePath + ".tmp";
|
|
54
|
+
const data = JSON.stringify(state, null, 2) + "\n";
|
|
53
55
|
fs.writeFileSync(tmpPath, data, { mode: 0o600 });
|
|
54
56
|
fs.renameSync(tmpPath, statePath);
|
|
55
|
-
debug(MODULE,
|
|
57
|
+
debug(MODULE, "State saved");
|
|
56
58
|
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Extracted from issue-vetting.ts to isolate repo-level checks
|
|
5
5
|
* from issue-level eligibility logic.
|
|
6
6
|
*/
|
|
7
|
-
import { Octokit } from
|
|
8
|
-
import { type ContributionGuidelines, type ProjectHealth } from
|
|
7
|
+
import { Octokit } from "@octokit/rest";
|
|
8
|
+
import { type ContributionGuidelines, type ProjectHealth } from "./types.js";
|
|
9
9
|
/**
|
|
10
10
|
* Check the health of a GitHub project: recent commits, CI status, star/fork counts.
|
|
11
11
|
* Results are cached for HEALTH_CACHE_TTL_MS (4 hours).
|