@oss-scout/core
Advanced tools
@@ -10,3 +10,3 @@ /** | ||
| import { Octokit } from "@octokit/rest"; | ||
| import { type SearchPriority, type IssueCandidate, type ProjectCategory, type ScoutPreferences, type ScoutState, type MergedPRRecord, type ClosedPRRecord, type OpenPRRecord } from "./types.js"; | ||
| import { type SearchPriority, type IssueCandidate, type ProjectCategory, type ProjectHealth, type ScoutPreferences, type ScoutState, type MergedPRRecord, type ClosedPRRecord, type OpenPRRecord } from "./types.js"; | ||
| import { type PrefetchedIssueCore } from "./issue-graphql.js"; | ||
@@ -100,2 +100,17 @@ import { type SearchBudgetTracker } from "./search-budget.js"; | ||
| /** | ||
| * Repo-intrinsic contribution-acceptance signal from the health snapshot | ||
| * (#248/#249/#1575). Does the repo merge PR-based contributions at all over the | ||
| * last 90 days? | ||
| * - `true` — merged at least one PR | ||
| * - `false` — had closed PRs but merged none (it rejects the work it receives) | ||
| * - `null` — no PR activity in the window, or health/counts unavailable; | ||
| * inconclusive → needs_review, never a hard skip | ||
| * | ||
| * This deliberately does NOT distinguish maintainer self-merges from outside | ||
| * contributions — a solo repo that merges only its own PRs still reads `true`. | ||
| * That contributor-diversity refinement is deferred (#248 secondary); the point | ||
| * here is only to stop auto-approving repos that merge nothing at all. | ||
| */ | ||
| export declare function repoAcceptsContributionsFromHealth(projectHealth: ProjectHealth): boolean | null; | ||
| /** | ||
| * Inputs to deriveRecommendation: the already-computed check results and | ||
@@ -144,2 +159,12 @@ * affinity signals. Kept as a flat record of primitives so the derivation is a | ||
| passedAllChecks: boolean; | ||
| /** | ||
| * Whether the repo merges PR-based contributions, from repo-wide recent merge | ||
| * history (#248/#249/#1575). `true` = has recent merged PRs; `false` = had | ||
| * closed PRs but merged none, so "approve" is withheld even when the | ||
| * eligibility checks pass (the high-star zero-merge spam case); `null` = | ||
| * couldn't compute or no PR activity in the window, treated as inconclusive → | ||
| * needs_review, never a hard skip. Distinct from effectiveMergedCount, which | ||
| * is the VIEWER's own history. See repoAcceptsContributionsFromHealth. | ||
| */ | ||
| repoAcceptsContributions: boolean | null; | ||
| } | ||
@@ -146,0 +171,0 @@ export interface RecommendationOutput { |
@@ -27,2 +27,27 @@ /** | ||
| /** | ||
| * Repo-intrinsic contribution-acceptance signal from the health snapshot | ||
| * (#248/#249/#1575). Does the repo merge PR-based contributions at all over the | ||
| * last 90 days? | ||
| * - `true` — merged at least one PR | ||
| * - `false` — had closed PRs but merged none (it rejects the work it receives) | ||
| * - `null` — no PR activity in the window, or health/counts unavailable; | ||
| * inconclusive → needs_review, never a hard skip | ||
| * | ||
| * This deliberately does NOT distinguish maintainer self-merges from outside | ||
| * contributions — a solo repo that merges only its own PRs still reads `true`. | ||
| * That contributor-diversity refinement is deferred (#248 secondary); the point | ||
| * here is only to stop auto-approving repos that merge nothing at all. | ||
| */ | ||
| export function repoAcceptsContributionsFromHealth(projectHealth) { | ||
| if (projectHealth.checkFailed || projectHealth.recentMergedPRCount == null) { | ||
| return null; | ||
| } | ||
| if (projectHealth.recentMergedPRCount > 0) | ||
| return true; | ||
| // No merged PRs. A number (0) for the rate means there WERE closed PRs, none | ||
| // merged — real evidence the repo rejects contributions. A null rate means no | ||
| // closed PRs in the window at all (new/quiet repo) — too little to skip on. | ||
| return projectHealth.recentMergeRate == null ? null : false; | ||
| } | ||
| /** | ||
| * Derive the human-readable notes, approve/skip reasons, and the final | ||
@@ -64,2 +89,6 @@ * recommendation from a vet's check results. Pure: no I/O, no state reads — the | ||
| notes.push("No CONTRIBUTING.md found"); | ||
| if (input.repoAcceptsContributions === false) | ||
| notes.push("Repo has merged no PRs in the last 90 days"); | ||
| else if (input.repoAcceptsContributions === null) | ||
| notes.push("Could not verify whether the repo merges PRs"); | ||
| // Reasons to skip / approve. | ||
@@ -82,2 +111,4 @@ if (!input.noExistingPR) { | ||
| reasonsToSkip.push("Unclear requirements"); | ||
| if (input.repoAcceptsContributions === false) | ||
| reasonsToSkip.push("Repo has no recent merged PRs"); | ||
| if (input.noExistingPR) | ||
@@ -121,3 +152,7 @@ reasonsToApprove.push("No existing PR"); | ||
| } | ||
| else if (input.passedAllChecks) { | ||
| else if (input.passedAllChecks && | ||
| input.repoAcceptsContributions !== false) { | ||
| // Withhold "approve" from a repo that merges nobody, even when the | ||
| // eligibility checks pass — the high-star zero-merge spam case (#249/#1575). | ||
| // A `null` (couldn't compute) still reaches here and is downgraded below. | ||
| recommendation = "approve"; | ||
@@ -136,3 +171,4 @@ } | ||
| input.claimInconclusive || | ||
| input.mergedCountInconclusive; | ||
| input.mergedCountInconclusive || | ||
| input.repoAcceptsContributions === null; | ||
| if (recommendation === "approve" && hasInconclusiveChecks) { | ||
@@ -203,3 +239,3 @@ recommendation = "needs_review"; | ||
| checkNotClaimed(this.octokit, owner, repo, number, core.commentCount), | ||
| checkProjectHealth(this.octokit, owner, repo), | ||
| checkProjectHealth(this.octokit, owner, repo, this.budgetTracker), | ||
| fetchContributionGuidelines(this.octokit, owner, repo), | ||
@@ -242,2 +278,7 @@ hasMergedPRsInRepo | ||
| : projectHealth.isActive; | ||
| // Repo-intrinsic merge-acceptance gate (#248/#249/#1575): does this repo | ||
| // merge PR-based contributions at all? A high-star repo that merges nothing | ||
| // is exactly the spam case that used to get auto-approved. Independent of | ||
| // our own contribution history, unlike effectiveMergedCount below. | ||
| const repoAcceptsContributions = repoAcceptsContributionsFromHealth(projectHealth); | ||
| const vettingResult = { | ||
@@ -294,3 +335,4 @@ passedAllChecks: noExistingPR && notClaimed && projectActive && clearRequirements, | ||
| !!claimCheck.inconclusive || | ||
| mergedCountInconclusive; | ||
| mergedCountInconclusive || | ||
| repoAcceptsContributions === null; | ||
| const { notes, reasonsToApprove, reasonsToSkip, recommendation } = deriveRecommendation({ | ||
@@ -322,2 +364,3 @@ noExistingPR, | ||
| passedAllChecks: vettingResult.passedAllChecks, | ||
| repoAcceptsContributions, | ||
| }); | ||
@@ -324,0 +367,0 @@ vettingResult.notes = notes; |
@@ -9,2 +9,3 @@ /** | ||
| import { type ContributionGuidelines, type ProjectHealth } from "./types.js"; | ||
| import { type SearchBudgetTracker } from "./search-budget.js"; | ||
| /** | ||
@@ -14,3 +15,3 @@ * Check the health of a GitHub project: recent commits, CI status, star/fork counts. | ||
| */ | ||
| export declare function checkProjectHealth(octokit: Octokit, owner: string, repo: string): Promise<ProjectHealth>; | ||
| export declare function checkProjectHealth(octokit: Octokit, owner: string, repo: string, tracker?: SearchBudgetTracker): Promise<ProjectHealth>; | ||
| /** | ||
@@ -17,0 +18,0 @@ * Fetch and parse CONTRIBUTING.md (or variants) from a GitHub repo. |
@@ -12,3 +12,47 @@ /** | ||
| import { probeRepoFile } from "./probe-repo-file.js"; | ||
| import { getSearchBudgetTracker, } from "./search-budget.js"; | ||
| const MODULE = "repo-health"; | ||
| /** Window for measuring repo-wide merge behavior. */ | ||
| const MERGE_WINDOW_DAYS = 90; | ||
| /** | ||
| * Repo-intrinsic merge signal: how much outside work a repo actually merges, | ||
| * measured from repo-wide PR history over the last 90 days (#248/#249/#1575) — | ||
| * NOT the viewer's own contribution relationship. Two budget-tracked Search API | ||
| * calls (merged count, closed-unmerged count); the result is cached with the | ||
| * surrounding health snapshot, so repeated candidates from one repo pay nothing. | ||
| * | ||
| * Errors are NOT swallowed here: they propagate to checkProjectHealth's outer | ||
| * catch, which turns the whole snapshot into `checkFailed` (and, crucially, does | ||
| * not cache it) so a transient search blip self-heals on the next call. Handling | ||
| * the error here and returning nulls instead would pin an inconclusive signal in | ||
| * the 4h health cache for the whole repo. `recentMergedPRCount` is therefore | ||
| * always a real number on the success path; `recentMergeRate` is null only when | ||
| * there were genuinely no closed PRs in the window. | ||
| */ | ||
| async function fetchRepoMergeStats(octokit, owner, repo, tracker) { | ||
| const since = new Date(Date.now() - MERGE_WINDOW_DAYS * 24 * 60 * 60 * 1000) | ||
| .toISOString() | ||
| .slice(0, 10); | ||
| const count = async (qualifiers) => { | ||
| await tracker.waitForBudget(); | ||
| try { | ||
| const { data } = await octokit.search.issuesAndPullRequests({ | ||
| q: `repo:${owner}/${repo} ${qualifiers} closed:>=${since}`, | ||
| per_page: 1, // only total_count is needed | ||
| }); | ||
| return data.total_count; | ||
| } | ||
| finally { | ||
| // Always record — a failed request still consumes rate-limit budget. | ||
| tracker.recordCall(); | ||
| } | ||
| }; | ||
| const merged = await count("is:pr is:merged"); | ||
| const closedUnmerged = await count("is:pr is:unmerged is:closed"); | ||
| const denom = merged + closedUnmerged; | ||
| return { | ||
| recentMergedPRCount: merged, | ||
| recentMergeRate: denom > 0 ? merged / denom : null, | ||
| }; | ||
| } | ||
| // ── Cache for contribution guidelines ── | ||
@@ -45,3 +89,6 @@ const guidelinesCache = new Map(); | ||
| */ | ||
| export async function checkProjectHealth(octokit, owner, repo) { | ||
| export async function checkProjectHealth(octokit, owner, repo, | ||
| // Optional injected budget tracker for the repo-wide merge-stat searches. | ||
| // Defaults to the shared singleton so existing callers behave identically. | ||
| tracker = getSearchBudgetTracker()) { | ||
| const cache = getHttpCache(); | ||
@@ -67,2 +114,3 @@ const healthCacheKey = `health:${owner}/${repo}`; | ||
| const ciStatus = "unknown"; | ||
| const mergeStats = await fetchRepoMergeStats(octokit, owner, repo, tracker); | ||
| return { | ||
@@ -79,2 +127,4 @@ repo: `${owner}/${repo}`, | ||
| language: repoData.language, | ||
| recentMergedPRCount: mergeStats.recentMergedPRCount, | ||
| recentMergeRate: mergeStats.recentMergeRate, | ||
| }; | ||
@@ -81,0 +131,0 @@ }); |
+17
-0
@@ -19,2 +19,19 @@ /** | ||
| language?: string | null; | ||
| /** | ||
| * Repo-wide merged PRs in the last 90 days. Repo-intrinsic — independent of | ||
| * the viewer's own contribution history — so it distinguishes a repo that | ||
| * merges outside work from a high-star repo that merges nobody (#248, #249, | ||
| * #1575). `null` when the count could not be computed (API failure) or was | ||
| * not fetched; callers treat `null` as inconclusive, never a hard skip. | ||
| */ | ||
| recentMergedPRCount?: number | null; | ||
| /** | ||
| * Fraction of recently-closed PRs that were merged — merged / (merged + | ||
| * closed-unmerged) over the last 90 days. `null` when there were no closed | ||
| * PRs in the window or the counts could not be computed. Repo-intrinsic and | ||
| * part of the candidate output contract: consumed by the downstream success | ||
| * grade (oss-autopilot) so a healthy new repo grades on its own merits rather | ||
| * than the viewer's contribution relationship (#248). Not read within scout. | ||
| */ | ||
| recentMergeRate?: number | null; | ||
| /** Discriminant: a real snapshot is never `checkFailed`. */ | ||
@@ -21,0 +38,0 @@ checkFailed?: false; |
+1
-1
| { | ||
| "name": "@oss-scout/core", | ||
| "version": "1.4.1", | ||
| "version": "1.5.0", | ||
| "description": "Personalized GitHub issue finder with multi-strategy search, deep vetting, and viability scoring — CLI, library, MCP server, and Claude Code plugin", | ||
@@ -5,0 +5,0 @@ "type": "module", |
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1112704
0.79%14265
0.98%