-
Notifications
You must be signed in to change notification settings - Fork 180
Adds Slack notifications on PR assignment #1359
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
const { Octokit } = require("@octokit/rest"); | ||
const { WebClient } = require("@slack/web-api"); | ||
const fs = require("fs"); | ||
|
||
const token = process.env.APP_TOKEN; | ||
|
@@ -10,6 +11,10 @@ const n = Math.max(1, parseInt(process.env.REVIEWERS_TO_REQUEST || "1", 10)); | |
const gh = new Octokit({ auth: token }); | ||
const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); | ||
|
||
// Slack configuration | ||
const slackToken = process.env.SLACK_BOT_TOKEN; | ||
const slack = slackToken ? new WebClient(slackToken) : null; | ||
|
||
function getEvent() { | ||
return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); | ||
} | ||
|
@@ -40,6 +45,81 @@ async function listTeamMembers(teamSlug) { | |
return res.data.map((u) => u.login); | ||
} | ||
|
||
async function getGitHubUserEmail(username) { | ||
try { | ||
const { data } = await gh.users.getByUsername({ username }); | ||
return data.email || null; | ||
} catch (error) { | ||
console.log(`Failed to fetch email for ${username}: ${error.message}`); | ||
return null; | ||
} | ||
} | ||
|
||
async function getSlackUserId(githubUsername) { | ||
if (!slack) { | ||
return null; | ||
} | ||
|
||
const email = await getGitHubUserEmail(githubUsername); | ||
if (!email || !email.endsWith("@e2b.dev")) { | ||
console.log(`No @e2b.dev email found for ${githubUsername}`); | ||
return null; | ||
} | ||
|
||
try { | ||
const result = await slack.users.lookupByEmail({ email }); | ||
if (result.ok && result.user?.id) { | ||
console.log(`Found Slack user ${result.user.id} for ${githubUsername} via ${email}`); | ||
return result.user.id; | ||
} | ||
} catch (error) { | ||
console.log(`Slack lookup failed for ${githubUsername} (${email}): ${error.message}`); | ||
} | ||
|
||
return null; | ||
} | ||
|
||
async function notifySlackUsers(assignees, prNumber, prUrl, prTitle) { | ||
if (!slack) { | ||
console.log("Slack not configured; skipping notifications."); | ||
return; | ||
} | ||
|
||
for (const assignee of assignees) { | ||
const slackUserId = await getSlackUserId(assignee); | ||
if (!slackUserId) { | ||
console.log(`Skipping Slack notification for ${assignee} (no Slack user found)`); | ||
continue; | ||
} | ||
|
||
try { | ||
await slack.chat.postMessage({ | ||
channel: slackUserId, | ||
text: `You've been assigned to review PR #${prNumber}: ${prTitle}\n${prUrl}`, | ||
blocks: [ | ||
{ | ||
type: "section", | ||
text: { | ||
type: "mrkdwn", | ||
text: `You've been assigned to review *<${prUrl}|PR #${prNumber}>*`, | ||
}, | ||
}, | ||
{ | ||
type: "section", | ||
text: { | ||
type: "mrkdwn", | ||
text: `*${prTitle}*`, | ||
}, | ||
}, | ||
], | ||
}); | ||
console.log(`Sent Slack DM to ${assignee} (${slackUserId})`); | ||
} catch (error) { | ||
console.log(`Failed to send Slack DM to ${assignee}: ${error.message}`); | ||
} | ||
} | ||
} | ||
|
||
function pickRandom(arr, k) { | ||
const list = [...arr]; | ||
for (let i = list.length - 1; i > 0; i--) { | ||
|
@@ -49,6 +129,24 @@ function pickRandom(arr, k) { | |
return list.slice(0, Math.max(0, Math.min(k, list.length))); | ||
} | ||
|
||
function parseCodeowners(content) { | ||
const lines = content.split("\n"); | ||
const owners = new Set(); | ||
|
||
for (const line of lines) { | ||
const trimmed = line.trim(); | ||
if (!trimmed || trimmed.startsWith("#")) continue; | ||
|
||
// Match @username patterns | ||
const matches = trimmed.matchAll(/@(\S+)/g); | ||
for (const match of matches) { | ||
owners.add(match[1]); | ||
} | ||
} | ||
|
||
return Array.from(owners); | ||
} | ||
|
||
(async () => { | ||
const ev = getEvent(); | ||
const num = prNumber(ev); | ||
|
@@ -64,37 +162,76 @@ function pickRandom(arr, k) { | |
const teams = await getUserTeams(author); | ||
const site = teams.includes(sf) ? sf : teams.includes(prg) ? prg : null; | ||
|
||
if (!site) { | ||
console.log("Author not in configured teams; skipping assignee update."); | ||
return; | ||
} | ||
|
||
const siteMembers = (await listTeamMembers(site)).filter((user) => user !== author); | ||
if (!siteMembers.length) { | ||
console.log(`No teammates found in ${site}; skipping assignee update.`); | ||
return; | ||
} | ||
|
||
const siteMemberSet = new Set(siteMembers); | ||
const assignedFromSite = [...alreadyAssigned].filter((login) => siteMemberSet.has(login)); | ||
let assignees; | ||
|
||
if (assignedFromSite.length >= n) { | ||
console.log(`PR #${num} already has ${assignedFromSite.length} teammate assignee(s); nothing to do.`); | ||
return; | ||
} | ||
|
||
const needed = n - assignedFromSite.length; | ||
|
||
const candidates = siteMembers.filter((member) => !alreadyAssigned.has(member)); | ||
if (!candidates.length) { | ||
console.log(`All teammates from ${site} are already assigned; nothing to add.`); | ||
return; | ||
} | ||
|
||
const assignees = pickRandom(candidates, needed); | ||
if (!assignees.length) { | ||
console.log(`Unable to select additional assignees from ${site}; skipping.`); | ||
return; | ||
if (!site) { | ||
console.log("Author not in configured teams; assigning from CODEOWNERS."); | ||
|
||
// Read and parse CODEOWNERS file | ||
let codeownersContent; | ||
try { | ||
codeownersContent = fs.readFileSync("CODEOWNERS", "utf8"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: CODEOWNERS Parsing Ignores Non-Root LocationsThe There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is ok |
||
} catch (error) { | ||
console.log(`Failed to read CODEOWNERS file: ${error.message}`); | ||
return; | ||
} | ||
|
||
const codeowners = parseCodeowners(codeownersContent); | ||
|
||
if (!codeowners.length) { | ||
console.log("No CODEOWNERS found; skipping assignee update."); | ||
return; | ||
} | ||
|
||
const codeownerSet = new Set(codeowners); | ||
const assignedFromCodeowners = [...alreadyAssigned].filter((login) => codeownerSet.has(login)); | ||
|
||
if (assignedFromCodeowners.length >= n) { | ||
console.log(`PR #${num} already has ${assignedFromCodeowners.length} CODEOWNER assignee(s); nothing to do.`); | ||
return; | ||
} | ||
|
||
const needed = n - assignedFromCodeowners.length; | ||
const candidates = codeowners.filter((owner) => owner !== author && !alreadyAssigned.has(owner)); | ||
|
||
if (!candidates.length) { | ||
console.log("All CODEOWNERS are either the author or already assigned; nothing to add."); | ||
return; | ||
} | ||
|
||
assignees = pickRandom(candidates, needed); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we don't use teams in CODEOWNERS, so ok |
||
if (!assignees.length) { | ||
console.log("Unable to select assignees from CODEOWNERS; skipping."); | ||
return; | ||
} | ||
} else { | ||
const siteMembers = (await listTeamMembers(site)).filter((user) => user !== author); | ||
if (!siteMembers.length) { | ||
console.log(`No teammates found in ${site}; skipping assignee update.`); | ||
return; | ||
} | ||
|
||
const siteMemberSet = new Set(siteMembers); | ||
const assignedFromSite = [...alreadyAssigned].filter((login) => siteMemberSet.has(login)); | ||
|
||
if (assignedFromSite.length >= n) { | ||
console.log(`PR #${num} already has ${assignedFromSite.length} teammate assignee(s); nothing to do.`); | ||
return; | ||
} | ||
|
||
const needed = n - assignedFromSite.length; | ||
|
||
const candidates = siteMembers.filter((member) => !alreadyAssigned.has(member)); | ||
if (!candidates.length) { | ||
console.log(`All teammates from ${site} are already assigned; nothing to add.`); | ||
return; | ||
} | ||
|
||
assignees = pickRandom(candidates, needed); | ||
if (!assignees.length) { | ||
console.log(`Unable to select additional assignees from ${site}; skipping.`); | ||
return; | ||
} | ||
} | ||
|
||
await gh.issues.addAssignees({ | ||
|
@@ -105,6 +242,9 @@ function pickRandom(arr, k) { | |
}); | ||
|
||
console.log(`Assigned ${assignees.join(", ")} to PR #${num}.`); | ||
|
||
// Send Slack notifications | ||
await notifySlackUsers(assignees, num, pr.html_url, pr.title); | ||
})().catch((error) => { | ||
console.error(error); | ||
process.exit(1); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this won't work if the primary email isn't
e2b.dev
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, i will take a look at this, seems like via GraphQL you can query all mails..