Add Guild board automation (#60266)

Automates the Guild contributor program on project board #74 so the
cohort is recognized and kept unblocked without manual babysitting.

- Move a board issue to In Progress when a Guild member self-assigns it,
and post a friendly heads-up when they are assigned an issue that is not
on the board.
- Flag when a Guild member opens a new PR while another of theirs is
still open, to help them land work before spreading thin.
- Check in on quiet assignments and, if an assignee stays silent, free
the issue back to a to-do column so others can pick it up; a "guild
hold" label lets maintainers pause check-ins after they have followed
up.
- Share a weekly digest of what the Guild shipped.
- Label PRs from Guild members and recognize a Guild contributor tier on
the community PR board.

Release Notes:

- N/A
This commit is contained in:
Lena authored and GitHub committed 2026-07-02 09:46:22 +00:00
1 parent 9cb3140ea1
commit b3d5ead59f
8 files changed
+940 -47

No files matched your search

@@ -0,0 +1,59 @@
# Guild board (https://github.com/orgs/zed-industries/projects/74) reactions to issue events:
# assigned guild member -> Status "In Progress" (or Slack if off-board)
# unassigned guild member -> move back to a To-Do column by Type + Slack
# commented guild assignee comments after a check-in -> Slack (each comment)
name: Guild Assignment Status
on:
issues:
types: [assigned, unassigned]
issue_comment:
types: [created]
permissions:
contents: read
concurrency:
group: guild-assignment-status-${{ github.event.issue.number || github.run_id }}
cancel-in-progress: false
jobs:
handle-event:
if: >-
github.repository == 'zed-industries/zed' &&
(github.event_name != 'issue_comment' || github.event.issue.pull_request == null)
runs-on: namespace-profile-2x4-ubuntu-2404
timeout-minutes: 5
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}
owner: zed-industries
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
sparse-checkout: |
script/github-guild-board.py
sparse-checkout-cone-mode: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Install dependencies
run: pip install requests
- name: Handle issue event
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
PROJECT_NUMBER: "74"
GUILD_MODE: event
SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }}
run: python script/github-guild-board.py
+115
View File
@@ -0,0 +1,115 @@
# When a guild member opens a PR while already having another open PR in
# zed-industries/zed, post a heads-up to #zed-guild-internal. PRs opened before
# CUTOFF_DATE (the cohort start) don't count as "already open".
name: Guild New PR Notification
on:
pull_request_target:
types: [opened]
permissions:
contents: read
concurrency:
group: guild-new-pr-notify-${{ github.event.pull_request.number }}
cancel-in-progress: false
env:
CUTOFF_DATE: "2026-07-01"
jobs:
notify-slack:
if: github.repository == 'zed-industries/zed'
runs-on: namespace-profile-2x4-ubuntu-2404
timeout-minutes: 5
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}
owner: zed-industries
- name: Notify on an additional open PR by a guild member
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const GUILD_TEAM_SLUG = 'guild-cohort-2';
const pr = context.payload.pull_request;
const author = pr.user.login;
const isGuildMember = async (username) => {
try {
const response = await github.rest.teams.getMembershipForUserInOrg({
org: 'zed-industries',
team_slug: GUILD_TEAM_SLUG,
username
});
return response.data.state === 'active';
} catch (error) {
if (error.status === 404) {
return false;
}
throw error;
}
};
if (!(await isGuildMember(author))) {
console.log(`${author} is not a guild member, skipping`);
return;
}
const cutoff = process.env.CUTOFF_DATE;
const query = `repo:zed-industries/zed is:pr is:open author:${author} created:>=${cutoff}`;
const result = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 100 });
const others = result.data.items.filter((item) => item.number !== pr.number);
if (others.length === 0) {
console.log(`${author} has no other qualifying open PRs, skipping`);
return;
}
const webhook = process.env.SLACK_WEBHOOK_GUILD_INTERNAL;
if (!webhook) {
core.setFailed('SLACK_WEBHOOK_GUILD_INTERNAL secret is not set');
return;
}
// Escape Slack's control characters so PR titles render literally
// instead of being read as links or @-mentions.
const esc = (text) =>
(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const quips = [
'Deep into the queue they wander.',
'Nevermore just one open PR.',
];
const quip = quips[Math.floor(Math.random() * quips.length)];
const otherList = others
.map((item) => `<${item.html_url}|#${item.number}> ${esc(item.title)}`)
.join('\n• ');
const message =
`${quip} @${author} just opened <${pr.html_url}|${esc(pr.title)}> ` +
`while still having open PR(s):\n• ${otherList}\n` +
'A gentle nudge to help them land one before spreading thin might be in order.';
const response = await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `${author} opened another PR while others are still open`,
blocks: [{ type: 'section', text: { type: 'mrkdwn', text: message } }],
}),
});
if (!response.ok) {
const body = await response.text();
core.setFailed(`Slack notification failed with status ${response.status}: ${body}`);
return;
}
console.log(`Notified: ${author} has ${others.length} other open PR(s)`);
@@ -0,0 +1,57 @@
# Scheduled sweep of the Guild board (https://github.com/orgs/zed-industries/projects/74).
# For an "In Progress" issue assigned to a guild member with no linked PR: post a
# check-in comment once the assignee goes quiet, re-nudging after any renewed
# silence, and clear the assignment (moving the issue back to a To-Do column by
# Type) if a check-in goes unanswered. The "guild hold" label pauses the sweep.
name: Guild Stale Assignments
on:
schedule:
- cron: "0 8 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: guild-stale-assignments
cancel-in-progress: true
jobs:
sweep:
if: github.repository == 'zed-industries/zed'
runs-on: namespace-profile-2x4-ubuntu-2404
timeout-minutes: 15
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}
owner: zed-industries
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
sparse-checkout: |
script/github-guild-board.py
sparse-checkout-cone-mode: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Install dependencies
run: pip install requests
- name: Sweep stale assignments
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
PROJECT_NUMBER: "74"
GUILD_MODE: stale
SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }}
run: python script/github-guild-board.py
@@ -0,0 +1,55 @@
# Scheduled Slack digest of Guild board
# (https://github.com/orgs/zed-industries/projects/74) issues recently closed by
# a merged PR authored by a guild member.
name: Guild Weekly Shipped
on:
schedule:
- cron: "0 7 * * 3"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: guild-weekly-shipped
cancel-in-progress: true
jobs:
digest:
if: github.repository == 'zed-industries/zed'
runs-on: namespace-profile-2x4-ubuntu-2404
timeout-minutes: 15
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}
owner: zed-industries
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
sparse-checkout: |
script/github-guild-board.py
sparse-checkout-cone-mode: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Install dependencies
run: pip install requests
- name: Build and send digest
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
PROJECT_NUMBER: "74"
GUILD_MODE: weekly
SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }}
run: python script/github-guild-board.py
+7 -44
View File
@@ -41,47 +41,7 @@ jobs:
const STAFF_TEAM_SLUG = 'staff';
const FIRST_CONTRIBUTION_LABEL = 'first contribution';
const GUILD_LABEL = 'guild';
const GUILD_MEMBERS = [
'11happy',
'AidanV',
'alanpjohn',
'AmaanBilwar',
'arjunkomath',
'austincummings',
'ayushk-1801',
'criticic',
'dongdong867',
'emamulandalib',
'eureka928',
'feitreim',
'iam-liam',
'iksuddle',
'ishaksebsib',
'lingyaochu',
'loadingalias',
'marcocondrache',
'mchisolm0',
'MostlyKIGuess',
'nairadithya',
'nihalxkumar',
'notJoon',
'OmChillure',
'Palanikannan1437',
'polyesterswing',
'prayanshchh',
'razeghi71',
'sarmadgulzar',
'seanstrom',
'Shivansh-25',
'SkandaBhat',
'th0jensen',
'tommyming',
'transitoryangel',
'TwistingTwists',
'virajbhartiya',
'YEDASAVG',
'Ziqi-Yang',
];
const GUILD_TEAM_SLUG = 'guild-cohort-2';
const COMMUNITY_CHAMPION_LABEL = 'community champion';
const COMMUNITY_CHAMPIONS = [
'0x2CA',
@@ -161,11 +121,11 @@ jobs:
return members.some((member) => member.toLowerCase() === authorLower);
};
const isStaffMember = async (author) => {
const isTeamMember = async (teamSlug, author) => {
try {
const response = await github.rest.teams.getMembershipForUserInOrg({
org: 'zed-industries',
team_slug: STAFF_TEAM_SLUG,
team_slug: teamSlug,
username: author
});
return response.data.state === 'active';
@@ -177,6 +137,9 @@ jobs:
}
};
const isStaffMember = (author) => isTeamMember(STAFF_TEAM_SLUG, author);
const isGuildMember = (author) => isTeamMember(GUILD_TEAM_SLUG, author);
const getIssueLabels = () => {
if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) {
return [COMMUNITY_CHAMPION_LABEL];
@@ -202,7 +165,7 @@ jobs:
labelsToAdd.push(COMMUNITY_CHAMPION_LABEL);
}
if (listIncludesAuthor(GUILD_MEMBERS, author)) {
if (await isGuildMember(author)) {
labelsToAdd.push(GUILD_LABEL);
}
@@ -12,6 +12,10 @@ on:
- "Community PR Board"
- "PR Board Meta Fields Refresh"
- "PR Issue Labeler"
- "Guild Assignment Status"
- "Guild Stale Assignments"
- "Guild Weekly Shipped"
- "Guild New PR Notification"
types: [completed]
jobs: