Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c610d95165 |
@@ -1,31 +0,0 @@
|
||||
name: Link Discord Account
|
||||
description: Connect your GitHub and Discord for the bounty program
|
||||
title: "link: @{{ github.actor }}"
|
||||
labels: ["link-discord"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Link your Discord account to receive XP and role rewards when your bounty PRs are merged.
|
||||
|
||||
**How to find your Discord ID:**
|
||||
1. Open Discord Settings > Advanced > Enable **Developer Mode**
|
||||
2. Right-click your username > **Copy User ID**
|
||||
|
||||
- type: input
|
||||
id: discord_id
|
||||
attributes:
|
||||
label: Discord User ID
|
||||
description: "Your numeric Discord ID (not your username). Example: 123456789012345678"
|
||||
placeholder: "123456789012345678"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: display_name
|
||||
attributes:
|
||||
label: Display Name (optional)
|
||||
description: How you'd like to be credited
|
||||
placeholder: "Jane Doe"
|
||||
validations:
|
||||
required: false
|
||||
@@ -34,4 +34,5 @@ jobs:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_BOUNTY_WEBHOOK_URL }}
|
||||
LURKR_API_KEY: ${{ secrets.LURKR_API_KEY }}
|
||||
LURKR_GUILD_ID: ${{ secrets.LURKR_GUILD_ID }}
|
||||
MONGODB_URI: ${{ secrets.MONGODB_URI }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
name: Link Discord account
|
||||
description: Auto-creates a PR to add contributor to contributors.yml when a link-discord issue is opened
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
link-discord:
|
||||
if: contains(github.event.issue.labels.*.name, 'link-discord')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Parse issue and update contributors.yml
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
const issue = context.payload.issue;
|
||||
const githubUsername = issue.user.login;
|
||||
|
||||
// Parse the issue body for form fields
|
||||
const body = issue.body || '';
|
||||
|
||||
// Extract Discord ID — look for the numeric value after the "Discord User ID" heading
|
||||
const discordMatch = body.match(/### Discord User ID\s*\n\s*(\d{17,20})/);
|
||||
if (!discordMatch) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: issue.number,
|
||||
body: `Could not find a valid Discord ID in the issue body. Please make sure you entered a numeric ID (17-20 digits), not a username.\n\nExample: \`123456789012345678\``
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
...context.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const discordId = discordMatch[1];
|
||||
|
||||
// Extract display name (optional)
|
||||
const nameMatch = body.match(/### Display Name \(optional\)\s*\n\s*(.+)/);
|
||||
const displayName = nameMatch ? nameMatch[1].trim() : '';
|
||||
|
||||
// Check if user already exists
|
||||
const yml = fs.readFileSync('contributors.yml', 'utf-8');
|
||||
if (yml.includes(`github: ${githubUsername}`)) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: issue.number,
|
||||
body: `@${githubUsername} is already in \`contributors.yml\`. If you need to update your Discord ID, please edit the file directly via PR.`
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
...context.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'completed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Append entry to contributors.yml
|
||||
let entry = ` - github: ${githubUsername}\n discord: "${discordId}"`;
|
||||
if (displayName && displayName !== '_No response_') {
|
||||
entry += `\n name: ${displayName}`;
|
||||
}
|
||||
entry += '\n';
|
||||
|
||||
const updated = yml.trimEnd() + '\n' + entry;
|
||||
fs.writeFileSync('contributors.yml', updated);
|
||||
|
||||
// Set outputs for commit step
|
||||
core.exportVariable('GITHUB_USERNAME', githubUsername);
|
||||
core.exportVariable('DISCORD_ID', discordId);
|
||||
core.exportVariable('ISSUE_NUMBER', issue.number.toString());
|
||||
|
||||
- name: Create PR
|
||||
run: |
|
||||
# Check if there are changes
|
||||
if git diff --quiet contributors.yml; then
|
||||
echo "No changes to contributors.yml"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BRANCH="docs/link-discord-${GITHUB_USERNAME}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$BRANCH"
|
||||
git add contributors.yml
|
||||
git commit -m "docs: link @${GITHUB_USERNAME} to Discord"
|
||||
git push origin "$BRANCH"
|
||||
|
||||
gh pr create \
|
||||
--title "docs: link @${GITHUB_USERNAME} to Discord" \
|
||||
--body "Adds @${GITHUB_USERNAME} (Discord \`${DISCORD_ID}\`) to \`contributors.yml\` for bounty XP tracking.
|
||||
|
||||
Closes #${ISSUE_NUMBER}" \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--label "link-discord"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Notify on issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const username = process.env.GITHUB_USERNAME;
|
||||
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: issueNumber,
|
||||
body: `A PR has been created to link your account. A maintainer will merge it shortly — once merged, you'll receive XP and Discord pings when your bounty PRs are merged.`
|
||||
});
|
||||
@@ -37,4 +37,5 @@ jobs:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_BOUNTY_WEBHOOK_URL }}
|
||||
LURKR_API_KEY: ${{ secrets.LURKR_API_KEY }}
|
||||
LURKR_GUILD_ID: ${{ secrets.LURKR_GUILD_ID }}
|
||||
MONGODB_URI: ${{ secrets.MONGODB_URI }}
|
||||
SINCE_DATE: ${{ github.event.inputs.since_date || '' }}
|
||||
|
||||
+4
-16
@@ -1,22 +1,10 @@
|
||||
# Identity mapping: GitHub username -> Discord ID
|
||||
#
|
||||
# This file links GitHub accounts to Discord accounts for the
|
||||
# Integration Bounty Program. When a bounty PR is merged, the
|
||||
# GitHub Action uses this file to ping the contributor on Discord.
|
||||
# DEPRECATED: Contributor identities are now stored in MongoDB.
|
||||
# Use /link-github <your-github-username> in Discord to link your account.
|
||||
#
|
||||
# HOW TO ADD YOURSELF:
|
||||
# Open a "Link Discord Account" issue:
|
||||
# https://github.com/aden-hive/hive/issues/new?template=link-discord.yml
|
||||
# A GitHub Action will automatically add your entry here.
|
||||
#
|
||||
# To find your Discord ID:
|
||||
# 1. Open Discord Settings > Advanced > Enable Developer Mode
|
||||
# 2. Right-click your name > Copy User ID
|
||||
#
|
||||
# Format:
|
||||
# - github: your-github-username
|
||||
# discord: "your-discord-id" # quotes required (it's a number)
|
||||
# name: Your Display Name # optional
|
||||
# This file is kept as a fallback for local development when MONGODB_URI
|
||||
# is not set. The bounty-tracker.ts script will prefer MongoDB when available.
|
||||
|
||||
contributors:
|
||||
# - github: example-user
|
||||
|
||||
@@ -105,7 +105,7 @@ See the [Setup Guide](setup-guide.md) for full configuration (Lurkr, webhooks, s
|
||||
|
||||
### Identity Linking
|
||||
|
||||
Contributors link GitHub ↔ Discord by opening a [Link Discord Account](https://github.com/aden-hive/hive/issues/new?template=link-discord.yml) issue. A GitHub Action auto-adds them to `contributors.yml` and closes the issue.
|
||||
Contributors link GitHub ↔ Discord by typing `/link-github <username>` in Discord. The bounty bot verifies the GitHub account and stores the mapping in MongoDB.
|
||||
|
||||
Without this link, bounties are still tracked but Lurkr can't push XP to your Discord account.
|
||||
|
||||
@@ -120,7 +120,7 @@ Without this link, bounties are still tracked but Lurkr can't push XP to your Di
|
||||
| Agent Builder role | Lurkr bot | Auto-assigned at level 5 |
|
||||
| OSS Contributor role | Lurkr bot | Auto-assigned at level 15 |
|
||||
| Core Contributor role | Maintainer | Manual (involves money) |
|
||||
| Identity linking | contributors.yml | PR-based, reviewed by maintainers |
|
||||
| Identity linking | Bounty bot + MongoDB | `/link-github` in Discord |
|
||||
|
||||
## Guides
|
||||
|
||||
@@ -142,4 +142,5 @@ Without this link, bounties are still tracked but Lurkr can't push XP to your Di
|
||||
- `.github/workflows/weekly-leaderboard.yml` — Monday leaderboard post
|
||||
- `scripts/bounty-tracker.ts` — Point calculation, Lurkr API, Discord formatting
|
||||
- `scripts/setup-bounty-labels.sh` — One-time label setup
|
||||
- `contributors.yml` — GitHub ↔ Discord identity mapping
|
||||
- `scripts/contributors-db.ts` — MongoDB-backed GitHub ↔ Discord identity store
|
||||
- `contributors.yml` — Fallback identity mapping (deprecated, prefer MongoDB)
|
||||
|
||||
@@ -6,9 +6,7 @@ Earn XP, Discord roles, and eventually real money by testing and building integr
|
||||
|
||||
### 1. Link your GitHub and Discord
|
||||
|
||||
Open a [Link Discord Account](https://github.com/aden-hive/hive/issues/new?template=link-discord.yml) issue — just paste your Discord ID and submit. A GitHub Action will automatically add you to `contributors.yml` and close the issue.
|
||||
|
||||
To find your Discord ID: Discord Settings > Advanced > Enable **Developer Mode**, then right-click your name > **Copy User ID**.
|
||||
Type `/link-github <your-github-username>` in any Discord channel. The bot verifies your GitHub account and links it automatically.
|
||||
|
||||
Without this link, Lurkr can't push XP to your Discord account.
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"frontend:build": "cd core/frontend && npm run build",
|
||||
"frontend:preview": "cd core/frontend && npm run preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"mongodb": "^6.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Bounty Bot — Discord bot for the Integration Bounty Program.
|
||||
*
|
||||
* Commands:
|
||||
* /link-github <username> — Link your Discord to a GitHub account
|
||||
* /bounty-rank — Check your bounty stats
|
||||
*
|
||||
* Environment:
|
||||
* DISCORD_BOT_TOKEN — Discord bot token
|
||||
* DISCORD_APP_ID — Discord application ID
|
||||
* MONGODB_URI — MongoDB connection string
|
||||
* GITHUB_PAT — GitHub PAT (to verify usernames)
|
||||
* LURKR_API_KEY — (optional) Lurkr API key for level lookups
|
||||
* LURKR_GUILD_ID — (optional) Discord server ID
|
||||
*
|
||||
* Setup:
|
||||
* 1. Create a Discord app at https://discord.com/developers/applications
|
||||
* 2. Bot > create bot, copy token
|
||||
* 3. OAuth2 > URL Generator > scopes: bot, applications.commands
|
||||
* 4. Bot permissions: Send Messages, Use Slash Commands
|
||||
* 5. Set env vars, then: bun run scripts/bounty-bot.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
getDb,
|
||||
closeDb,
|
||||
addContributor,
|
||||
findByDiscord,
|
||||
ensureIndexes,
|
||||
} from "./contributors-db";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
||||
const DISCORD_APP_ID = process.env.DISCORD_APP_ID;
|
||||
const GITHUB_PAT = process.env.GITHUB_PAT;
|
||||
const LURKR_API_KEY = process.env.LURKR_API_KEY;
|
||||
const LURKR_GUILD_ID = process.env.LURKR_GUILD_ID;
|
||||
|
||||
if (!DISCORD_BOT_TOKEN || !DISCORD_APP_ID) {
|
||||
console.error("Missing DISCORD_BOT_TOKEN or DISCORD_APP_ID");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const DISCORD_API = "https://discord.com/api/v10";
|
||||
const GITHUB_API = "https://api.github.com";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub: verify username
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function verifyGitHubUser(username: string): Promise<boolean> {
|
||||
if (!GITHUB_PAT) {
|
||||
console.warn("No GITHUB_PAT set, skipping GitHub username verification");
|
||||
return true;
|
||||
}
|
||||
const res = await fetch(`${GITHUB_API}/users/${username}`, {
|
||||
headers: { Authorization: `Bearer ${GITHUB_PAT}` },
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lurkr: get user level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getLurkrLevel(discordId: string): Promise<{ level: number; xp: number } | null> {
|
||||
if (!LURKR_API_KEY || !LURKR_GUILD_ID) return null;
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.lurkr.gg/v2/levels/${LURKR_GUILD_ID}/users/${discordId}`,
|
||||
{ headers: { "X-API-Key": LURKR_API_KEY } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = await res.json() as { level: { level: number; xp: number } };
|
||||
return data.level;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discord: register slash commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function registerCommands() {
|
||||
const commands = [
|
||||
{
|
||||
name: "link-github",
|
||||
description: "Link your Discord account to GitHub for bounty XP",
|
||||
options: [
|
||||
{
|
||||
name: "username",
|
||||
description: "Your GitHub username",
|
||||
type: 3, // STRING
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "bounty-rank",
|
||||
description: "Check your bounty program level and stats",
|
||||
},
|
||||
];
|
||||
|
||||
const res = await fetch(`${DISCORD_API}/applications/${DISCORD_APP_ID}/commands`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(commands),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Failed to register commands: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
console.log("Slash commands registered");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discord: Gateway (WebSocket)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function connectGateway() {
|
||||
const gatewayRes = await fetch(`${DISCORD_API}/gateway/bot`, {
|
||||
headers: { Authorization: `Bot ${DISCORD_BOT_TOKEN}` },
|
||||
});
|
||||
if (!gatewayRes.ok) throw new Error(`Gateway fetch failed: ${gatewayRes.status}`);
|
||||
const { url } = (await gatewayRes.json()) as { url: string };
|
||||
|
||||
const ws = new WebSocket(`${url}?v=10&encoding=json`);
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let seq: number | null = null;
|
||||
|
||||
ws.addEventListener("message", async (event) => {
|
||||
const data = JSON.parse(event.data as string);
|
||||
seq = data.s ?? seq;
|
||||
|
||||
switch (data.op) {
|
||||
case 10: // Hello
|
||||
heartbeatInterval = setInterval(() => {
|
||||
ws.send(JSON.stringify({ op: 1, d: seq }));
|
||||
}, data.d.heartbeat_interval);
|
||||
|
||||
// Identify
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
op: 2,
|
||||
d: {
|
||||
token: DISCORD_BOT_TOKEN,
|
||||
intents: 0,
|
||||
properties: { os: "linux", browser: "bounty-bot", device: "bounty-bot" },
|
||||
},
|
||||
})
|
||||
);
|
||||
break;
|
||||
|
||||
case 11: // Heartbeat ACK
|
||||
break;
|
||||
|
||||
case 0: // Dispatch
|
||||
if (data.t === "READY") {
|
||||
console.log(`Bot online as ${data.d.user.username}#${data.d.user.discriminator}`);
|
||||
} else if (data.t === "INTERACTION_CREATE") {
|
||||
await handleInteraction(data.d);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
setTimeout(connectGateway, 5000);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
console.error("WebSocket error:", event);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interaction handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleInteraction(interaction: any) {
|
||||
const { type, data, member, user } = interaction;
|
||||
|
||||
if (type !== 2) return;
|
||||
|
||||
const discordUser = member?.user ?? user;
|
||||
const discordId = discordUser.id;
|
||||
|
||||
if (data.name === "link-github") {
|
||||
const githubUsername = data.options?.[0]?.value;
|
||||
if (!githubUsername) {
|
||||
await respondToInteraction(interaction, "Please provide your GitHub username.");
|
||||
return;
|
||||
}
|
||||
|
||||
await respondToInteraction(interaction, "Linking your account...", true);
|
||||
|
||||
// Verify the GitHub username exists
|
||||
const exists = await verifyGitHubUser(githubUsername);
|
||||
if (!exists) {
|
||||
await editInteractionResponse(interaction, `\u274C GitHub user **${githubUsername}** not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const result = await addContributor(db, githubUsername, discordId);
|
||||
const emoji = result.success ? "\u2705" : "\u274C";
|
||||
await editInteractionResponse(interaction, `${emoji} ${result.message}`);
|
||||
} else if (data.name === "bounty-rank") {
|
||||
await respondToInteraction(interaction, "Checking your rank...", true);
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
const entry = await findByDiscord(db, discordId);
|
||||
|
||||
if (!entry) {
|
||||
await editInteractionResponse(
|
||||
interaction,
|
||||
"You haven't linked your GitHub yet. Use `/link-github <username>` first."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let msg = `**${entry.githubDisplay}** (linked)`;
|
||||
|
||||
const level = await getLurkrLevel(discordId);
|
||||
if (level) {
|
||||
msg += `\nLevel **${level.level}** | **${level.xp}** XP`;
|
||||
}
|
||||
|
||||
await editInteractionResponse(interaction, msg);
|
||||
} catch (err) {
|
||||
console.error("Rank lookup failed:", err);
|
||||
await editInteractionResponse(interaction, "Failed to look up your rank. Try again later.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function respondToInteraction(interaction: any, content: string, ephemeral = false) {
|
||||
await fetch(`${DISCORD_API}/interactions/${interaction.id}/${interaction.token}/callback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: 4,
|
||||
data: { content, flags: ephemeral ? 64 : 0 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function editInteractionResponse(interaction: any, content: string) {
|
||||
await fetch(
|
||||
`${DISCORD_API}/webhooks/${DISCORD_APP_ID}/${interaction.token}/messages/@original`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("Starting Bounty Bot...");
|
||||
|
||||
const db = await getDb();
|
||||
await ensureIndexes(db);
|
||||
console.log("MongoDB connected");
|
||||
|
||||
await registerCommands();
|
||||
await connectGateway();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error(err);
|
||||
await closeDb();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", async () => {
|
||||
await closeDb();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { getDb, closeDb, loadContributorMap } from "./contributors-db";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -181,11 +182,10 @@ function parseContributorsYaml(raw: string): Contributor[] {
|
||||
return contributors;
|
||||
}
|
||||
|
||||
function loadContributors(): Map<string, Contributor> {
|
||||
function loadContributorsFromYaml(): Map<string, Contributor> {
|
||||
const map = new Map<string, Contributor>();
|
||||
|
||||
try {
|
||||
// Resolve path relative to the script location (scripts/ dir → repo root)
|
||||
const scriptDir = new URL(".", import.meta.url).pathname;
|
||||
const raw = readFileSync(
|
||||
join(scriptDir, "..", "contributors.yml"),
|
||||
@@ -203,6 +203,20 @@ function loadContributors(): Map<string, Contributor> {
|
||||
return map;
|
||||
}
|
||||
|
||||
async function loadContributors(): Promise<Map<string, Contributor>> {
|
||||
if (process.env.MONGODB_URI) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const map = await loadContributorMap(db);
|
||||
console.log(`Loaded ${map.size} contributors from MongoDB`);
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.warn(`MongoDB load failed, falling back to YAML: ${err}`);
|
||||
}
|
||||
}
|
||||
return loadContributorsFromYaml();
|
||||
}
|
||||
|
||||
function resolveDiscord(
|
||||
githubUsername: string,
|
||||
contributors: Map<string, Contributor>
|
||||
@@ -285,7 +299,7 @@ function formatBountyNotification(bounty: BountyResult): string {
|
||||
msg += `PR: ${bounty.pr.html_url}\n`;
|
||||
|
||||
if (!bounty.discordId) {
|
||||
msg += `\n_\u{1F517} @${bounty.contributor}: link your Discord in \`contributors.yml\` to get pinged!_`;
|
||||
msg += `\n_\u{1F517} @${bounty.contributor}: use \`/link-github\` in Discord to get pinged!_`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
@@ -454,7 +468,7 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const contributors = loadContributors();
|
||||
const contributors = await loadContributors();
|
||||
|
||||
if (mode === "notify") {
|
||||
// Single bounty notification
|
||||
@@ -523,10 +537,12 @@ async function main() {
|
||||
}
|
||||
|
||||
// Run if invoked directly
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => closeDb());
|
||||
|
||||
// Export for testing
|
||||
export {
|
||||
@@ -535,6 +551,7 @@ export {
|
||||
formatBountyNotification,
|
||||
formatLeaderboard,
|
||||
loadContributors,
|
||||
loadContributorsFromYaml,
|
||||
resolveDiscord,
|
||||
awardLurkrXP,
|
||||
lurkrAddXP,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* MongoDB-backed contributor identity store.
|
||||
*
|
||||
* Stores GitHub ↔ Discord mappings in a `contributors` collection.
|
||||
* Used by bounty-bot.ts (read/write) and bounty-tracker.ts (read).
|
||||
*
|
||||
* Environment:
|
||||
* MONGODB_URI — MongoDB connection string (required)
|
||||
*/
|
||||
|
||||
import { MongoClient, type Collection, type Db } from "mongodb";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ContributorDoc {
|
||||
github: string; // GitHub username (lowercased for lookups)
|
||||
githubDisplay: string; // Original-case GitHub username
|
||||
discord: string; // Discord user ID
|
||||
name?: string;
|
||||
linkedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _client: MongoClient | null = null;
|
||||
let _db: Db | null = null;
|
||||
|
||||
export async function getDb(): Promise<Db> {
|
||||
if (_db) return _db;
|
||||
|
||||
const uri = process.env.MONGODB_URI;
|
||||
if (!uri) {
|
||||
throw new Error("Missing MONGODB_URI environment variable");
|
||||
}
|
||||
|
||||
_client = new MongoClient(uri);
|
||||
await _client.connect();
|
||||
_db = _client.db("hive");
|
||||
return _db;
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (_client) {
|
||||
await _client.close();
|
||||
_client = null;
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
|
||||
function collection(db: Db): Collection<ContributorDoc> {
|
||||
return db.collection<ContributorDoc>("contributors");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function findByGithub(
|
||||
db: Db,
|
||||
githubUsername: string
|
||||
): Promise<ContributorDoc | null> {
|
||||
return collection(db).findOne({ github: githubUsername.toLowerCase() });
|
||||
}
|
||||
|
||||
export async function findByDiscord(
|
||||
db: Db,
|
||||
discordId: string
|
||||
): Promise<ContributorDoc | null> {
|
||||
return collection(db).findOne({ discord: discordId });
|
||||
}
|
||||
|
||||
export async function getAllContributors(
|
||||
db: Db
|
||||
): Promise<ContributorDoc[]> {
|
||||
return collection(db).find().toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Map<github_lowercase, { github, discord, name }> for compatibility
|
||||
* with bounty-tracker.ts's existing contributor resolution.
|
||||
*/
|
||||
export async function loadContributorMap(
|
||||
db: Db
|
||||
): Promise<Map<string, { github: string; discord: string; name?: string }>> {
|
||||
const docs = await getAllContributors(db);
|
||||
const map = new Map<string, { github: string; discord: string; name?: string }>();
|
||||
for (const doc of docs) {
|
||||
map.set(doc.github, {
|
||||
github: doc.githubDisplay,
|
||||
discord: doc.discord,
|
||||
name: doc.name,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function addContributor(
|
||||
db: Db,
|
||||
githubUsername: string,
|
||||
discordId: string,
|
||||
name?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const existing = await findByGithub(db, githubUsername);
|
||||
if (existing) {
|
||||
return { success: false, message: `**${githubUsername}** is already linked.` };
|
||||
}
|
||||
|
||||
const byDiscord = await findByDiscord(db, discordId);
|
||||
if (byDiscord) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Your Discord is already linked to **${byDiscord.githubDisplay}**. To change it, ask a maintainer.`,
|
||||
};
|
||||
}
|
||||
|
||||
await collection(db).insertOne({
|
||||
github: githubUsername.toLowerCase(),
|
||||
githubDisplay: githubUsername,
|
||||
discord: discordId,
|
||||
name,
|
||||
linkedAt: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Linked! **${githubUsername}** ↔ Discord. You'll now receive XP when your bounty PRs are merged.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index setup (run once)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function ensureIndexes(db: Db): Promise<void> {
|
||||
const coll = collection(db);
|
||||
await coll.createIndex({ github: 1 }, { unique: true });
|
||||
await coll.createIndex({ discord: 1 }, { unique: true });
|
||||
}
|
||||
Reference in New Issue
Block a user