-
Notifications
You must be signed in to change notification settings - Fork 311
174 lines (146 loc) · 6.71 KB
/
auto-assign-issue.yml
File metadata and controls
174 lines (146 loc) · 6.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
name: Auto Assign Issue by Label
on:
issues:
types: [opened, labeled]
workflow_dispatch:
inputs:
issue_number:
description: "Issue number to test against"
required: true
type: number
permissions:
contents: read
issues: write
jobs:
auto-assign:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event.issue.state == 'open'
concurrency:
group: assign-issue-${{ github.event.issue.number || inputs.issue_number }}
cancel-in-progress: false
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python dependencies
run: pip install --quiet --no-deps pyyaml==6.0.3
- name: Assign issue based on label mapping
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_API_URL: ${{ github.api_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
MAPPING_FILE: .github/label-assignees.yml
shell: python
run: |
import json
import os
import urllib.error
import urllib.request
import yaml
REQUEST_TIMEOUT_SECONDS = 30
def github_request(method, path, payload=None):
url = f"{os.environ['GITHUB_API_URL']}{path}"
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
"X-GitHub-Api-Version": "2022-11-28",
}
data = None
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SECONDS) as response:
body = response.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
print(f"GitHub API request failed: {method} {url} -> HTTP {error.code}: {error.reason} — {body}")
raise
except urllib.error.URLError as error:
print(f"Network error during GitHub API request: {method} {url} -> {error.reason}")
raise
def parse_mapping(filepath):
"""Parse label-to-assignee mappings from the YAML file.
Expected format:
assignees:
<github-username>:
- <label-name>
- <label-name>
Returns:
dict: Mapping of lowercase label names to a list of assignee
usernames. If the same label appears under multiple users
all of them are collected. Returns an empty dict if the
file is not found or contains no valid mappings.
"""
mapping = {}
try:
with open(filepath, encoding="utf-8") as f:
data = yaml.safe_load(f)
except FileNotFoundError:
print(f"Mapping file '{filepath}' not found.")
return mapping
if data is None:
data = {}
elif not isinstance(data, dict):
print(f"Warning: mapping file top level is not a mapping — got {type(data).__name__}")
return mapping
raw = data.get("assignees")
if not isinstance(raw, dict):
print(f"Warning: 'assignees' in mapping file is not a mapping — got {type(raw).__name__}")
return mapping
for username, labels in raw.items():
if not isinstance(username, str):
print(f"Warning: non-string assignee key {username!r} — skipping")
continue
if not labels:
continue
if not isinstance(labels, list):
print(f"Warning: labels for '{username}' is not a list — skipping")
continue
for label in labels:
if not isinstance(label, str):
print(f"Warning: non-string label value {label!r} under '{username}' — skipping")
continue
label_lower = label.lower()
mapping.setdefault(label_lower, []).append(username)
return mapping
mapping = parse_mapping(os.environ["MAPPING_FILE"])
if not mapping:
print("No label-to-assignee mappings found. Nothing to do.")
raise SystemExit(0)
repo = os.environ["GITHUB_REPOSITORY"]
issue_number = os.environ["ISSUE_NUMBER"]
issue = github_request("GET", f"/repos/{repo}/issues/{issue_number}")
if "pull_request" in issue:
print("Item is a pull request, not an issue. Skipping auto-assignment.")
raise SystemExit(0)
if issue.get("state") != "open":
print("Issue is no longer open. Skipping auto-assignment.")
raise SystemExit(0)
if issue.get("assignees"):
print("Issue already has an assignee. Skipping auto-assignment.")
raise SystemExit(0)
issue_labels = [label["name"].lower() for label in issue.get("labels", [])]
if not issue_labels:
print("Issue has no labels. Nothing to assign.")
raise SystemExit(0)
assignees = []
seen = set()
for label in issue_labels:
for assignee in mapping.get(label, []):
if assignee not in seen:
seen.add(assignee)
assignees.append(assignee)
print(f"Label '{label}' matched candidate assignee '{assignee}'")
if not assignees:
print("No matching label mappings found for this issue's labels.")
raise SystemExit(0)
if len(assignees) > 3:
print(f"Warning: {len(assignees)} assignees matched but the limit is 3. "
f"Leaving issue #{issue_number} unassigned.")
raise SystemExit(0)
path = f"/repos/{repo}/issues/{issue_number}/assignees"
github_request("POST", path, {"assignees": assignees})
print(f"Successfully assigned issue #{issue_number} to: {', '.join(assignees)}")