Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 170 additions & 30 deletions .github/actions/auto-request-same-site/script.js
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;
Expand All @@ -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"));
}
Expand Down Expand Up @@ -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")) {
Copy link
Member

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

Copy link
Member Author

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..

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--) {
Expand All @@ -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);
Expand All @@ -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");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: CODEOWNERS Parsing Ignores Non-Root Locations

The CODEOWNERS parsing logic currently only checks the repository root for the file. GitHub supports CODEOWNERS in three standard locations (root, .github/, and docs/), so the action fails to find files in .github/ or docs/. This prevents the fallback assignment logic from working correctly when the PR author isn't in a configured team.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

The 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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: CODEOWNERS Parsing Fails with Team References

The parseCodeowners function extracts both individual usernames and team references from CODEOWNERS. Since the GitHub API for adding assignees only accepts individual usernames, attempting to assign team references causes the API call to fail.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

The 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({
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/auto-request-same-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
- name: Install deps
run: |
npm init -y
npm i @octokit/rest
npm i @octokit/rest @slack/web-api

- name: Run same-site override
env:
Expand All @@ -45,4 +45,5 @@ jobs:
PRG_TEAM_SLUG: eng-prg
REVIEWERS_TO_REQUEST: "1"
TEAM_MODE: "false"
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
run: node .github/actions/auto-request-same-site/script.js