From e2a5b1ef9740ce76b230348a3a935c7921d3ac47 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Tue, 30 Sep 2025 14:40:55 +0200 Subject: [PATCH 1/8] Added action script + workflow for same-site PR reviewer --- .../actions/auto-request-same-site/script.js | 76 +++++++++++++++++++ .github/workflows/auto-request-same-site.yml | 37 +++++++++ 2 files changed, 113 insertions(+) create mode 100644 .github/actions/auto-request-same-site/script.js create mode 100644 .github/workflows/auto-request-same-site.yml diff --git a/.github/actions/auto-request-same-site/script.js b/.github/actions/auto-request-same-site/script.js new file mode 100644 index 0000000000..f68e27e295 --- /dev/null +++ b/.github/actions/auto-request-same-site/script.js @@ -0,0 +1,76 @@ +const { Octokit } = require("@octokit/rest"); +const fs = require("fs"); + +const token = process.env.APP_TOKEN; // GitHub App installation token +const org = process.env.ORG; +const sf = process.env.SF_TEAM_SLUG; +const prg = process.env.PRG_TEAM_SLUG; +const n = parseInt(process.env.REVIEWERS_TO_REQUEST || "1", 10); + +const gh = new Octokit({ auth: token }); +const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); + +function prNumber() { + const ev = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + return ev.pull_request?.number || null; +} + +async function getPR(num) { + const { data } = await gh.pulls.get({ owner, repo, pull_number: num }); + return data; +} + +async function getUserTeams(login) { + const data = await gh.graphql( + `query($org:String!, $login:String!){ + organization(login:$org){ + teams(first:100, userLogins: [$login]){ nodes { slug } } + } + }`, + { org, login } + ); + return (data.organization?.teams?.nodes || []).map(t => t.slug); +} + +async function listTeamMembers(teamSlug) { + const res = await gh.teams.listMembersInOrg({ org, team_slug: teamSlug, per_page: 100 }); + return res.data.map(u => u.login); +} + +function pickRandom(arr, k) { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a.slice(0, Math.max(0, Math.min(k, a.length))); +} + +(async () => { + const num = prNumber(); + if (!num) { console.log("No PR number; exiting."); return; } + + const pr = await getPR(num); + const author = pr.user.login; + + const teams = await getUserTeams(author); + const site = teams.includes(sf) ? sf : teams.includes(prg) ? prg : null; + if (!site) { console.log("Author not in eng-sf or eng-prg; skipping."); return; } + + // Individuals (default) + const members = (await listTeamMembers(site)).filter(u => u !== author); + const already = new Set(pr.requested_reviewers.map(r => r.login)); + const candidates = members.filter(m => !already.has(m)); + const reviewers = pickRandom(candidates, n); + + if (reviewers.length) { + await gh.pulls.requestReviewers({ owner, repo, pull_number: num, reviewers }); + console.log(`Requested ${reviewers.join(", ")} from ${site}`); + } else { + console.log(`No candidates to request from ${site}.`); + } + + // Or request the whole team: + // await gh.pulls.requestReviewers({ owner, repo, pull_number: num, team_reviewers: [site] }); + // console.log(`Requested team ${site}`); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/.github/workflows/auto-request-same-site.yml b/.github/workflows/auto-request-same-site.yml new file mode 100644 index 0000000000..c96d997388 --- /dev/null +++ b/.github/workflows/auto-request-same-site.yml @@ -0,0 +1,37 @@ +name: Auto-request same-site reviewers + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, synchronize, edited] + +permissions: {} + +jobs: + assign: + runs-on: ubuntu-latest + steps: + - name: Get GitHub App installation token + id: app + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: e2b-dev + + - uses: actions/setup-node@v4 + with: { node-version: 20 } + + - run: npm init -y && npm i @octokit/rest + + # No checkout needed since we don't run repo code, + # but if you want the file *in* the repo, do checkout + run it: + - uses: actions/checkout@v4 + + - name: Request reviewers (same-site) + env: + APP_TOKEN: ${{ steps.app.outputs.token }} + ORG: e2b-dev + SF_TEAM_SLUG: eng-sf + PRG_TEAM_SLUG: eng-prg + REVIEWERS_TO_REQUEST: "1" + run: node .github/actions/auto-request-same-site/script.js From 63e861e5a4b1eb49a97ce044c6c6d625f343b276 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Tue, 30 Sep 2025 15:05:18 +0200 Subject: [PATCH 2/8] Updated version that uses default CODEOWNERS if the PR author is not from sf or prg team --- .../actions/auto-request-same-site/script.js | 119 +++++++++++++++--- .github/workflows/auto-request-same-site.yml | 23 ++-- 2 files changed, 116 insertions(+), 26 deletions(-) diff --git a/.github/actions/auto-request-same-site/script.js b/.github/actions/auto-request-same-site/script.js index f68e27e295..2a70be9a3f 100644 --- a/.github/actions/auto-request-same-site/script.js +++ b/.github/actions/auto-request-same-site/script.js @@ -1,25 +1,27 @@ const { Octokit } = require("@octokit/rest"); const fs = require("fs"); -const token = process.env.APP_TOKEN; // GitHub App installation token +const token = process.env.APP_TOKEN; // GitHub App installation token const org = process.env.ORG; const sf = process.env.SF_TEAM_SLUG; const prg = process.env.PRG_TEAM_SLUG; const n = parseInt(process.env.REVIEWERS_TO_REQUEST || "1", 10); +const TEAM_MODE = (process.env.TEAM_MODE || "false").toLowerCase() === "true"; const gh = new Octokit({ auth: token }); const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); -function prNumber() { - const ev = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); +// ---- helpers ---- +function getEvent() { + return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); +} +function prNumber(ev) { return ev.pull_request?.number || null; } - async function getPR(num) { const { data } = await gh.pulls.get({ owner, repo, pull_number: num }); return data; } - async function getUserTeams(login) { const data = await gh.graphql( `query($org:String!, $login:String!){ @@ -31,12 +33,10 @@ async function getUserTeams(login) { ); return (data.organization?.teams?.nodes || []).map(t => t.slug); } - async function listTeamMembers(teamSlug) { const res = await gh.teams.listMembersInOrg({ org, team_slug: teamSlug, per_page: 100 }); return res.data.map(u => u.login); } - function pickRandom(arr, k) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { @@ -46,21 +46,110 @@ function pickRandom(arr, k) { return a.slice(0, Math.max(0, Math.min(k, a.length))); } +// Read CODEOWNERS (from usual locations) and parse the global "*" owners. +// Returns { users: [logins], teams: [teamSlugs] } +async function readDefaultCodeownersOwners(baseRef) { + const paths = [".github/CODEOWNERS", "CODEOWNERS"]; + let text = ""; + for (const path of paths) { + try { + const { data } = await gh.repos.getContent({ owner, repo, path, ref: baseRef }); + if (Array.isArray(data)) continue; // directory + text = Buffer.from(data.content, "base64").toString("utf8"); + break; + } catch { /* try next */ } + } + if (!text) return { users: [], teams: [] }; + + // find last matching "*" rule (later rules take precedence) + const lines = text.split(/\r?\n/); + let starOwners = null; + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const parts = line.split(/\s+/); + if (parts[0] === "*") starOwners = parts.slice(1); + } + if (!starOwners) return { users: [], teams: [] }; + + const users = []; + const teams = []; + for (const ownerRef of starOwners) { + if (!ownerRef.startsWith("@")) continue; + const clean = ownerRef.slice(1); // remove leading '@' + const slash = clean.indexOf("/"); + if (slash > -1) { + // looks like org/team + const maybeOrg = clean.slice(0, slash); + const teamSlug = clean.slice(slash + 1); + if (maybeOrg.toLowerCase() === org.toLowerCase()) teams.push(teamSlug); + // if CODEOWNERS references another org's team, we ignore + } else { + users.push(clean); + } + } + return { users, teams }; +} + (async () => { - const num = prNumber(); + const ev = getEvent(); + const num = prNumber(ev); if (!num) { console.log("No PR number; exiting."); return; } + const baseRef = ev.pull_request?.base?.ref || "main"; const pr = await getPR(num); const author = pr.user.login; + // Determine author site const teams = await getUserTeams(author); const site = teams.includes(sf) ? sf : teams.includes(prg) ? prg : null; - if (!site) { console.log("Author not in eng-sf or eng-prg; skipping."); return; } - // Individuals (default) - const members = (await listTeamMembers(site)).filter(u => u !== author); - const already = new Set(pr.requested_reviewers.map(r => r.login)); - const candidates = members.filter(m => !already.has(m)); + // If author is not in eng-sf or eng-prg => do nothing (keep CODEOWNERS defaults) + if (!site) { + console.log("Author not in eng-sf or eng-prg; keeping CODEOWNERS reviewers."); + return; + } + + // Author IS internal: remove global CODEOWNERS defaults, then assign same-site reviewers + const defaults = await readDefaultCodeownersOwners(baseRef); + console.log("CODEOWNERS * defaults:", defaults); + + // Find currently requested users & teams + const { data: req } = await gh.pulls.listRequestedReviewers({ owner, repo, pull_number: num }); + const currentUsers = new Set(req.users.map(u => u.login)); + const currentTeams = new Set(req.teams.map(t => t.slug)); + + // Compute removal sets based on CODEOWNERS defaults + const toRemoveUsers = defaults.users.filter(u => currentUsers.has(u)); + const toRemoveTeams = defaults.teams.filter(t => currentTeams.has(t)); + + if (toRemoveUsers.length || toRemoveTeams.length) { + await gh.pulls.removeRequestedReviewers({ + owner, repo, pull_number: num, + reviewers: toRemoveUsers, + team_reviewers: toRemoveTeams + }); + console.log( + "Removed CODEOWNERS defaults:", + toRemoveUsers.length ? `users=[${toRemoveUsers.join(", ")}]` : "users=[]", + toRemoveTeams.length ? `teams=[${toRemoveTeams.join(", ")}]` : "teams=[]" + ); + } else { + console.log("No CODEOWNERS defaults currently requested (nothing to remove)."); + } + + // Now request same-site reviewers + if (TEAM_MODE) { + await gh.pulls.requestReviewers({ owner, repo, pull_number: num, team_reviewers: [site] }); + console.log(`Requested team ${site}`); + return; + } + + const siteMembers = (await listTeamMembers(site)).filter(u => u !== author); + // refresh requested reviewers after potential removals + const { data: req2 } = await gh.pulls.listRequestedReviewers({ owner, repo, pull_number: num }); + const already = new Set(req2.users.map(u => u.login)); + const candidates = siteMembers.filter(m => !already.has(m)); const reviewers = pickRandom(candidates, n); if (reviewers.length) { @@ -69,8 +158,4 @@ function pickRandom(arr, k) { } else { console.log(`No candidates to request from ${site}.`); } - - // Or request the whole team: - // await gh.pulls.requestReviewers({ owner, repo, pull_number: num, team_reviewers: [site] }); - // console.log(`Requested team ${site}`); })().catch(e => { console.error(e); process.exit(1); }); diff --git a/.github/workflows/auto-request-same-site.yml b/.github/workflows/auto-request-same-site.yml index c96d997388..9a72363783 100644 --- a/.github/workflows/auto-request-same-site.yml +++ b/.github/workflows/auto-request-same-site.yml @@ -1,9 +1,11 @@ name: Auto-request same-site reviewers on: + # Works for PRs from branches AND forks (runs in base-repo context) pull_request_target: types: [opened, reopened, ready_for_review, synchronize, edited] +# We’ll use a GitHub App installation token; the default GITHUB_TOKEN isn’t needed permissions: {} jobs: @@ -17,21 +19,24 @@ jobs: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: e2b-dev + # repositories: infra-stable # optionally narrow - - uses: actions/setup-node@v4 - with: { node-version: 20 } - - - run: npm init -y && npm i @octokit/rest + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 - # No checkout needed since we don't run repo code, - # but if you want the file *in* the repo, do checkout + run it: - - uses: actions/checkout@v4 + - name: Install deps + run: | + npm init -y + npm i @octokit/rest - - name: Request reviewers (same-site) + - name: Run same-site override env: APP_TOKEN: ${{ steps.app.outputs.token }} ORG: e2b-dev SF_TEAM_SLUG: eng-sf PRG_TEAM_SLUG: eng-prg - REVIEWERS_TO_REQUEST: "1" + REVIEWERS_TO_REQUEST: "1" # change to "2" if you want two people + TEAM_MODE: "false" # set to "true" to request the whole team instead of individuals run: node .github/actions/auto-request-same-site/script.js From 924c9ab25f3ec873d0d1f1afd349e3ad40d35be8 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Tue, 30 Sep 2025 15:11:26 +0200 Subject: [PATCH 3/8] Added checkout to workflow --- .github/workflows/auto-request-same-site.yml | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/.github/workflows/auto-request-same-site.yml b/.github/workflows/auto-request-same-site.yml index 9a72363783..02c2adfec3 100644 --- a/.github/workflows/auto-request-same-site.yml +++ b/.github/workflows/auto-request-same-site.yml @@ -1,13 +1,3 @@ -name: Auto-request same-site reviewers - -on: - # Works for PRs from branches AND forks (runs in base-repo context) - pull_request_target: - types: [opened, reopened, ready_for_review, synchronize, edited] - -# We’ll use a GitHub App installation token; the default GITHUB_TOKEN isn’t needed -permissions: {} - jobs: assign: runs-on: ubuntu-latest @@ -19,7 +9,9 @@ jobs: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: e2b-dev - # repositories: infra-stable # optionally narrow + + - name: Checkout repo + uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 @@ -37,6 +29,6 @@ jobs: ORG: e2b-dev SF_TEAM_SLUG: eng-sf PRG_TEAM_SLUG: eng-prg - REVIEWERS_TO_REQUEST: "1" # change to "2" if you want two people - TEAM_MODE: "false" # set to "true" to request the whole team instead of individuals + REVIEWERS_TO_REQUEST: "1" + TEAM_MODE: "false" run: node .github/actions/auto-request-same-site/script.js From f0494e04900b551484cf41f37760973e7ac9ca9a Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Tue, 30 Sep 2025 15:17:48 +0200 Subject: [PATCH 4/8] Added forgotton trigger --- .github/workflows/auto-request-same-site.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-request-same-site.yml b/.github/workflows/auto-request-same-site.yml index 02c2adfec3..656a0d7c77 100644 --- a/.github/workflows/auto-request-same-site.yml +++ b/.github/workflows/auto-request-same-site.yml @@ -1,3 +1,12 @@ +name: Auto-request same-site reviewers + +on: + # Works for PRs from branches and forks (runs in base-repo context) + pull_request_target: + types: [opened, reopened, ready_for_review, synchronize, edited] + +permissions: {} # we use the GitHub App token, not GITHUB_TOKEN + jobs: assign: runs-on: ubuntu-latest @@ -10,7 +19,7 @@ jobs: private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: e2b-dev - - name: Checkout repo + - name: Checkout repo (needed because the script lives in the repo) uses: actions/checkout@v4 - name: Setup Node From 603abb4ee623d4d7c6b468e986c31e6c1713d121 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Tue, 30 Sep 2025 15:42:14 +0200 Subject: [PATCH 5/8] Fixed and tested version --- .github/workflows/auto-request-same-site.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-request-same-site.yml b/.github/workflows/auto-request-same-site.yml index 656a0d7c77..26023e0a97 100644 --- a/.github/workflows/auto-request-same-site.yml +++ b/.github/workflows/auto-request-same-site.yml @@ -5,7 +5,8 @@ on: pull_request_target: types: [opened, reopened, ready_for_review, synchronize, edited] -permissions: {} # we use the GitHub App token, not GITHUB_TOKEN +permissions: + contents: read jobs: assign: From fee8fabf734a064edfea22ab41cd48ba18c7f12f Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Tue, 30 Sep 2025 15:56:34 +0200 Subject: [PATCH 6/8] changed secrets slugs & bumped version to 24 --- .github/workflows/auto-request-same-site.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-request-same-site.yml b/.github/workflows/auto-request-same-site.yml index 26023e0a97..064b600057 100644 --- a/.github/workflows/auto-request-same-site.yml +++ b/.github/workflows/auto-request-same-site.yml @@ -16,8 +16,8 @@ jobs: id: app uses: actions/create-github-app-token@v1 with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} + app-id: ${{ secrets.E2B_GH_APP_AUTO_REQUEST_SAME_SITE_APP_ID }} + private-key: ${{ secrets.E2B_GH_APP_AUTO_REQUEST_SAME_SITE_APP_PRIVATE_KEY }} owner: e2b-dev - name: Checkout repo (needed because the script lives in the repo) @@ -26,7 +26,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Install deps run: | From 40f20d7d9f9d7d2096f375154e2f4452f83a5182 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Wed, 1 Oct 2025 12:35:54 +0200 Subject: [PATCH 7/8] Make sure we run the auto_assign action only on opened and ready_for_review --- .github/workflows/auto-request-same-site.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-request-same-site.yml b/.github/workflows/auto-request-same-site.yml index 064b600057..1258219551 100644 --- a/.github/workflows/auto-request-same-site.yml +++ b/.github/workflows/auto-request-same-site.yml @@ -3,7 +3,7 @@ name: Auto-request same-site reviewers on: # Works for PRs from branches and forks (runs in base-repo context) pull_request_target: - types: [opened, reopened, ready_for_review, synchronize, edited] + types: [opened, ready_for_review] permissions: contents: read From b82aba158e5b9714cac1c619193324c45ac2880c Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Wed, 1 Oct 2025 12:39:19 +0200 Subject: [PATCH 8/8] Make sure we run the auto_assign action only on opened and ready_for_review --- .github/workflows/auto-request-same-site.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-request-same-site.yml b/.github/workflows/auto-request-same-site.yml index 1258219551..c56232fc0b 100644 --- a/.github/workflows/auto-request-same-site.yml +++ b/.github/workflows/auto-request-same-site.yml @@ -3,7 +3,7 @@ name: Auto-request same-site reviewers on: # Works for PRs from branches and forks (runs in base-repo context) pull_request_target: - types: [opened, ready_for_review] + types: [opened] permissions: contents: read