Scoring Engine
The scoring engine in lib/scoring/engine.ts converts raw GitHub activity into ranked scores. It is the same engine whether you’re looking at contributors, repositories, or teams — only the aggregation mode changes.
Default weights
Base points
| Signal | Points |
|---|---|
commit | 10 |
pr_merge | 50 |
review | 20 (then × review state weight) |
review_comment | 5 |
issue_open | 10 |
issue_close | 10 |
comment | 3 |
pr_open | 0 (not directly scored) |
pr_close_no_merge | 0 + penalty −10 |
spam | 0 + penalty −12 |
Review state weights
Applied only to review signals. Multiplied with base points.
| State | Weight |
|---|---|
approved | ×1.25 |
changes_requested | ×1.0 |
commented | ×0.5 |
A review with state approved scores 20 × 1.25 = 25 points.
Multipliers
All multipliers stack multiplicatively and are applied in order.
| Multiplier | Factor | Applies to |
|---|---|---|
merged_pr_commit | ×1.2 | commit (only if the commit is in a merged PR) |
pr_linked_to_issue | ×1.1 | pr_merge, commit (in the linked PR) |
first_activity | ×1.5 | commit, pr_merge, review, issue_open, issue_close, comment (first of each type per day) |
Worked example — a commit by a first-time contributor in a merged PR that’s linked to an issue:
base = 10
× merged_pr_commit (1.2) → 12
× pr_linked_to_issue (1.1) → 13.2
× first_activity (1.5) → 19.8
final score = 19.8Worked example — an approved review of someone else’s PR by an established contributor:
base = 20
× review_state approved (1.25) → 25
(no other multipliers apply)
final score = 25Daily quotas (contributor mode only)
Quotas cap signals per user per day. Signals beyond the cap score 0.
| Signal | Daily limit |
|---|---|
commit | 4 |
comment | 3 |
Quotas are per-day, not rolling-24h. A user’s 5th commit on June 1 scores 0; their 1st commit on June 2 scores normally.
Diminishing returns (contributor mode only)
Applied when a user exceeds weeklyThreshold signals of the same type in the current calendar week.
| Parameter | Default |
|---|---|
weeklyThreshold | 9 |
decayFactor | 0.11 |
floorFraction | 0.2 |
Formula:
factor = max(floorFraction, 1 − decayFactor × excess)
where excess = count − weeklyThresholdExample — a user with 13 commits in the current week:
excess = 13 − 9 = 4
factor = max(0.2, 1 − 0.11 × 4) = max(0.2, 0.56) = 0.56Each commit beyond the 9th scores at 56% of its base points.
Example — 17 commits:
excess = 17 − 9 = 8
factor = max(0.2, 1 − 0.11 × 8) = max(0.2, 0.12) = 0.2Floor is hit. Further commits score at 20%.
Zero-point conditions
All enabled by default. When triggered, score = 0 and multipliers are not applied.
| Condition | When |
|---|---|
self_review | User reviews their own PR |
Formula:
factor = max(floorFraction, 1 − decayFactor × excess)
where excess = count − weeklyThresholdExample — a user with 13 commits in the current week:
excess = 13 − 9 = 4
factor = max(0.2, 1 − 0.11 × 4) = max(0.2, 0.56) = 0.56Each commit beyond the 9th scores at 56% of its base points.
Example — 17 commits:
excess = 17 − 9 = 8
factor = max(0.2, 1 − 0.11 × 8) = max(0.2, 0.12) = 0.2Floor is hit. Further commits score at 20%.
Subtracted after multipliers are applied.
| Penalty | Amount |
|---|---|
spamPenalty | −12 per spam signal |
prClosedNoMergePenalty | −10 per unmerged closed PR |
Entity aggregation
Contributor mode
computeScores() processes signals chronologically per user. Quotas and diminishing returns are applied. Each user gets a score, breakdown, counts, and rank.
Repository mode
aggregateByRepository() groups signals by repository. Scores each repo independently. Skips per-user quotas (skipQuota: true) and the first_activity multiplier. Zero-point conditions still apply.
Team mode
aggregateByTeamSignals() first runs computeScores() per user (contributor mode), then sums per-user scores per team. A user in multiple teams scores full points for each team — scores are not split.
Signal scoring order
Each signal goes through this pipeline:
1. Check zero-point → if triggered, score = 0, stop
2. Look up base points for signal type
3. Apply multipliers (merged_pr_commit, pr_linked_to_issue, first_activity)
4. Check daily quota → if exceeded, score = 0, stop
5. Apply diminishing returns factor
6. Subtract penalties (spam, pr_closed_no_merge)
7. Add to user/repo/team totalThe order matters — multipliers apply before diminishing returns. A signal that scores 19.8 after multipliers becomes ~11.1 if diminishing returns applies a 0.56 factor.
Content hash and dedup
normalizeGitHubData() produces a SHA-256 hash from:
user_id | signal_type | repository_id | event_timestamp | content_keyOnly the first 32 characters are stored in signals.content_hash char(64). The unique index on (user_id, type, repository_id, event_timestamp, content_hash) makes upsertSignals() fully idempotent — re-ingesting the same activity produces no duplicates.
Bot signals
Bot users are identified during normalization (isBot = true) and stored with users.is_bot = true. Their signals are stored normally in the signals table. The bot_activity zero-point condition zeroes their score. Disabling it in a preset allows bot commits to contribute to repository and team scores (e.g., Dependabot PRs).