539 lines
14 KiB
TypeScript
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,
|
|
};
|