@@ -59,83 +59,104 @@ async function passesNormalCheck(github, homeRepo, username, prereq) {
5959/**
6060 * Determines whether a contributor is eligible for a single candidate level.
6161 *
62- * Two checks are tried in order:
62+ * Checks are evaluated in order:
6363 * 1. Floor level (no prerequisite) — always eligible.
64- * 2. Bypass — has already closed ≥1 issue at this level or higher.
65- * 3. Normal — has met the prerequisite count at the previous level .
64+ * 2. Bypass — contributor already completed ≥1 issue at this level or higher.
65+ * 3. Normal progression — contributor met prerequisite count requirements .
6666 *
67- * Returns null on API failure so the caller can conservatively skip the candidate .
67+ * API failures are treated conservatively as ineligible .
6868 *
6969 * @param {import('@actions/github').GitHub } github
70- * @param {object } homeRepo - Home repo entry from CONFIG.repos.
71- * @param {string } username - GitHub login of the contributor.
72- * @param {string } candidate - Canonical level key being evaluated.
73- * @returns {Promise<boolean> } True if eligible, false otherwise (API failures treated as false).
70+ * @param {object } homeRepo
71+ * @param {string } username
72+ * @param {string } candidate - Canonical level key being evaluated
73+ * @returns {Promise<boolean> }
7474 */
7575async function isEligibleFor ( github , homeRepo , username , candidate ) {
7676 const prereq = CONFIG . skillPrerequisites [ candidate ] ;
7777
78- // Floor level (gfi) has no prerequisite — always eligible as a floor .
78+ // Entry- level floor (gfi) is always eligible.
7979 if ( ! prereq ?. requiredLevel ) return true ;
8080
81- const bypass = await passesBypassCheck ( github , homeRepo , username , candidate ) ;
81+ const bypass = await passesBypassCheck (
82+ github ,
83+ homeRepo ,
84+ username ,
85+ candidate ,
86+ ) ;
87+
8288 if ( bypass ) return true ;
8389
84- // null (API failure) is treated as false — caller skips conservatively.
85- const normal = await passesNormalCheck ( github , homeRepo , username , prereq ) ;
90+ // API failures are treated conservatively as false.
91+ const normal = await passesNormalCheck (
92+ github ,
93+ homeRepo ,
94+ username ,
95+ prereq ,
96+ ) ;
97+
8698 return normal === true ;
8799}
88100
89101/**
90- * Determines the highest skill level a contributor is eligible for,
91- * based solely on their *historical* closed issues (excludes this PR).
102+ * Resolves the highest level the contributor is historically eligible for.
92103 *
93- * Walks the hierarchy from highest to lowest and returns the first
94- * level for which the contributor is eligible. The result acts as a
95- * ceiling — recommendations will not exceed this level.
104+ * Historical eligibility intentionally excludes the current PR being processed.
105+ * The result acts as an eligibility ceiling for recommendations.
96106 *
97- * The caller is responsible for adjusting this ceiling to account for
98- * the current PR's completion (see adjustEligibilityForCurrentPR).
107+ * The caller is responsible for projecting the current PR completion
108+ * via adjustEligibilityForCurrentPR( ).
99109 *
100110 * @param {import('@actions/github').GitHub } github
101- * @param {object } homeRepo - Home repo entry from CONFIG.repos.
102- * @param {string } username - GitHub login of the contributor.
103- * @returns {Promise<string> } Canonical level key of the eligibility ceiling.
111+ * @param {object } homeRepo
112+ * @param {string } username
113+ * @returns {Promise<string> } Canonical level key
104114 */
105115async function resolveEligibleLevel ( github , homeRepo , username ) {
106116 for ( const candidate of [ ...CONFIG . skillHierarchy ] . reverse ( ) ) {
107- const eligible = await isEligibleFor ( github , homeRepo , username , candidate ) ;
108- // false includes API failures (treated conservatively as ineligible)
117+ const eligible = await isEligibleFor (
118+ github ,
119+ homeRepo ,
120+ username ,
121+ candidate ,
122+ ) ;
123+
124+ // false includes API failures (treated conservatively)
109125 if ( eligible === true ) return candidate ;
110126 }
127+
111128 return CONFIG . skillHierarchy [ 0 ] ;
112129}
113130
114131/**
115- * Adjusts the historical eligibility ceiling to include the current PR's completion.
132+ * Projects the contributor's eligibility ceiling after including
133+ * the current PR completion.
116134 *
117- * resolveEligibleLevel is intentionally blind to the PR being processed .
118- * Without this adjustment, a contributor completing their very first GFI
119- * would have an eligibility ceiling of 'gfi' and never see beginner issues .
135+ * resolveEligibleLevel() intentionally ignores the current PR .
136+ * Without this adjustment, contributors completing their first GFI
137+ * would never receive beginner recommendations .
120138 *
121- * Logic: if the ceiling is at or below the completed level, bump it one step up.
139+ * Logic:
140+ * if eligible ceiling <= completed level
141+ * => bump ceiling one level upward
122142 *
123- * @param {string } completedKey - Canonical key of the level just completed.
124- * @param {string } eligibleKey - Historical ceiling from resolveEligibleLevel.
125- * @returns {string } Adjusted ceiling key.
143+ * @param {string } completedKey - Level completed by current PR
144+ * @param {string } eligibleKey - Historical eligibility ceiling
145+ * @returns {string } Adjusted eligibility ceiling
126146 */
127147function adjustEligibilityForCurrentPR ( completedKey , eligibleKey ) {
128- const h = CONFIG . skillHierarchy ;
148+ const h = CONFIG . skillHierarchy ;
149+
129150 const completedIdx = h . indexOf ( completedKey ) ;
130- const eligibleIdx = h . indexOf ( eligibleKey ) ;
151+ const eligibleIdx = h . indexOf ( eligibleKey ) ;
131152
132153 return eligibleIdx <= completedIdx
133154 ? h [ Math . min ( completedIdx + 1 , h . length - 1 ) ]
134155 : eligibleKey ;
135156}
136157
137158/**
138- * Computes next level metadata for unlock detection .
159+ * Computes metadata about the next progression level .
139160 *
140161 * Ensures:
141162 * - current level exists
@@ -147,50 +168,73 @@ function adjustEligibilityForCurrentPR(completedKey, eligibleKey) {
147168 */
148169function getNextLevelInfo ( currentLevelKey ) {
149170 const hierarchy = CONFIG . skillHierarchy ;
171+
150172 const currentIndex = hierarchy . indexOf ( currentLevelKey ) ;
151173 if ( currentIndex === - 1 ) return null ;
152174
153175 const nextKey = hierarchy [ currentIndex + 1 ] ;
154176 if ( ! nextKey ) return null ;
155177
156178 const nextPrereq = CONFIG . skillPrerequisites [ nextKey ] ;
157- if ( ! nextPrereq || nextPrereq . requiredLevel !== currentLevelKey ) return null ;
179+
180+ if (
181+ ! nextPrereq ||
182+ nextPrereq . requiredLevel !== currentLevelKey
183+ ) {
184+ return null ;
185+ }
158186
159187 return { nextKey, nextPrereq } ;
160188}
161189
162190/**
163- * Detects if the contributor just unlocked the next level.
191+ * Detects whether the current PR completion unlocks the next level.
192+ *
193+ * GitHub search indexing may lag behind workflow execution, so counts are
194+ * treated as historical state and the current PR completion is projected
195+ * locally via +1.
164196 *
165- * Trigger condition:
166- * completed count === requiredCount
197+ * Example:
198+ * intermediate requires 3 beginner completions
199+ * historical beginner count = 2
200+ * current PR completes another beginner issue
201+ * projected count = 3 => unlock intermediate
167202 *
168- * Uses a capped query (requiredCount + 1) for efficiency.
203+ * Uses a capped query for efficiency.
169204 *
170205 * @param {import('@actions/github').GitHub } github
171206 * @param {object } homeRepo
172207 * @param {string } username
173208 * @param {string } currentLevelKey
174209 * @returns {Promise<string|null> } unlocked level key
175210 */
176- async function detectUnlockedLevel ( github , homeRepo , username , currentLevelKey ) {
211+ async function detectUnlockedLevel (
212+ github ,
213+ homeRepo ,
214+ username ,
215+ currentLevelKey ,
216+ ) {
177217 const next = getNextLevelInfo ( currentLevelKey ) ;
178218 if ( ! next ) return null ;
179219
180220 const { nextKey, nextPrereq } = next ;
181221
182- const count = await countClosedIssuesByAssignee (
222+ const historicalCount = await countClosedIssuesByAssignee (
183223 github ,
184224 homeRepo . owner ,
185225 homeRepo . repo ,
186226 username ,
187227 repoLabelFor ( homeRepo , currentLevelKey ) ,
188- nextPrereq . requiredCount + 1 ,
228+ nextPrereq . requiredCount ,
189229 ) ;
190230
191- if ( count === null ) return null ;
231+ if ( historicalCount === null ) return null ;
232+
233+ const projectedCount = historicalCount + 1 ;
192234
193- return count === nextPrereq . requiredCount ? nextKey : null ;
235+ return projectedCount === nextPrereq . requiredCount
236+ ? nextKey
237+ : null ;
194238}
195239
196240module . exports = {
0 commit comments