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 2026-07-02 11:46:22 +02:00 committed by GitHub
parent 9cb3140ea1
commit b3d5ead59f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 940 additions and 47 deletions

View file

@ -155,11 +155,17 @@ def compute_size_bucket(total_changes):
def compute_contributor(pr_labels):
"""Return the Contributor value derived from the PR's labels.
The auto-labeler is responsible for applying `community champion` and
`first contribution` based on the author's history; anything else on
the board is treated as a returning contributor (PRs from staff/bots
The auto-labeler is responsible for applying `guild`, `community champion`
and `first contribution` based on the author's membership/history; anything
else on the board is treated as a returning contributor (PRs from staff/bots
don't reach this function because they're filtered upstream).
Guild takes precedence over Champion because current-cohort guild members
are frequently also community champions, and their guild status is the more
relevant signal while the cohort is active.
"""
if "guild" in pr_labels:
return "Guild"
if "community champion" in pr_labels:
return "Champion"
if "first contribution" in pr_labels:

View file

@ -0,0 +1,634 @@
#!/usr/bin/env python3
"""
Automation for the Guild project board (#74).
GUILD_MODE selects behavior:
- event: react to a single issue webhook.
assigned guild member -> Status "In Progress", or a Slack heads-up if
the issue isn't on the board.
unassigned guild member off an "In Progress" issue -> move back to a
To-Do column by Type (e.g. Bugs go back to Bug Bashers column) + Slack.
created (issue_comment) guild assignee comments on an issue that has a
pending check-in -> Slack (fires on each such comment).
- stale: nudge issues assigned to a guild member with no linked PR once the
assignee goes quiet; a reply resets the clock, so nudges recur after renewed
silence, and the assignment is cleared after a further grace period without a
reply. The "guild hold" label pauses both nudging and clearing.
- weekly: Slack digest of board issues recently closed by a merged PR authored
by a guild member.
Requires: requests
"""
import html
import json
import os
import random
import time
from datetime import datetime, timedelta, timezone
from functools import lru_cache
import requests
RETRYABLE_STATUS_CODES = {502, 503, 504}
MAX_RETRIES = 3
RETRY_DELAY_SECONDS = 5
GITHUB_API_URL = "https://api.github.com"
REPO_OWNER = "zed-industries"
REPO_NAME = "zed"
GUILD_TEAM_SLUG = "guild-cohort-2"
STATUS_FIELD = "Status"
STATUS_IN_PROGRESS = "In Progress"
STATUS_BUG_BASHERS = "Bug Bashers"
STATUS_SHIP_FEATURE = "Ship a New Feature"
# Which to-do column to move issues of a given type to.
STATUS_FOR_TYPE = {
"Feature": STATUS_SHIP_FEATURE,
"Bug": STATUS_BUG_BASHERS,
"Docs": STATUS_BUG_BASHERS,
"Crash": STATUS_BUG_BASHERS,
}
# How many days to wait after assignment before posting a check-in comment.
CHECK_IN_AFTER_DAYS = 14
# Clear the assignment after this many days of no reply to check-in.
AUTO_CLEAR_AFTER_DAYS = 7
# For the slack summaries of what's been recently shipped by the Guild.
SHIPPED_WINDOW_DAYS = 7
# Applying this label to an issue pauses the stale sweep for it (no nudges, no
# auto-clear of the assignee) until a human removes the label.
NUDGE_HOLD_LABEL = "guild hold"
# Hidden in the rendered issue; lets later runs find the bot's own check-in.
CHECK_IN_MARKER = "<!-- zedgar:guild-check-in -->"
CHECK_IN_BODY = (
"{marker}\n"
"Hey @{assignee}, checking in to see if you're still actively working on "
"this issue. No worries if you no longer have the time. If that's the case, "
"do you mind unassigning yourself from the issue so another contributor can "
"work on it? If you are still working on it, drop a comment and let us know "
"how we can help! Otherwise, the bot will clear the assignment in "
"{clear_days} days from now."
)
ZEDGAR_QUIPS = [
"Deep into the backlog peering...",
"Once upon a board so dreary...",
"A tell-tale ping beneath the board.",
]
def github_graphql(query, variables):
for attempt in range(MAX_RETRIES + 1):
response = requests.post(
f"{GITHUB_API_URL}/graphql",
headers=GITHUB_HEADERS,
json={"query": query, "variables": variables},
timeout=30,
)
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
time.sleep(RETRY_DELAY_SECONDS)
continue
response.raise_for_status()
result = response.json()
if "errors" in result:
raise RuntimeError(f"GraphQL error: {result['errors']}")
return result["data"]
raise RuntimeError("github_graphql: retry loop exited without return")
def github_rest_request(method, path, body=None):
url = f"{GITHUB_API_URL}/{path}"
for attempt in range(MAX_RETRIES + 1):
response = requests.request(
method, url, headers=GITHUB_HEADERS, json=body, timeout=30
)
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
time.sleep(RETRY_DELAY_SECONDS)
continue
response.raise_for_status()
if response.status_code == 204 or not response.content:
return None
return response.json()
raise RuntimeError("github_rest_request: retry loop exited without return")
def github_rest_get_paginated(path):
results = []
page = 1
while True:
separator = "&" if "?" in path else "?"
batch = github_rest_request("GET", f"{path}{separator}per_page=100&page={page}")
if not batch:
break
results.extend(batch)
if len(batch) < 100:
break
page += 1
return results
@lru_cache(maxsize=None)
def is_guild_member(username):
response = requests.get(
f"{GITHUB_API_URL}/orgs/{REPO_OWNER}/teams/{GUILD_TEAM_SLUG}/memberships/{username}",
headers=GITHUB_HEADERS,
timeout=30,
)
if response.status_code == 404:
return False
response.raise_for_status()
# A pending invitation reports state "pending"; only active members count.
return response.json().get("state") == "active"
def issue_comments(issue_number):
return github_rest_get_paginated(
f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments"
)
def latest_assignment_time(issue_number, assignee):
events = github_rest_get_paginated(
f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/timeline"
)
times = [
parse_dt(event["created_at"])
for event in events
if event.get("event") == "assigned"
and (event.get("assignee") or {}).get("login") == assignee
]
if not times:
raise RuntimeError(
f"No assignment event for {assignee} on issue #{issue_number}"
)
return max(times)
def issue_closing_prs(issue_node_id, include_closed_prs=False):
# Caps at the first 20 closing PRs: callers only test presence or scan for a
# single guild-authored merge, so an issue exceeding this bound is not worth
# paginating.
data = github_graphql(
"""
query($issueId: ID!, $includeClosedPrs: Boolean!) {
node(id: $issueId) {
... on Issue {
closedByPullRequestsReferences(first: 20, includeClosedPrs: $includeClosedPrs) {
nodes { merged author { login } }
}
}
}
}
""",
{"issueId": issue_node_id, "includeClosedPrs": include_closed_prs},
)
node = data.get("node") or {}
refs = node.get("closedByPullRequestsReferences") or {}
return [
{"merged": pr["merged"], "author": (pr.get("author") or {}).get("login")}
for pr in refs.get("nodes", [])
]
def fetch_project(project_number):
data = github_graphql(
"""
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
fields(first: 50) {
nodes {
... on ProjectV2Field { id name dataType }
... on ProjectV2SingleSelectField { id name options { id name } }
}
}
}
}
}
""",
{"owner": REPO_OWNER, "number": project_number},
)
project = data["organization"]["projectV2"]
if not project:
raise RuntimeError(f"Project #{project_number} not found in {REPO_OWNER}")
return project
def find_project_item(project_id, content_node_id):
# Also fetches each item's single-select values, so callers that need the
# issue's Status (the unassignment handler) don't have to re-query the item.
data = github_graphql(
"""
query($contentId: ID!) {
node(id: $contentId) {
... on Issue {
projectItems(first: 50) {
nodes {
id
project { id }
fieldValues(first: 20) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
field { ... on ProjectV2SingleSelectField { name } }
name
}
}
}
}
}
}
}
}
""",
{"contentId": content_node_id},
)
node = data.get("node") or {}
for item in (node.get("projectItems") or {}).get("nodes", []):
if item["project"]["id"] == project_id:
return item
return None
def set_project_field(project, item_id, field_name, option_name):
field = next(
(f for f in project["fields"]["nodes"] if f.get("name") == field_name), None
)
if not field:
raise RuntimeError(f"Field '{field_name}' not found on board")
option_id = next(
(o["id"] for o in field.get("options", []) if o["name"] == option_name), None
)
if not option_id:
raise RuntimeError(f"Option '{option_name}' not found in field '{field_name}'")
github_graphql(
"""
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId, itemId: $itemId, fieldId: $fieldId,
value: { singleSelectOptionId: $optionId }
}) { projectV2Item { id } }
}
""",
{
"projectId": project["id"],
"itemId": item_id,
"fieldId": field["id"],
"optionId": option_id,
},
)
def list_project_issues(project_id):
cursor = None
while True:
data = github_graphql(
"""
query($projectId: ID!, $cursor: String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
isArchived
fieldValues(first: 20) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
field { ... on ProjectV2SingleSelectField { name } }
name
}
}
}
content {
__typename
... on Issue {
id number title url state closedAt
issueType { name }
assignees(first: 10) { nodes { login } }
labels(first: 20) { nodes { name } }
}
}
}
}
}
}
}
""",
{"projectId": project_id, "cursor": cursor},
)
page = data["node"]["items"]
yield from page["nodes"]
if not page["pageInfo"]["hasNextPage"]:
return
cursor = page["pageInfo"]["endCursor"]
def post_comment(issue_number, body):
github_rest_request(
"POST", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments", {"body": body}
)
def remove_assignees(issue_number, assignees):
github_rest_request(
"DELETE",
f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/assignees",
{"assignees": assignees},
)
def escape_slack(text):
# Escape Slack's control characters (&, <, >) so free text like issue/PR
# titles and comment bodies renders literally instead of being interpreted
# as a link or @-mention. send_slack can't do this wholesale because its own
# messages legitimately contain <url|text> links and *bold* markup.
# quote=False keeps it to Slack's three characters (no " or ' escaping).
return html.escape(text or "", quote=False)
def slack_link(url, text):
# The single way to embed a titled link, so the title is always escaped
# without every caller having to remember to do it.
return f"<{url}|{escape_slack(text)}>"
def send_slack(text):
webhook = os.environ.get("SLACK_WEBHOOK_GUILD_INTERNAL")
if not webhook:
raise RuntimeError("SLACK_WEBHOOK_GUILD_INTERNAL is not set")
message = f"{random.choice(ZEDGAR_QUIPS)} {text}"
response = requests.post(
webhook,
json={
"text": message,
"blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": message}}],
},
timeout=30,
)
if response.status_code != 200:
raise RuntimeError(
f"Slack webhook returned {response.status_code}: {response.text[:200]}"
)
def parse_dt(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def item_status(item):
for field_value in (item.get("fieldValues") or {}).get("nodes", []):
if field_value.get("field", {}).get("name") == STATUS_FIELD:
return field_value.get("name")
return None
def latest_check_in_time(comments):
times = [
parse_dt(comment["created_at"])
for comment in comments
if CHECK_IN_MARKER in (comment.get("body") or "")
]
return max(times, default=None)
def handle_assignment(issue, assignee_login, project_number):
if not is_guild_member(assignee_login):
return
project = fetch_project(project_number)
item = find_project_item(project["id"], issue["node_id"])
if not item:
send_slack(
f"Heads-up: @{assignee_login} was assigned to issue #{issue['number']} "
f"{slack_link(issue['html_url'], issue['title'])}, which isn't on the Guild board. "
"Possible mistake — if it's meant to be Guild work, add it to Bug Bashers "
"or Ship a New Feature; otherwise no action needed."
)
return
set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS)
def handle_unassignment(issue, removed_login, sender, project_number):
# Our own auto-clear also unassigns; don't double-handle that.
if (sender or {}).get("type") == "Bot":
return
if not is_guild_member(removed_login):
return
project = fetch_project(project_number)
item = find_project_item(project["id"], issue["node_id"])
if not item or item_status(item) != STATUS_IN_PROGRESS:
return
sender_login = (sender or {}).get("login")
who = (
"unassigned themselves from"
if sender_login == removed_login
else f"was unassigned (by @{sender_login}) from"
)
status = STATUS_FOR_TYPE.get((issue.get("type") or {}).get("name") or "")
link = slack_link(issue["html_url"], issue["title"])
if status:
set_project_field(project, item["id"], STATUS_FIELD, status)
send_slack(
f"@{removed_login} {who} In Progress issue #{issue['number']} "
f"{link}. I moved it back to *{status}*, "
"so it's up for grabs again — no action needed."
)
else:
send_slack(
f"@{removed_login} {who} In Progress issue #{issue['number']} "
f"{link}. I couldn't tell its Type, so it's "
"still In Progress — please move it to Bug Bashers or Ship a New Feature so it "
"can be picked up again, then set the :done-checkmark: emoji on this message when done."
)
def handle_comment(issue, comment):
commenter = (comment.get("user") or {}).get("login")
if not commenter or CHECK_IN_MARKER in (comment.get("body") or ""):
return
assignees = [a["login"] for a in (issue.get("assignees") or [])]
if commenter not in assignees or not is_guild_member(commenter):
return
check_in_at = latest_check_in_time(issue_comments(issue["number"]))
if check_in_at is None or parse_dt(comment["created_at"]) <= check_in_at:
return
reply = " ".join((comment.get("body") or "").split())
if len(reply) > 500:
reply = reply[:500] + ""
send_slack(
f"@{commenter} replied to the check-in on In Progress issue #{issue['number']} "
f"{slack_link(issue['html_url'], issue['title'])}:\n"
f"> {escape_slack(reply)}\n"
f"Follow up if they need help, or add the `{NUDGE_HOLD_LABEL}` label to the "
"issue to pause check-ins on it."
)
def run_event(project_number):
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
with open(os.environ["GITHUB_EVENT_PATH"]) as f:
event = json.load(f)
if event_name == "issues":
action = event["action"]
issue = event["issue"]
if action == "assigned":
handle_assignment(issue, event["assignee"]["login"], project_number)
elif action == "unassigned":
handle_unassignment(
issue, event["assignee"]["login"], event.get("sender"), project_number
)
elif event_name == "issue_comment" and "pull_request" not in event["issue"]:
handle_comment(event["issue"], event["comment"])
def run_stale(project_number):
project = fetch_project(project_number)
checked_in = cleared = 0
for item in list_project_issues(project["id"]):
if item.get("isArchived"):
continue
content = item.get("content") or {}
if content.get("__typename") != "Issue":
continue
if item_status(item) != STATUS_IN_PROGRESS:
continue
assignees = [a["login"] for a in content["assignees"]["nodes"]]
guild_assignees = [a for a in assignees if is_guild_member(a)]
if not guild_assignees:
continue
labels = [label["name"] for label in (content.get("labels") or {}).get("nodes", [])]
if NUDGE_HOLD_LABEL in labels:
continue
issue_number = content["number"]
if issue_closing_prs(content["id"]):
continue
comments = issue_comments(issue_number)
assignee = guild_assignees[0]
# One pass over the comments for both the latest check-in and the
# assignee's latest comment. Measuring from the assignee's last activity
# (their assignment or a later comment) means a reply resets the clock, so
# a nudge only recurs after renewed silence (and a check-in from a prior
# assignment stint predates this, so it is naturally ignored).
last_activity = latest_assignment_time(issue_number, assignee)
check_in_at = None
for comment in comments:
created_at = parse_dt(comment["created_at"])
if CHECK_IN_MARKER in (comment.get("body") or ""):
if check_in_at is None or created_at > check_in_at:
check_in_at = created_at
elif (comment.get("user") or {}).get("login") == assignee:
if created_at > last_activity:
last_activity = created_at
if check_in_at is None or check_in_at < last_activity:
# No outstanding nudge since the assignee last engaged; nudge once
# they have stayed quiet for the check-in window.
if (NOW - last_activity).days < CHECK_IN_AFTER_DAYS:
continue
post_comment(
issue_number,
CHECK_IN_BODY.format(
marker=CHECK_IN_MARKER,
assignee=assignee,
clear_days=AUTO_CLEAR_AFTER_DAYS,
),
)
checked_in += 1
continue
# We nudged and the assignee stayed quiet; clear once the grace period
# lapses, otherwise leave them the rest of it.
if (NOW - check_in_at).days < AUTO_CLEAR_AFTER_DAYS:
continue
remove_assignees(issue_number, guild_assignees)
status = STATUS_FOR_TYPE.get((content.get("issueType") or {}).get("name") or "")
link = slack_link(content["url"], content["title"])
if status:
set_project_field(project, item["id"], STATUS_FIELD, status)
send_slack(
f"Issue #{issue_number} {link} went quiet with @{assignee} "
"(no linked PR, no reply to my check-in), so I cleared the assignment and "
f"moved it back to *{status}*. It's up for grabs again — no action needed."
)
else:
send_slack(
f"Issue #{issue_number} {link} went quiet with @{assignee} "
"(no linked PR, no reply to my check-in), so I cleared the assignment. I "
"couldn't tell its Type — please move it to Bug Bashers or Ship a New "
"Feature so it can be picked up again, then set the :done-checkmark: emoji "
"on this message when done."
)
cleared += 1
print(f"Stale sweep: {checked_in} checked in, {cleared} cleared")
def run_weekly(project_number):
project = fetch_project(project_number)
cutoff = NOW - timedelta(days=SHIPPED_WINDOW_DAYS)
shipped = []
for item in list_project_issues(project["id"]):
content = item.get("content") or {}
if content.get("__typename") != "Issue" or content.get("state") != "CLOSED":
continue
if parse_dt(content["closedAt"]) < cutoff:
continue
for pr in issue_closing_prs(content["id"], include_closed_prs=True):
if pr["merged"] and pr["author"] and is_guild_member(pr["author"]):
shipped.append((pr["author"], content))
break
if not shipped:
send_slack("A quiet week on the Guild board — nothing shipped. Onward.")
return
lines = "\n".join(
f"• @{author}{slack_link(content['url'], content['title'])} (#{content['number']})"
for author, content in shipped
)
send_slack(f"Here's what the Guild shipped this week:\n{lines}")
if __name__ == "__main__":
GITHUB_HEADERS = {
"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
NOW = datetime.now(timezone.utc)
project_number = int(os.environ["PROJECT_NUMBER"])
mode = os.environ["GUILD_MODE"]
if mode == "event":
run_event(project_number)
elif mode == "stale":
run_stale(project_number)
elif mode == "weekly":
run_weekly(project_number)
else:
raise RuntimeError(f"Unknown GUILD_MODE: {mode}")