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