diff --git a/.github/workflows/assign_contributor_issue.yml b/.github/workflows/assign_contributor_issue.yml new file mode 100644 index 00000000000..5e968611299 --- /dev/null +++ b/.github/workflows/assign_contributor_issue.yml @@ -0,0 +1,70 @@ +# Assign Contributor Issue — auto-assign labeled contributor issues +# +# When an issue has both a `.contrib/good *` label and an `area:` label, +# finds the least-busy contributor interested in that area (via Tally form +# responses), assigns the issue, updates the project board, and notifies +# the contributor on Slack. +# +# Errors and "no candidates" conditions are reported to the Slack activity +# channel. + +name: Assign Contributor Issue + +on: + issues: + types: [labeled] + workflow_dispatch: + inputs: + issue_number: + description: "Issue number to test against" + required: true + type: number + +permissions: + contents: read + +concurrency: + group: assign-contributor-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: true + +jobs: + assign-contributor: + if: >- + github.event_name == 'workflow_dispatch' || + (github.repository == 'zed-industries/zed' && + github.event.issue.state == 'open' && + (startsWith(github.event.label.name, '.contrib/good ') || startsWith(github.event.label.name, 'area:'))) + 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-assign-contributor-issue.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: Assign contributor + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + TALLY_API_KEY: ${{ secrets.TALLY_API_KEY }} + TALLY_FORM_ID: ${{ vars.TALLY_CONTRIBUTOR_FORM_ID }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_CONTRIBUTOR_BOT_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }} + run: python script/github-assign-contributor-issue.py "$ISSUE_NUMBER" diff --git a/script/github-assign-contributor-issue.py b/script/github-assign-contributor-issue.py new file mode 100644 index 00000000000..5b48b46a3df --- /dev/null +++ b/script/github-assign-contributor-issue.py @@ -0,0 +1,421 @@ +#!/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 + +""" + +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