mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-23 12:37:09 +00:00
This is mostly to enable testing the github action with the right secrets — the code is atrocious for now. Release notes: - N/A
421 lines
13 KiB
Python
421 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Assign a labeled contributor issue to the least-busy interested contributor.
|
|
|
|
When an issue has both a `.contrib/good *` label and an `area:` label, this
|
|
script:
|
|
1. Fetches Tally form responses to find contributors interested in the issue's areas
|
|
2. Queries GitHub for each candidate's current open issue assignment count
|
|
3. Assigns the issue to the least-busy candidate (random tiebreak)
|
|
4. Adds the issue to a GitHub project board with "Assign" status
|
|
5. Notifies the assignee via Slack DM and posts to an activity channel
|
|
|
|
Errors and notable conditions (no candidates found, API failures) are reported
|
|
to the Slack activity channel before the script exits.
|
|
|
|
Requires:
|
|
requests (pip install requests)
|
|
|
|
Usage:
|
|
python github-assign-contributor-issue.py <issue_number>
|
|
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import random
|
|
import sys
|
|
|
|
import requests
|
|
|
|
GITHUB_API = "https://api.github.com"
|
|
TALLY_API = "https://api.tally.so"
|
|
SLACK_API = "https://slack.com/api"
|
|
|
|
REPO_OWNER = "zed-industries"
|
|
REPO_NAME = "zed"
|
|
PROJECT_NUMBER = 83
|
|
SLACK_ACTIVITY_CHANNEL_ID = "C0B0JCE8GDC"
|
|
|
|
|
|
def eligible_areas(issue):
|
|
"""Returns the list of area names if the issue is eligible for assignment, or None."""
|
|
labels = [label["name"] for label in issue["labels"]]
|
|
assignees = [a["login"] for a in issue["assignees"]]
|
|
|
|
contrib_labels = [name for name in labels if name.startswith(".contrib/good ")]
|
|
area_labels = [name for name in labels if name.startswith("area:")]
|
|
|
|
if not contrib_labels or not area_labels:
|
|
print("Issue needs both a .contrib/good * label and an area: label, skipping")
|
|
return None
|
|
|
|
if assignees:
|
|
print(f"Issue is already assigned to {assignees}, skipping")
|
|
return None
|
|
|
|
areas = [label.removeprefix("area:") for label in area_labels]
|
|
print(f"Areas: {areas}")
|
|
return areas
|
|
|
|
|
|
# --- Tally ---
|
|
|
|
|
|
def fetch_tally_contributors(api_key, form_id):
|
|
"""Fetch all completed submissions from a Tally form.
|
|
|
|
Deduplicates by GitHub username, keeping the latest submission.
|
|
"""
|
|
headers = {"Authorization": f"Bearer {api_key}"}
|
|
contributors = {}
|
|
page = 1
|
|
|
|
while True:
|
|
response = requests.get(
|
|
f"{TALLY_API}/forms/{form_id}/submissions",
|
|
headers=headers,
|
|
params={"page": page, "limit": 500, "filter": "completed"},
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
field_titles = {}
|
|
for question in data.get("questions", []):
|
|
for field in question.get("fields", []):
|
|
field_titles[field["uuid"]] = field.get("title", "")
|
|
|
|
questions = {q["id"]: q for q in data.get("questions", [])}
|
|
|
|
for submission in data.get("submissions", []):
|
|
record = parse_submission(submission, questions, field_titles)
|
|
if record:
|
|
contributors[record["github_username"].lower()] = record
|
|
|
|
if not data.get("hasMore", False):
|
|
break
|
|
page += 1
|
|
|
|
return list(contributors.values())
|
|
|
|
|
|
def parse_submission(submission, questions, field_titles):
|
|
"""Parse a single Tally submission into a contributor record.
|
|
|
|
Returns a dict with github_username, email (optional), and areas,
|
|
or None if the submission is incomplete.
|
|
"""
|
|
github_username = None
|
|
email = None
|
|
areas = []
|
|
|
|
for response in submission.get("responses", []):
|
|
try:
|
|
question_title = questions[response["questionId"]]["title"].lower()
|
|
answer = response["answer"]
|
|
except KeyError:
|
|
continue
|
|
|
|
try:
|
|
if "github" in question_title:
|
|
github_username = str(answer).strip().lstrip("@")
|
|
elif "email" in question_title:
|
|
email = str(answer).strip().lower()
|
|
elif "area" in question_title:
|
|
for item in answer if isinstance(answer, list) else [answer]:
|
|
area = field_titles.get(item, item).strip()
|
|
if area:
|
|
areas.append(area)
|
|
except (TypeError, AttributeError):
|
|
continue
|
|
|
|
if not github_username or not areas:
|
|
return None
|
|
|
|
record = {"github_username": github_username, "areas": areas}
|
|
if email:
|
|
record["email"] = email
|
|
return record
|
|
|
|
|
|
def find_candidates(contributors, area_names):
|
|
"""Find contributors interested in any of the given areas (case-insensitive)."""
|
|
target = {name.lower() for name in area_names}
|
|
return [c for c in contributors if any(a.lower() in target for a in c["areas"])]
|
|
|
|
|
|
def pick_least_busy(github_headers, candidates):
|
|
"""Pick the candidate with the fewest open assignments (random tiebreak)."""
|
|
usernames = [c["github_username"] for c in candidates]
|
|
loads = count_open_assignments(github_headers, usernames)
|
|
for username, count in loads.items():
|
|
print(f" {username}: {count} open assignments")
|
|
|
|
min_load = min(loads.values())
|
|
least_busy = [c for c in candidates if loads[c["github_username"]] == min_load]
|
|
chosen = random.choice(least_busy)
|
|
print(
|
|
f"Selected: {chosen['github_username']} (load: {min_load}, {len(least_busy)} tied)"
|
|
)
|
|
return chosen
|
|
|
|
|
|
# --- GitHub ---
|
|
|
|
|
|
def fetch_issue(headers, issue_number):
|
|
"""Fetch issue details from the GitHub API."""
|
|
response = requests.get(
|
|
f"{GITHUB_API}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}",
|
|
headers=headers,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
|
|
def count_open_assignments(headers, usernames):
|
|
"""Count open issues assigned to each user in a single GraphQL request."""
|
|
aliases = [
|
|
f'u{i}: search(query: "repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open assignee:{name}", type: ISSUE) {{ issueCount }}'
|
|
for i, name in enumerate(usernames)
|
|
]
|
|
query = "query {\n" + "\n".join(aliases) + "\n}"
|
|
data = execute_graphql(headers, query, {})
|
|
return {name: data[f"u{i}"]["issueCount"] for i, name in enumerate(usernames)}
|
|
|
|
|
|
def assign_issue(headers, issue_number, username):
|
|
"""Assign a GitHub issue to a user."""
|
|
response = requests.post(
|
|
f"{GITHUB_API}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/assignees",
|
|
headers=headers,
|
|
json={"assignees": [username]},
|
|
)
|
|
response.raise_for_status()
|
|
|
|
|
|
def execute_graphql(headers, query, variables):
|
|
"""Execute a GitHub GraphQL query. Raises on HTTP or GraphQL errors."""
|
|
response = requests.post(
|
|
f"{GITHUB_API}/graphql",
|
|
headers=headers,
|
|
json={"query": query, "variables": variables},
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
if "errors" in result:
|
|
raise RuntimeError(f"GraphQL error: {result['errors']}")
|
|
return result["data"]
|
|
|
|
|
|
def fetch_project(headers, project_number):
|
|
"""Fetch a GitHub project board's metadata including fields and status options."""
|
|
data = execute_graphql(
|
|
headers,
|
|
"""
|
|
query($owner: String!, $number: Int!) {
|
|
organization(login: $owner) {
|
|
projectV2(number: $number) {
|
|
id
|
|
fields(first: 50) {
|
|
nodes {
|
|
... on ProjectV2SingleSelectField {
|
|
id
|
|
name
|
|
options { id name }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
{"owner": REPO_OWNER, "number": project_number},
|
|
)
|
|
return data["organization"]["projectV2"]
|
|
|
|
|
|
def add_issue_to_project(headers, project_id, issue_node_id):
|
|
"""Add an issue to a GitHub project board. Returns the project item ID."""
|
|
data = execute_graphql(
|
|
headers,
|
|
"""
|
|
mutation($projectId: ID!, $contentId: ID!) {
|
|
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
|
|
item { id }
|
|
}
|
|
}
|
|
""",
|
|
{"projectId": project_id, "contentId": issue_node_id},
|
|
)
|
|
item_id = data["addProjectV2ItemById"]["item"]["id"]
|
|
print(f"Added issue to project (item: {item_id})")
|
|
return item_id
|
|
|
|
|
|
def set_project_item_status(headers, project, item_id, status_name):
|
|
"""Set the Status field on a project item. Hard-fails if the status option is missing."""
|
|
status_field_id = None
|
|
option_id = None
|
|
for field in project["fields"]["nodes"]:
|
|
if field.get("name") == "Status":
|
|
status_field_id = field["id"]
|
|
for option in field.get("options", []):
|
|
if option["name"] == status_name:
|
|
option_id = option["id"]
|
|
break
|
|
break
|
|
|
|
if not status_field_id or not option_id:
|
|
available = [f.get("name") for f in project["fields"]["nodes"] if f.get("name")]
|
|
raise RuntimeError(
|
|
f"Could not find Status field with '{status_name}' option. "
|
|
f"Fields found: {available}"
|
|
)
|
|
|
|
execute_graphql(
|
|
headers,
|
|
"""
|
|
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": status_field_id,
|
|
"optionId": option_id,
|
|
},
|
|
)
|
|
print(f"Set project status to '{status_name}'")
|
|
|
|
|
|
# --- Slack ---
|
|
|
|
|
|
def slack_post_message(headers, recipient, text):
|
|
"""Post a message to a Slack channel or user DM."""
|
|
response = requests.post(
|
|
f"{SLACK_API}/chat.postMessage",
|
|
headers=headers,
|
|
json={"channel": recipient, "text": text},
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
if not data["ok"]:
|
|
raise RuntimeError(f"Slack API error: {data['error']}")
|
|
|
|
|
|
def find_slack_user_id(headers, email):
|
|
"""Look up a Slack user ID by email. Returns None if not found."""
|
|
try:
|
|
response = requests.get(
|
|
f"{SLACK_API}/users.lookupByEmail",
|
|
headers=headers,
|
|
params={"email": email},
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()["user"]["id"]
|
|
except (requests.RequestException, KeyError):
|
|
return None
|
|
|
|
|
|
def post_to_activity(slack_headers, message):
|
|
"""Best-effort post to the Slack activity channel."""
|
|
try:
|
|
slack_post_message(slack_headers, SLACK_ACTIVITY_CHANNEL_ID, message)
|
|
except Exception as exc:
|
|
print(f"Failed to post to Slack activity channel: {exc}")
|
|
|
|
|
|
def notify_assignment(slack_headers, chosen, issue):
|
|
"""DM the chosen contributor and post to the activity channel."""
|
|
issue_number = issue["number"]
|
|
issue_title = issue["title"]
|
|
issue_url = issue["html_url"]
|
|
chosen_username = chosen["github_username"]
|
|
|
|
slack_user_id = find_slack_user_id(slack_headers, chosen.get("email"))
|
|
|
|
if slack_user_id:
|
|
slack_post_message(
|
|
slack_headers,
|
|
slack_user_id,
|
|
f"\U0001f44b You've been assigned to <{issue_url}|#{issue_number}: {issue_title}>! "
|
|
f"This issue matches your areas of interest. "
|
|
f"Let us know if you have any questions.",
|
|
)
|
|
|
|
activity_message = (
|
|
f"\U0001f4cb <{issue_url}|#{issue_number}: {issue_title}> "
|
|
f"assigned to *{chosen_username}*"
|
|
)
|
|
if slack_user_id:
|
|
activity_message += f" (<@{slack_user_id}>)"
|
|
post_to_activity(slack_headers, activity_message)
|
|
|
|
|
|
# --- Main ---
|
|
|
|
|
|
if __name__ == "__main__":
|
|
issue_number = sys.argv[1]
|
|
|
|
github_token = os.environ["GITHUB_TOKEN"]
|
|
tally_api_key = os.environ["TALLY_API_KEY"]
|
|
tally_form_id = os.environ["TALLY_FORM_ID"]
|
|
slack_bot_token = os.environ["SLACK_CONTRIBUTOR_ROUTING_BOT_TOKEN"]
|
|
|
|
github_headers = {
|
|
"Authorization": f"Bearer {github_token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
slack_headers = {
|
|
"Authorization": f"Bearer {slack_bot_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
issue = fetch_issue(github_headers, issue_number)
|
|
if not (areas := eligible_areas(issue)):
|
|
sys.exit(0)
|
|
|
|
try:
|
|
contributors = fetch_tally_contributors(tally_api_key, tally_form_id)
|
|
print(f"Found {len(contributors)} contributors in Tally")
|
|
|
|
candidates = find_candidates(contributors, areas)
|
|
if not candidates:
|
|
post_to_activity(
|
|
slack_headers,
|
|
f"\u26a0\ufe0f No contributors found for {', '.join(areas)} \u2014 "
|
|
f"<{issue['html_url']}|#{issue_number}: {issue['title']}>",
|
|
)
|
|
print(f"No contributors interested in areas: {areas}")
|
|
sys.exit(0)
|
|
|
|
chosen = pick_least_busy(github_headers, candidates)
|
|
|
|
assign_issue(github_headers, issue_number, chosen["github_username"])
|
|
print(f"Assigned #{issue_number} to {chosen['github_username']}")
|
|
|
|
project = fetch_project(github_headers, PROJECT_NUMBER)
|
|
item_id = add_issue_to_project(github_headers, project["id"], issue["node_id"])
|
|
set_project_item_status(github_headers, project, item_id, "Assigned")
|
|
|
|
notify_assignment(slack_headers, chosen, issue)
|
|
|
|
except Exception as exc:
|
|
post_to_activity(
|
|
slack_headers,
|
|
f"\u274c Failed to assign contributor for "
|
|
f"<{issue['html_url']}|#{issue_number}: {issue['title']}>: {exc}",
|
|
)
|
|
raise
|