Files
hive/scripts/bounty-tracker.ts
2026-03-18 15:34:08 -07:00

539 lines
14 KiB
TypeScript

#!/usr/bin/env bun
/**
* Bounty Tracker — calculates points from merged PRs and generates leaderboards.
*
* Modes:
* notify — Post a Discord message for a single completed bounty (called by bounty-completed.yml)
* leaderboard — Generate and post the weekly leaderboard (called by weekly-leaderboard.yml)
*
* Environment:
* GITHUB_TOKEN — GitHub API token
* GITHUB_REPOSITORY_OWNER — e.g. "adenhq"
* GITHUB_REPOSITORY_NAME — e.g. "hive"
* DISCORD_WEBHOOK_URL — Discord webhook for #integrations-announcements
* MONGODB_URI — MongoDB connection string (contributors collection)
* LURKR_API_KEY — Lurkr Read/Write API key (for XP push)
* LURKR_GUILD_ID — Discord server ID where Lurkr is installed
* PR_NUMBER — (notify mode) The merged PR number
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Contributor {
github: string;
discord: string;
name?: string;
}
interface GitHubLabel {
name: string;
}
interface GitHubUser {
login: string;
}
interface GitHubPR {
number: number;
title: string;
merged_at: string | null;
labels: GitHubLabel[];
user: GitHubUser;
html_url: string;
}
interface BountyResult {
pr: GitHubPR;
bountyType: string;
points: number;
difficulty: string;
contributor: string;
discordId: string | null;
}
interface LeaderboardEntry {
github: string;
discordId: string | null;
points: number;
bounties: number;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const POINTS: Record<string, number> = {
// Integration bounties
"bounty:test": 20,
"bounty:docs": 20,
"bounty:code": 30,
"bounty:new-tool": 75,
// Standard bounties
"bounty:small": 10,
"bounty:medium": 30,
"bounty:large": 75,
"bounty:extreme": 150,
};
// ---------------------------------------------------------------------------
// GitHub API
// ---------------------------------------------------------------------------
async function githubRequest<T>(
endpoint: string,
token: string,
method: string = "GET",
body?: unknown
): Promise<T> {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "bounty-tracker",
};
if (body) {
headers["Content-Type"] = "application/json";
}
const options: RequestInit = { method, headers };
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`https://api.github.com${endpoint}`, options);
if (!response.ok) {
throw new Error(
`GitHub API request failed: ${response.status} ${response.statusText}`
);
}
return response.json();
}
async function getPR(
owner: string,
repo: string,
prNumber: number,
token: string
): Promise<GitHubPR> {
return githubRequest<GitHubPR>(
`/repos/${owner}/${repo}/pulls/${prNumber}`,
token
);
}
async function getMergedBountyPRs(
owner: string,
repo: string,
token: string,
since?: string
): Promise<GitHubPR[]> {
// GitHub search API requires each label with special chars to be quoted individually.
// Multiple label: qualifiers are OR'd together.
const bountyLabels = Object.keys(POINTS)
.map((l) => `label:"${l}"`)
.join(" ");
const query = `repo:${owner}/${repo} is:pr is:merged ${bountyLabels}${since ? ` merged:>=${since}` : ""}`;
const result = await githubRequest<{ items: GitHubPR[] }>(
`/search/issues?q=${encodeURIComponent(query)}&per_page=100&sort=updated&order=desc`,
token
);
return result.items;
}
// ---------------------------------------------------------------------------
// Identity resolution (via bot API)
// ---------------------------------------------------------------------------
async function loadContributors(): Promise<Map<string, Contributor>> {
const map = new Map<string, Contributor>();
const apiUrl = process.env.BOT_API_URL;
if (!apiUrl) {
console.warn("Warning: BOT_API_URL not set, contributor lookups disabled");
return map;
}
try {
const headers: Record<string, string> = {};
const apiKey = process.env.BOT_API_KEY;
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const res = await fetch(`${apiUrl}/api/contributors`, { headers });
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
}
const docs = (await res.json()) as Contributor[];
for (const doc of docs) {
map.set(doc.github.toLowerCase(), doc);
}
console.log(`Loaded ${map.size} contributors from bot API`);
} catch (err) {
console.warn(`Warning: could not load contributors from bot API: ${err}`);
}
return map;
}
function resolveDiscord(
githubUsername: string,
contributors: Map<string, Contributor>
): string | null {
const entry = contributors.get(githubUsername.toLowerCase());
return entry?.discord ?? null;
}
// ---------------------------------------------------------------------------
// Bounty extraction
// ---------------------------------------------------------------------------
function extractBounty(
pr: GitHubPR,
contributors: Map<string, Contributor>
): BountyResult | null {
const labels = pr.labels.map((l) => l.name);
const bountyLabel = labels.find((l) => l.startsWith("bounty:"));
if (!bountyLabel) return null;
const points = POINTS[bountyLabel];
if (points === undefined) return null;
const difficulty =
labels.find((l) => l.startsWith("difficulty:"))?.replace("difficulty:", "") ??
"unknown";
return {
pr,
bountyType: bountyLabel.replace("bounty:", ""),
points,
difficulty,
contributor: pr.user.login,
discordId: resolveDiscord(pr.user.login, contributors),
};
}
// ---------------------------------------------------------------------------
// Discord notifications
// ---------------------------------------------------------------------------
async function postToDiscord(
webhookUrl: string,
content: string,
embeds?: unknown[]
): Promise<void> {
const body: Record<string, unknown> = { content };
if (embeds) body.embeds = embeds;
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(
`Discord webhook failed: ${response.status} ${response.statusText}`
);
}
}
function formatBountyNotification(bounty: BountyResult): string {
const userMention = bounty.discordId
? `<@${bounty.discordId}>`
: `**${bounty.contributor}**`;
const typeEmoji: Record<string, string> = {
test: "\u{1F9EA}",
docs: "\u{1F4DD}",
code: "\u{1F527}",
"new-tool": "\u{2B50}",
small: "\u{1F4A1}",
medium: "\u{1F6E0}",
large: "\u{1F680}",
extreme: "\u{1F525}",
};
const emoji = typeEmoji[bounty.bountyType] ?? "\u{1F3AF}";
let msg = `${emoji} **Bounty Completed!**\n\n`;
msg += `${userMention} completed a **${bounty.bountyType}** bounty (+${bounty.points} pts)\n`;
msg += `PR: ${bounty.pr.html_url}\n`;
if (!bounty.discordId) {
msg += `\n_\u{1F517} @${bounty.contributor}: use \`/link-github\` in Discord to get pinged!_`;
}
return msg;
}
function formatLeaderboard(entries: LeaderboardEntry[]): string {
if (entries.length === 0) {
return "No bounty completions this period.";
}
const sorted = [...entries].sort((a, b) => b.points - a.points);
const top10 = sorted.slice(0, 10);
const medals = ["\u{1F947}", "\u{1F948}", "\u{1F949}"];
let msg = "**\u{1F3C6} Bounty Leaderboard**\n\n";
for (let i = 0; i < top10.length; i++) {
const entry = top10[i];
const rank = medals[i] ?? `**${i + 1}.**`;
const name = entry.discordId
? `<@${entry.discordId}>`
: `**${entry.github}**`;
msg += `${rank} ${name}${entry.points} pts (${entry.bounties} bounties)\n`;
}
msg += `\n_${sorted.length} contributors total_`;
return msg;
}
// ---------------------------------------------------------------------------
// Lurkr API — push XP to Discord leveling system
// ---------------------------------------------------------------------------
const LURKR_BASE_URL = "https://api.lurkr.gg/v2";
interface LurkrLevelResponse {
level: {
level: number;
xp: number;
messageCount: number;
};
}
async function lurkrAddXP(
guildId: string,
userId: string,
xp: number,
apiKey: string
): Promise<LurkrLevelResponse> {
const response = await fetch(
`${LURKR_BASE_URL}/levels/${guildId}/users/${userId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-API-Key": apiKey,
},
body: JSON.stringify({ xp: { increment: xp } }),
}
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Lurkr API failed: ${response.status} ${text}`);
}
return response.json();
}
async function lurkrGetUser(
guildId: string,
userId: string,
apiKey: string
): Promise<LurkrLevelResponse | null> {
const response = await fetch(
`${LURKR_BASE_URL}/levels/${guildId}/users/${userId}`,
{
method: "GET",
headers: { "X-API-Key": apiKey },
}
);
if (response.status === 404) return null;
if (!response.ok) {
const text = await response.text();
throw new Error(`Lurkr API failed: ${response.status} ${text}`);
}
return response.json();
}
async function awardLurkrXP(bounty: BountyResult): Promise<string | null> {
const apiKey = process.env.LURKR_API_KEY;
const guildId = process.env.LURKR_GUILD_ID;
if (!apiKey || !guildId) {
console.log("Lurkr not configured (missing LURKR_API_KEY or LURKR_GUILD_ID), skipping XP push");
return null;
}
if (!bounty.discordId) {
console.log(`No Discord ID for @${bounty.contributor}, cannot push Lurkr XP`);
return null;
}
try {
const result = await lurkrAddXP(guildId, bounty.discordId, bounty.points, apiKey);
const msg = `Lurkr: +${bounty.points} XP \u2192 <@${bounty.discordId}> (now level ${result.level.level}, ${result.level.xp} XP)`;
console.log(msg);
return msg;
} catch (err) {
// Lurkr failure should not prevent the Discord notification from being sent
console.error(`Lurkr XP push failed (non-fatal): ${err}`);
return null;
}
}
// ---------------------------------------------------------------------------
// Leaderboard calculation
// ---------------------------------------------------------------------------
function buildLeaderboard(
bounties: BountyResult[]
): LeaderboardEntry[] {
const map = new Map<string, LeaderboardEntry>();
for (const b of bounties) {
const key = b.contributor.toLowerCase();
const existing = map.get(key);
if (existing) {
existing.points += b.points;
existing.bounties += 1;
} else {
map.set(key, {
github: b.contributor,
discordId: b.discordId,
points: b.points,
bounties: 1,
});
}
}
return Array.from(map.values());
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function main() {
const mode = process.argv[2];
const token = process.env.GITHUB_TOKEN;
const owner = process.env.GITHUB_REPOSITORY_OWNER;
const repo = process.env.GITHUB_REPOSITORY_NAME;
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
if (!token || !owner || !repo) {
console.error(
"Missing required env: GITHUB_TOKEN, GITHUB_REPOSITORY_OWNER, GITHUB_REPOSITORY_NAME"
);
process.exit(1);
}
const contributors = await loadContributors();
if (mode === "notify") {
// Single bounty notification
const prNumber = parseInt(process.env.PR_NUMBER ?? "", 10);
if (!prNumber) {
console.error("Missing PR_NUMBER env var");
process.exit(1);
}
const pr = await getPR(owner, repo, prNumber, token);
if (!pr.merged_at) {
console.log("PR not merged, skipping");
return;
}
const bounty = extractBounty(pr, contributors);
if (!bounty) {
console.log("No bounty label found, skipping");
return;
}
console.log(
`Bounty: ${bounty.bountyType} | ${bounty.points} pts | @${bounty.contributor}`
);
// Push XP to Lurkr (before Discord notification so we can include level info)
const lurkrMsg = await awardLurkrXP(bounty);
if (webhookUrl) {
let msg = formatBountyNotification(bounty);
if (lurkrMsg) {
msg += `\n${lurkrMsg}`;
}
await postToDiscord(webhookUrl, msg);
console.log("Discord notification sent");
} else {
console.log("No DISCORD_WEBHOOK_URL set, skipping Discord notification");
console.log(formatBountyNotification(bounty));
}
} else if (mode === "leaderboard") {
// Weekly leaderboard
const since = process.env.SINCE_DATE;
const prs = await getMergedBountyPRs(owner, repo, token, since);
console.log(`Found ${prs.length} merged bounty PRs`);
const bounties = prs
.map((pr) => extractBounty(pr, contributors))
.filter((b): b is BountyResult => b !== null);
const entries = buildLeaderboard(bounties);
const msg = formatLeaderboard(entries);
console.log(msg);
if (webhookUrl) {
await postToDiscord(webhookUrl, msg);
console.log("Leaderboard posted to Discord");
}
} else {
console.error("Usage: bounty-tracker.ts <notify|leaderboard>");
console.error(" notify — Post Discord notification for a merged bounty PR");
console.error(" leaderboard — Generate and post the leaderboard");
process.exit(1);
}
}
// Run if invoked directly
main().catch((err) => {
console.error(err);
process.exit(1);
});
// Export for testing
export {
extractBounty,
buildLeaderboard,
formatBountyNotification,
formatLeaderboard,
loadContributors,
resolveDiscord,
awardLurkrXP,
lurkrAddXP,
lurkrGetUser,
POINTS,
};
export type {
BountyResult,
LeaderboardEntry,
Contributor,
GitHubPR,
LurkrLevelResponse,
};