Skip to content

Commit a235ce3

Browse files
workflows: add dashboard team management file
To add and remove members of the dashboard team we use a specific membership file, this way it's easier to onboarding and offboard new members. Github has a policy that only members of an organization can be part of a team, this change automates that invitation and team setup.
1 parent 6e6f959 commit a235ce3

6 files changed

Lines changed: 482 additions & 0 deletions

File tree

.github/dasboard-team

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
nuclearcat
2+
victor-accarini
3+
alanpeixinho
4+
gustavobtflores

.github/scripts/package-lock.json

Lines changed: 186 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/scripts/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "automation-scripts",
3+
"private": true,
4+
"type": "commonjs",
5+
"dependencies": {
6+
"@octokit/rest": "22.0.1"
7+
}
8+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require("fs");
4+
const { Octokit } = require("@octokit/rest");
5+
6+
const octokit = new Octokit({
7+
auth: process.env.GITHUB_TOKEN,
8+
});
9+
10+
const ORG = process.env.ORG;
11+
const TEAM_SLUG = process.env.TEAM_SLUG;
12+
const DRY_RUN = process.env.DRY_RUN === "true";
13+
14+
const TEAM_FILE = ".github/dashboard-team";
15+
16+
const PROTECTED_USERS = ["nuclearcat", "victor-accarini"];
17+
18+
const USER_RE = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
19+
20+
function validateEnv() {
21+
const required = ["ORG", "TEAM_SLUG"];
22+
23+
if (!DRY_RUN) {
24+
required.push("GITHUB_TOKEN");
25+
}
26+
27+
const missing = required.filter((v) => !process.env[v]);
28+
29+
if (missing.length > 0) {
30+
throw new Error(`Missing required env vars: ${missing.join(", ")}`);
31+
}
32+
}
33+
34+
function readDesiredUsers() {
35+
if (!fs.existsSync(TEAM_FILE)) {
36+
throw new Error(`Team file not found: ${TEAM_FILE}`);
37+
}
38+
39+
const content = fs.readFileSync(TEAM_FILE, "utf8");
40+
41+
const users = [
42+
...new Set(
43+
content
44+
.split("\n")
45+
.map((line) => line.trim().toLowerCase())
46+
.filter((line) => line && !line.startsWith("#")),
47+
),
48+
];
49+
50+
const invalidUsers = users.some((u) => !USER_RE.test(u));
51+
if (invalidUsers) {
52+
throw new Error(
53+
`Found an invalid GitHub username in array: [${users.join(", ")}]`,
54+
);
55+
}
56+
57+
return users;
58+
}
59+
60+
async function getCurrentTeamMembers() {
61+
const members = await octokit.paginate(octokit.teams.listMembersInOrg, {
62+
org: ORG,
63+
team_slug: TEAM_SLUG,
64+
per_page: 100,
65+
});
66+
67+
return members.map((m) => m.login.toLowerCase());
68+
}
69+
70+
async function ensureOrgMembership(username) {
71+
try {
72+
const membership = await octokit.orgs.getMembershipForUser({
73+
org: ORG,
74+
username,
75+
});
76+
77+
const state = membership.data.state;
78+
79+
if (state === "active") {
80+
console.log(`${username} is already an active org member`);
81+
return "active";
82+
}
83+
84+
if (state === "pending") {
85+
console.log(`${username} already has a pending org invitation`);
86+
return "pending";
87+
}
88+
89+
throw new Error(`Unexpected membership state for ${username}: ${state}`);
90+
} catch (err) {
91+
if (err.status === 404) {
92+
if (DRY_RUN) {
93+
console.log(`[DRY RUN] Would invite ${username} to org`);
94+
return "invited";
95+
}
96+
97+
console.log(`Inviting ${username} to org`);
98+
99+
await octokit.orgs.setMembershipForUser({
100+
org: ORG,
101+
username,
102+
role: "direct_member",
103+
});
104+
105+
return "invited";
106+
}
107+
108+
throw err;
109+
}
110+
}
111+
112+
async function addToTeam(username) {
113+
if (DRY_RUN) {
114+
console.log(`[DRY RUN] Would add ${username} to ${TEAM_SLUG}`);
115+
return;
116+
}
117+
118+
console.log(`Adding ${username} to ${TEAM_SLUG}`);
119+
120+
await octokit.teams.addOrUpdateMembershipForUserInOrg({
121+
org: ORG,
122+
team_slug: TEAM_SLUG,
123+
username,
124+
role: "member",
125+
});
126+
}
127+
128+
async function removeFromTeam(username) {
129+
if (PROTECTED_USERS.includes(username)) {
130+
console.log(`Skipping protected user removal: ${username}`);
131+
return;
132+
}
133+
134+
if (DRY_RUN) {
135+
console.log(`[DRY RUN] Would remove ${username} from ${TEAM_SLUG}`);
136+
return;
137+
}
138+
139+
console.log(`Removing ${username} from ${TEAM_SLUG}`);
140+
141+
await octokit.teams.removeMembershipForUserInOrg({
142+
org: ORG,
143+
team_slug: TEAM_SLUG,
144+
username,
145+
});
146+
}
147+
148+
async function main() {
149+
validateEnv();
150+
151+
const desiredUsers = readDesiredUsers();
152+
const currentUsers = await getCurrentTeamMembers();
153+
154+
const usersToAdd = desiredUsers.filter((u) => !currentUsers.includes(u));
155+
const usersToRemove = currentUsers.filter((u) => !desiredUsers.includes(u));
156+
157+
console.log("");
158+
console.log("=== Dashboard Team Sync ===");
159+
console.log("");
160+
161+
console.log(`Desired users (${desiredUsers.length}):`);
162+
desiredUsers.forEach((u) => console.log(` - ${u}`));
163+
164+
console.log("");
165+
166+
console.log(`Current users (${currentUsers.length}):`);
167+
currentUsers.forEach((u) => console.log(` - ${u}`));
168+
169+
console.log("");
170+
171+
for (const username of usersToAdd) {
172+
const membershipState = await ensureOrgMembership(username);
173+
174+
if (membershipState !== "active") {
175+
console.log(`${username} is not yet an active org member`);
176+
177+
continue;
178+
}
179+
180+
await addToTeam(username);
181+
}
182+
183+
for (const username of usersToRemove) {
184+
await removeFromTeam(username);
185+
}
186+
187+
console.log("");
188+
console.log("Sync complete");
189+
}
190+
191+
main().catch((err) => {
192+
console.error("");
193+
console.error("Sync failed");
194+
console.error(err);
195+
196+
process.exit(1);
197+
});

0 commit comments

Comments
 (0)