Assign Check Bot #44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Assign Check Bot | |
| on: | |
| issue_comment: | |
| types: [created] | |
| schedule: | |
| - cron: '0 9 * * *' # daily at 09:00 UTC — deadline checker | |
| jobs: | |
| # ── /assign-check and /approve-assign ──────────────────────────────────── | |
| handle-command: | |
| if: github.event_name == 'issue_comment' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| issues: write | |
| contents: read | |
| steps: | |
| - name: Run assign bot | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| // ── Config ────────────────────────────────────────────────────── | |
| const MAINTAINERS = ['bitflicker64', 'maintainer2', 'maintainer3']; | |
| // ☝️ Replace with your actual maintainer GitHub usernames | |
| const REPO_OWNER = context.repo.owner; | |
| const REPO_NAME = context.repo.repo; | |
| const UPSTREAM = 'apache/hugegraph'; // upstream for stats lookups | |
| const FULL_REPO = UPSTREAM; | |
| const commenter = context.payload.comment.user.login; | |
| const body = context.payload.comment.body.trim(); | |
| const issueNumber = context.payload.issue.number; | |
| // ── Route commands ────────────────────────────────────────────── | |
| // Strip markdown formatting GitHub editors inject around mentions: | |
| // __[@user](url)__ or **[@user](url)** or plain @user — normalise to plain text first | |
| const plainBody = body | |
| .replace(/[_*]{1,2}\[@?([\w-]+)\]\([^)]*\)[_*]{1,2}/g, '@$1') // __[@user](url)__ | |
| .replace(/\[@?([\w-]+)\]\([^)]*\)/g, '@$1'); // [@user](url) | |
| const assignCheckMatch = plainBody.match(/^\/assign-check\s+@?([\w-]+)\s+(\d+)d?\b/i); | |
| const approveAssignMatch = plainBody.match(/^\/approve-assign\s+@?([\w-]+)/i); | |
| if (!assignCheckMatch && !approveAssignMatch) return; | |
| // ── Helpers ───────────────────────────────────────────────────── | |
| async function postComment(msg) { | |
| await github.rest.issues.createComment({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| issue_number: issueNumber, body: msg | |
| }); | |
| } | |
| async function isMaintainer(login) { | |
| return MAINTAINERS.includes(login); | |
| } | |
| // Fetch total contributions to this repo (commits authored) | |
| async function getRepoContributions(login) { | |
| try { | |
| const { data } = await github.rest.repos.getContributorsStats({ | |
| owner: REPO_OWNER, repo: REPO_NAME | |
| }); | |
| if (!Array.isArray(data)) return 0; | |
| const entry = data.find(c => c.author?.login === login); | |
| return entry ? entry.total : 0; | |
| } catch { return 0; } | |
| } | |
| // Fetch public profile data | |
| async function getUserProfile(login) { | |
| try { | |
| const { data } = await github.rest.users.getByUsername({ username: login }); | |
| return data; | |
| } catch { return null; } | |
| } | |
| // Fetch merged PRs in this repo | |
| async function getMergedPRs(login) { | |
| try { | |
| const { data } = await github.rest.search.issuesAndPullRequests({ | |
| q: `repo:${FULL_REPO} is:pr is:merged author:${login}` | |
| }); | |
| return data.total_count; | |
| } catch { return 0; } | |
| } | |
| // Fetch issues closed/opened in this repo | |
| async function getIssueActivity(login) { | |
| try { | |
| const { data } = await github.rest.search.issuesAndPullRequests({ | |
| q: `repo:${FULL_REPO} is:issue author:${login}` | |
| }); | |
| return data.total_count; | |
| } catch { return 0; } | |
| } | |
| // Check if user has forked the upstream repo. | |
| // We can't assume the fork has the same repo name (e.g. apache/hugegraph | |
| // forks as Pranjal2007v/hugegraph, not /incubator-hugegraph). | |
| // So we list the user's public repos and check if any is a fork of UPSTREAM. | |
| async function hasFork(login) { | |
| try { | |
| // First try the most likely name (upstream repo name) | |
| const upstreamRepoName = UPSTREAM.split('/')[1]; | |
| try { | |
| const { data } = await github.rest.repos.get({ | |
| owner: login, | |
| repo: upstreamRepoName | |
| }); | |
| if (data.fork === true && data.parent?.full_name === UPSTREAM) return true; | |
| } catch { /* repo name didn't match, fall through */ } | |
| // Fallback: scan user's repos for any fork of UPSTREAM (up to 100) | |
| const { data: repos } = await github.rest.repos.listForUser({ | |
| username: login, | |
| type: 'forks', | |
| per_page: 100 | |
| }); | |
| for (const repo of repos) { | |
| if (repo.fork) { | |
| try { | |
| const { data: full } = await github.rest.repos.get({ | |
| owner: login, repo: repo.name | |
| }); | |
| if (full.parent?.full_name === UPSTREAM) return true; | |
| } catch { continue; } | |
| } | |
| } | |
| return false; | |
| } catch { return false; } | |
| } | |
| function timeAgo(dateStr) { | |
| const diff = Date.now() - new Date(dateStr).getTime(); | |
| const days = Math.floor(diff / 86400000); | |
| if (days < 30) return `${days}d ago`; | |
| if (days < 365) return `${Math.floor(days/30)}mo ago`; | |
| return `${Math.floor(days/365)}yr ago`; | |
| } | |
| // ── /assign-check @user Nd ─────────────────────────────────────── | |
| if (assignCheckMatch) { | |
| const targetUser = assignCheckMatch[1]; | |
| const days = parseInt(assignCheckMatch[2], 10); | |
| if (days < 1 || days > 90) { | |
| return postComment(`⚠️ @${commenter} — deadline must be between 1 and 90 days.`); | |
| } | |
| // Step 1: fork check | |
| const forked = await hasFork(targetUser); | |
| if (!forked) { | |
| return postComment( | |
| `🤖 **@${targetUser} hasn't forked this repo.**\n\n` + | |
| `Not gonna let you work on it buddy — fork the repo first, ` + | |
| `then someone can run \`/assign-check\` for you again.\n\n` + | |
| `> _Fork it here: https://github.com/${FULL_REPO}_` | |
| ); | |
| } | |
| // Step 2: gather bio data in parallel | |
| const [profile, repoContribs, mergedPRs, issueCount] = await Promise.all([ | |
| getUserProfile(targetUser), | |
| getRepoContributions(targetUser), | |
| getMergedPRs(targetUser), | |
| getIssueActivity(targetUser) | |
| ]); | |
| if (!profile) { | |
| return postComment(`⚠️ Could not fetch profile for @${targetUser}.`); | |
| } | |
| const accountAge = timeAgo(profile.created_at); | |
| const followers = profile.followers ?? 0; | |
| const publicRepos = profile.public_repos ?? 0; | |
| const bio = profile.bio ? `\n> ${profile.bio}\n` : ''; | |
| const location = profile.location ? `📍 ${profile.location}` : ''; | |
| const company = profile.company ? `🏢 ${profile.company}` : ''; | |
| const card = [ | |
| `## 🤖 Contributor Bio — @${targetUser}`, | |
| bio, | |
| `| Field | Value |`, | |
| `|---|---|`, | |
| `| 🍴 Forked repo | ✅ Yes |`, | |
| `| 📅 Account created | ${accountAge} |`, | |
| `| 👥 Followers | ${followers} |`, | |
| `| 📦 Public repos | ${publicRepos} |`, | |
| location ? `| ${location} | |` : null, | |
| company ? `| ${company} | |` : null, | |
| `| 🔨 Commits in this repo | ${repoContribs} |`, | |
| `| ✅ PRs merged in this repo | ${mergedPRs} |`, | |
| `| 📝 Issues opened in this repo | ${issueCount} |`, | |
| ``, | |
| `---`, | |
| `**Requested deadline:** ${days} day${days > 1 ? 's' : ''}`, | |
| ``, | |
| `A maintainer can approve with:`, | |
| `\`\`\``, | |
| `/approve-assign @${targetUser}`, | |
| `\`\`\``, | |
| ``, | |
| `_🤖 Assign Bot — bio pulled from GitHub API_` | |
| ].filter(l => l !== null).join('\n'); | |
| return postComment(card); | |
| } | |
| // ── /approve-assign @user ──────────────────────────────────────── | |
| if (approveAssignMatch) { | |
| if (!(await isMaintainer(commenter))) { | |
| return postComment( | |
| `🚫 @${commenter} — only maintainers can approve assignments.` | |
| ); | |
| } | |
| const targetUser = approveAssignMatch[1]; | |
| // Look back through comments to find the most recent /assign-check for this user | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| issue_number: issueNumber, per_page: 100 | |
| }); | |
| let days = 7; // default if not found | |
| for (let i = comments.length - 1; i >= 0; i--) { | |
| const m = comments[i].body.match(new RegExp(`\\/assign-check\\s+@?${targetUser}\\s+(\\d+)d?\\b`, 'i')); | |
| if (m) { days = parseInt(m[1], 10); break; } | |
| } | |
| const deadline = new Date(Date.now() + days * 86400000); | |
| const deadlineStr = deadline.toISOString().split('T')[0]; // YYYY-MM-DD | |
| // Assign the issue | |
| await github.rest.issues.addAssignees({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| issue_number: issueNumber, | |
| assignees: [targetUser] | |
| }); | |
| // Add label (create it if missing) | |
| const labelName = `assigned:${days}d`; | |
| try { | |
| await github.rest.issues.getLabel({ owner: REPO_OWNER, repo: REPO_NAME, name: labelName }); | |
| } catch { | |
| await github.rest.issues.createLabel({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| name: labelName, color: '0075ca', | |
| description: `Assigned with a ${days}-day deadline` | |
| }); | |
| } | |
| await github.rest.issues.addLabels({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| issue_number: issueNumber, labels: [labelName] | |
| }); | |
| // Store deadline as a pinned comment so the cron can find it | |
| await postComment( | |
| `<!-- ASSIGN_BOT: assignee=${targetUser} deadline=${deadlineStr} days=${days} -->\n` + | |
| `✅ **@${targetUser} has been assigned** by @${commenter}.\n\n` + | |
| `⏰ **Deadline: ${deadlineStr}** (${days} day${days > 1 ? 's' : ''} from now)\n\n` + | |
| `Please post an update at least once before the deadline. ` + | |
| `The bot will warn you 3 days before and unassign if there's no activity by ${deadlineStr}.\n\n` + | |
| `_🤖 Assign Bot_` | |
| ); | |
| } | |
| # ── Daily deadline checker ───────────────────────────────────────────────── | |
| check-deadlines: | |
| if: github.event_name == 'schedule' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| issues: write | |
| contents: read | |
| steps: | |
| - name: Check assignment deadlines | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const REPO_OWNER = context.repo.owner; | |
| const REPO_NAME = context.repo.repo; | |
| const today = new Date(); | |
| // Get all open issues that have an assigned:Xd label | |
| const { data: issues } = await github.rest.issues.listForRepo({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| state: 'open', per_page: 100 | |
| }); | |
| const assignedIssues = issues.filter(issue => | |
| issue.labels.some(l => l.name.startsWith('assigned:')) | |
| ); | |
| console.log(`Found ${assignedIssues.length} assigned issue(s) to check.`); | |
| for (const issue of assignedIssues) { | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| issue_number: issue.number, per_page: 100 | |
| }); | |
| let assignee = null; | |
| let deadline = null; | |
| let days = null; | |
| for (const c of comments) { | |
| const m = c.body.match(/<!-- ASSIGN_BOT: assignee=([\w-]+) deadline=(\d{4}-\d{2}-\d{2}) days=(\d+) -->/); | |
| if (m) { | |
| assignee = m[1]; | |
| deadline = new Date(m[2]); | |
| days = parseInt(m[3], 10); | |
| } | |
| } | |
| if (!assignee || !deadline) continue; | |
| const daysLeft = Math.ceil((deadline - today) / 86400000); | |
| console.log(`Issue #${issue.number}: @${assignee}, ${daysLeft} day(s) left`); | |
| const assignBotComment = comments.find(c => | |
| c.body.includes('<!-- ASSIGN_BOT:') && c.body.includes(assignee) | |
| ); | |
| const assignedAt = assignBotComment ? new Date(assignBotComment.created_at) : null; | |
| const hasUpdate = assignedAt && comments.some(c => | |
| c.user.login === assignee && | |
| new Date(c.created_at) > assignedAt | |
| ); | |
| // ── 3-day warning ──────────────────────────────────────────── | |
| if (daysLeft === 3 && !hasUpdate) { | |
| const alreadyWarned = comments.some(c => | |
| c.body.includes('⚠️ **Deadline reminder') && c.body.includes(assignee) | |
| ); | |
| if (!alreadyWarned) { | |
| await github.rest.issues.createComment({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| issue_number: issue.number, | |
| body: | |
| `⚠️ **Deadline reminder — @${assignee}**\n\n` + | |
| `You have **3 days left** (deadline: ${deadline.toISOString().split('T')[0]}) ` + | |
| `to post an update on this issue.\n\n` + | |
| `If you need more time or can't continue, please let the maintainers know ` + | |
| `so someone else can pick this up.\n\n` + | |
| `_🤖 Assign Bot_` | |
| }); | |
| console.log(`Warned @${assignee} on issue #${issue.number}`); | |
| } | |
| } | |
| // ── Deadline passed, no update → unassign ──────────────────── | |
| if (daysLeft <= 0 && !hasUpdate) { | |
| await github.rest.issues.removeAssignees({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| issue_number: issue.number, | |
| assignees: [assignee] | |
| }); | |
| const labelName = `assigned:${days}d`; | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| issue_number: issue.number, name: labelName | |
| }); | |
| } catch { /* label may already be gone */ } | |
| await github.rest.issues.createComment({ | |
| owner: REPO_OWNER, repo: REPO_NAME, | |
| issue_number: issue.number, | |
| body: | |
| `⏰ **Time's up — @${assignee} has been unassigned.**\n\n` + | |
| `The ${days}-day deadline passed with no update on this issue. ` + | |
| `This issue is now open for someone else to pick up.\n\n` + | |
| `@${assignee} — if you'd still like to work on this, ` + | |
| `ask a maintainer to re-assign you.\n\n` + | |
| `_🤖 Assign Bot_` | |
| }); | |
| console.log(`Unassigned @${assignee} from issue #${issue.number} — deadline passed`); | |
| } | |
| } |