github-assign-contributor-issue.py

  1#!/usr/bin/env python3
  2"""
  3Assign a labeled contributor issue to the least-busy interested contributor.
  4
  5When an issue has both a `.contrib/good *` label and an `area:` label, this
  6script:
  71. Fetches Tally form responses to find contributors interested in the issue's areas
  82. Queries GitHub for each candidate's current open issue assignment count
  93. Assigns the issue to the least-busy candidate (random tiebreak)
 104. Adds the issue to a GitHub project board with "Assign" status
 115. Notifies the assignee via Slack DM and posts to an activity channel
 12
 13Errors and notable conditions (no candidates found, API failures) are reported
 14to the Slack activity channel before the script exits.
 15
 16Requires:
 17    requests (pip install requests)
 18
 19Usage:
 20    python github-assign-contributor-issue.py <issue_number>
 21
 22"""
 23
 24import json
 25import os
 26import random
 27import sys
 28
 29import requests
 30
 31GITHUB_API = "https://api.github.com"
 32TALLY_API = "https://api.tally.so"
 33SLACK_API = "https://slack.com/api"
 34
 35REPO_OWNER = "zed-industries"
 36REPO_NAME = "zed"
 37PROJECT_NUMBER = 83
 38SLACK_ACTIVITY_CHANNEL_ID = "C0B0JCE8GDC"
 39
 40
 41def eligible_areas(issue):
 42    """Returns the list of area names if the issue is eligible for assignment, or None."""
 43    labels = [label["name"] for label in issue["labels"]]
 44    assignees = [a["login"] for a in issue["assignees"]]
 45
 46    contrib_labels = [name for name in labels if name.startswith(".contrib/good ")]
 47    area_labels = [name for name in labels if name.startswith("area:")]
 48
 49    if not contrib_labels or not area_labels:
 50        print("Issue needs both a .contrib/good * label and an area: label, skipping")
 51        return None
 52
 53    if assignees:
 54        print(f"Issue is already assigned to {assignees}, skipping")
 55        return None
 56
 57    areas = [label.removeprefix("area:") for label in area_labels]
 58    print(f"Areas: {areas}")
 59    return areas
 60
 61
 62# --- Tally ---
 63
 64
 65def fetch_tally_contributors(api_key, form_id):
 66    """Fetch all completed submissions from a Tally form.
 67
 68    Deduplicates by GitHub username, keeping the latest submission.
 69    """
 70    headers = {"Authorization": f"Bearer {api_key}"}
 71    contributors = {}
 72    page = 1
 73
 74    while True:
 75        response = requests.get(
 76            f"{TALLY_API}/forms/{form_id}/submissions",
 77            headers=headers,
 78            params={"page": page, "limit": 500, "filter": "completed"},
 79        )
 80        response.raise_for_status()
 81        data = response.json()
 82
 83        field_titles = {}
 84        for question in data.get("questions", []):
 85            for field in question.get("fields", []):
 86                field_titles[field["uuid"]] = field.get("title", "")
 87
 88        questions = {q["id"]: q for q in data.get("questions", [])}
 89
 90        for submission in data.get("submissions", []):
 91            record = parse_submission(submission, questions, field_titles)
 92            if record:
 93                contributors[record["github_username"].lower()] = record
 94
 95        if not data.get("hasMore", False):
 96            break
 97        page += 1
 98
 99    return list(contributors.values())
100
101
102def parse_submission(submission, questions, field_titles):
103    """Parse a single Tally submission into a contributor record.
104
105    Returns a dict with github_username, email (optional), and areas,
106    or None if the submission is incomplete.
107    """
108    github_username = None
109    email = None
110    areas = []
111
112    for response in submission.get("responses", []):
113        try:
114            question_title = questions[response["questionId"]]["title"].lower()
115            answer = response["answer"]
116        except KeyError:
117            continue
118
119        try:
120            if "github" in question_title:
121                github_username = str(answer).strip().lstrip("@")
122            elif "email" in question_title:
123                email = str(answer).strip().lower()
124            elif "area" in question_title:
125                for item in answer if isinstance(answer, list) else [answer]:
126                    area = field_titles.get(item, item).strip()
127                    if area:
128                        areas.append(area)
129        except (TypeError, AttributeError):
130            continue
131
132    if not github_username or not areas:
133        return None
134
135    record = {"github_username": github_username, "areas": areas}
136    if email:
137        record["email"] = email
138    return record
139
140
141def find_candidates(contributors, area_names):
142    """Find contributors interested in any of the given areas (case-insensitive)."""
143    target = {name.lower() for name in area_names}
144    return [c for c in contributors if any(a.lower() in target for a in c["areas"])]
145
146
147def pick_least_busy(github_headers, candidates):
148    """Pick the candidate with the fewest open assignments (random tiebreak)."""
149    usernames = [c["github_username"] for c in candidates]
150    loads = count_open_assignments(github_headers, usernames)
151    for username, count in loads.items():
152        print(f"  {username}: {count} open assignments")
153
154    min_load = min(loads.values())
155    least_busy = [c for c in candidates if loads[c["github_username"]] == min_load]
156    chosen = random.choice(least_busy)
157    print(
158        f"Selected: {chosen['github_username']} (load: {min_load}, {len(least_busy)} tied)"
159    )
160    return chosen
161
162
163# --- GitHub ---
164
165
166def fetch_issue(headers, issue_number):
167    """Fetch issue details from the GitHub API."""
168    response = requests.get(
169        f"{GITHUB_API}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}",
170        headers=headers,
171    )
172    response.raise_for_status()
173    return response.json()
174
175
176def count_open_assignments(headers, usernames):
177    """Count open issues assigned to each user in a single GraphQL request."""
178    aliases = [
179        f'u{i}: search(query: "repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open assignee:{name}", type: ISSUE) {{ issueCount }}'
180        for i, name in enumerate(usernames)
181    ]
182    query = "query {\n" + "\n".join(aliases) + "\n}"
183    data = execute_graphql(headers, query, {})
184    return {name: data[f"u{i}"]["issueCount"] for i, name in enumerate(usernames)}
185
186
187def assign_issue(headers, issue_number, username):
188    """Assign a GitHub issue to a user."""
189    response = requests.post(
190        f"{GITHUB_API}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/assignees",
191        headers=headers,
192        json={"assignees": [username]},
193    )
194    response.raise_for_status()
195
196
197def execute_graphql(headers, query, variables):
198    """Execute a GitHub GraphQL query. Raises on HTTP or GraphQL errors."""
199    response = requests.post(
200        f"{GITHUB_API}/graphql",
201        headers=headers,
202        json={"query": query, "variables": variables},
203    )
204    response.raise_for_status()
205    result = response.json()
206    if "errors" in result:
207        raise RuntimeError(f"GraphQL error: {result['errors']}")
208    return result["data"]
209
210
211def fetch_project(headers, project_number):
212    """Fetch a GitHub project board's metadata including fields and status options."""
213    data = execute_graphql(
214        headers,
215        """
216        query($owner: String!, $number: Int!) {
217          organization(login: $owner) {
218            projectV2(number: $number) {
219              id
220              fields(first: 50) {
221                nodes {
222                  ... on ProjectV2SingleSelectField {
223                    id
224                    name
225                    options { id name }
226                  }
227                }
228              }
229            }
230          }
231        }
232        """,
233        {"owner": REPO_OWNER, "number": project_number},
234    )
235    return data["organization"]["projectV2"]
236
237
238def add_issue_to_project(headers, project_id, issue_node_id):
239    """Add an issue to a GitHub project board. Returns the project item ID."""
240    data = execute_graphql(
241        headers,
242        """
243        mutation($projectId: ID!, $contentId: ID!) {
244          addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
245            item { id }
246          }
247        }
248        """,
249        {"projectId": project_id, "contentId": issue_node_id},
250    )
251    item_id = data["addProjectV2ItemById"]["item"]["id"]
252    print(f"Added issue to project (item: {item_id})")
253    return item_id
254
255
256def set_project_item_status(headers, project, item_id, status_name):
257    """Set the Status field on a project item. Hard-fails if the status option is missing."""
258    status_field_id = None
259    option_id = None
260    for field in project["fields"]["nodes"]:
261        if field.get("name") == "Status":
262            status_field_id = field["id"]
263            for option in field.get("options", []):
264                if option["name"] == status_name:
265                    option_id = option["id"]
266                    break
267            break
268
269    if not status_field_id or not option_id:
270        available = [f.get("name") for f in project["fields"]["nodes"] if f.get("name")]
271        raise RuntimeError(
272            f"Could not find Status field with '{status_name}' option. "
273            f"Fields found: {available}"
274        )
275
276    execute_graphql(
277        headers,
278        """
279        mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
280          updateProjectV2ItemFieldValue(input: {
281            projectId: $projectId
282            itemId: $itemId
283            fieldId: $fieldId
284            value: { singleSelectOptionId: $optionId }
285          }) {
286            projectV2Item { id }
287          }
288        }
289        """,
290        {
291            "projectId": project["id"],
292            "itemId": item_id,
293            "fieldId": status_field_id,
294            "optionId": option_id,
295        },
296    )
297    print(f"Set project status to '{status_name}'")
298
299
300# --- Slack ---
301
302
303def slack_post_message(headers, recipient, text):
304    """Post a message to a Slack channel or user DM."""
305    response = requests.post(
306        f"{SLACK_API}/chat.postMessage",
307        headers=headers,
308        json={"channel": recipient, "text": text},
309    )
310    response.raise_for_status()
311    data = response.json()
312    if not data["ok"]:
313        raise RuntimeError(f"Slack API error: {data['error']}")
314
315
316def find_slack_user_id(headers, email):
317    """Look up a Slack user ID by email. Returns None if not found."""
318    try:
319        response = requests.get(
320            f"{SLACK_API}/users.lookupByEmail",
321            headers=headers,
322            params={"email": email},
323        )
324        response.raise_for_status()
325        return response.json()["user"]["id"]
326    except (requests.RequestException, KeyError):
327        return None
328
329
330def post_to_activity(slack_headers, message):
331    """Best-effort post to the Slack activity channel."""
332    try:
333        slack_post_message(slack_headers, SLACK_ACTIVITY_CHANNEL_ID, message)
334    except Exception as exc:
335        print(f"Failed to post to Slack activity channel: {exc}")
336
337
338def notify_assignment(slack_headers, chosen, issue):
339    """DM the chosen contributor and post to the activity channel."""
340    issue_number = issue["number"]
341    issue_title = issue["title"]
342    issue_url = issue["html_url"]
343    chosen_username = chosen["github_username"]
344
345    slack_user_id = find_slack_user_id(slack_headers, chosen.get("email"))
346
347    if slack_user_id:
348        slack_post_message(
349            slack_headers,
350            slack_user_id,
351            f"\U0001f44b You've been assigned to <{issue_url}|#{issue_number}: {issue_title}>! "
352            f"This issue matches your areas of interest. "
353            f"Let us know if you have any questions.",
354        )
355
356    activity_message = (
357        f"\U0001f4cb <{issue_url}|#{issue_number}: {issue_title}> "
358        f"assigned to *{chosen_username}*"
359    )
360    if slack_user_id:
361        activity_message += f" (<@{slack_user_id}>)"
362    post_to_activity(slack_headers, activity_message)
363
364
365# --- Main ---
366
367
368if __name__ == "__main__":
369    issue_number = sys.argv[1]
370
371    github_token = os.environ["GITHUB_TOKEN"]
372    tally_api_key = os.environ["TALLY_API_KEY"]
373    tally_form_id = os.environ["TALLY_FORM_ID"]
374    slack_bot_token = os.environ["SLACK_CONTRIBUTOR_ROUTING_BOT_TOKEN"]
375
376    github_headers = {
377        "Authorization": f"Bearer {github_token}",
378        "Accept": "application/vnd.github+json",
379        "X-GitHub-Api-Version": "2022-11-28",
380    }
381    slack_headers = {
382        "Authorization": f"Bearer {slack_bot_token}",
383        "Content-Type": "application/json",
384    }
385
386    issue = fetch_issue(github_headers, issue_number)
387    if not (areas := eligible_areas(issue)):
388        sys.exit(0)
389
390    try:
391        contributors = fetch_tally_contributors(tally_api_key, tally_form_id)
392        print(f"Found {len(contributors)} contributors in Tally")
393
394        candidates = find_candidates(contributors, areas)
395        if not candidates:
396            post_to_activity(
397                slack_headers,
398                f"\u26a0\ufe0f No contributors found for {', '.join(areas)} \u2014 "
399                f"<{issue['html_url']}|#{issue_number}: {issue['title']}>",
400            )
401            print(f"No contributors interested in areas: {areas}")
402            sys.exit(0)
403
404        chosen = pick_least_busy(github_headers, candidates)
405
406        assign_issue(github_headers, issue_number, chosen["github_username"])
407        print(f"Assigned #{issue_number} to {chosen['github_username']}")
408
409        project = fetch_project(github_headers, PROJECT_NUMBER)
410        item_id = add_issue_to_project(github_headers, project["id"], issue["node_id"])
411        set_project_item_status(github_headers, project, item_id, "Assigned")
412
413        notify_assignment(slack_headers, chosen, issue)
414
415    except Exception as exc:
416        post_to_activity(
417            slack_headers,
418            f"\u274c Failed to assign contributor for "
419            f"<{issue['html_url']}|#{issue_number}: {issue['title']}>: {exc}",
420        )
421        raise