Skip to content

Commit 9f68c00

Browse files
ci: add automated issue triage bot using GitHub Models
Add a GitHub Actions workflow that automatically triages new issues by classifying them with gpt-4o-mini via GitHub Models and applying the appropriate label (bug, Missing Feature, question, enhancement, or breaking-change). Also posts a polite acknowledgment comment. Features: - Triggers on new issues and manual workflow dispatch - Skips issues that already have triage labels (respects manual labels) - Sanitizes LLM output to prevent markdown injection - Robust error handling with timeouts and descriptive messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bdc2bcb commit 9f68c00

File tree

2 files changed

+224
-0
lines changed

2 files changed

+224
-0
lines changed

.github/scripts/triage-issue.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""
2+
Issue triage bot for TorchSharp.
3+
4+
Classifies new GitHub issues using an LLM and applies the appropriate label.
5+
Posts a polite comment acknowledging the issue.
6+
Skips issues that already have triage labels (manually set by maintainers).
7+
"""
8+
9+
import json
10+
import os
11+
import re
12+
import sys
13+
import urllib.error
14+
import urllib.request
15+
16+
GITHUB_API = "https://api.github.com"
17+
INFERENCE_API = "https://models.inference.ai.azure.com"
18+
MODEL = "gpt-4o-mini"
19+
20+
TRIAGE_LABELS = {"bug", "Missing Feature", "question", "enhancement", "breaking-change"}
21+
22+
SYSTEM_PROMPT = """\
23+
You are an issue triage bot for TorchSharp, a .NET binding for PyTorch.
24+
25+
Classify the following GitHub issue into exactly ONE of these categories:
26+
- bug: Something is broken, crashes, throws an unexpected error, or produces wrong results.
27+
- Missing Feature: A PyTorch API or feature that is not yet available in TorchSharp.
28+
- question: The user is asking for help, guidance, or clarification on how to use TorchSharp.
29+
- enhancement: A suggestion to improve existing functionality (not a missing PyTorch API).
30+
- breaking-change: The issue reports or requests a change that would break existing public API.
31+
32+
Respond with ONLY a JSON object in this exact format, no other text:
33+
{"label": "<one of: bug, Missing Feature, question, enhancement, breaking-change>", "reason": "<one sentence explanation>"}
34+
"""
35+
36+
COMMENT_TEMPLATES = {
37+
"bug": (
38+
"Thank you for reporting this issue! 🙏\n\n"
39+
"I've triaged this as a **bug**. {reason}\n\n"
40+
"A maintainer will review this soon. In the meantime, please make sure you've "
41+
"included a minimal code sample to reproduce the issue and the TorchSharp version you're using.\n\n"
42+
"*This comment was generated automatically by the issue triage bot.*"
43+
),
44+
"Missing Feature": (
45+
"Thank you for opening this issue! 🙏\n\n"
46+
"I've triaged this as a **missing feature** request. {reason}\n\n"
47+
"If you haven't already, it would be very helpful to include a link to the "
48+
"corresponding PyTorch documentation and a Python code example.\n\n"
49+
"*This comment was generated automatically by the issue triage bot.*"
50+
),
51+
"question": (
52+
"Thank you for reaching out! 🙏\n\n"
53+
"I've triaged this as a **question**. {reason}\n\n"
54+
"A maintainer or community member will try to help as soon as possible. "
55+
"Please make sure to include the TorchSharp version and a code sample for context.\n\n"
56+
"*This comment was generated automatically by the issue triage bot.*"
57+
),
58+
"enhancement": (
59+
"Thank you for the suggestion! 🙏\n\n"
60+
"I've triaged this as an **enhancement** request. {reason}\n\n"
61+
"A maintainer will review this when they get a chance.\n\n"
62+
"*This comment was generated automatically by the issue triage bot.*"
63+
),
64+
"breaking-change": (
65+
"Thank you for reporting this! 🙏\n\n"
66+
"I've triaged this as a potential **breaking change**. {reason}\n\n"
67+
"A maintainer will review this carefully.\n\n"
68+
"*This comment was generated automatically by the issue triage bot.*"
69+
),
70+
}
71+
72+
73+
def github_request(method, path, body=None):
74+
"""Make an authenticated request to the GitHub API."""
75+
token = os.environ["GITHUB_TOKEN"]
76+
url = f"{GITHUB_API}{path}"
77+
data = json.dumps(body).encode() if body else None
78+
req = urllib.request.Request(url, data=data, method=method)
79+
req.add_header("Authorization", f"Bearer {token}")
80+
req.add_header("Accept", "application/vnd.github+json")
81+
req.add_header("X-GitHub-Api-Version", "2022-11-28")
82+
if data:
83+
req.add_header("Content-Type", "application/json")
84+
try:
85+
with urllib.request.urlopen(req, timeout=30) as resp:
86+
return json.loads(resp.read())
87+
except urllib.error.HTTPError as e:
88+
error_body = e.read().decode(errors="replace") if e.fp else ""
89+
raise RuntimeError(f"GitHub API {method} {path} failed ({e.code}): {error_body}") from e
90+
91+
92+
def sanitize_reason(reason):
93+
"""Sanitize LLM-generated reason to prevent markdown injection."""
94+
reason = reason[:200]
95+
reason = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", reason) # Strip links
96+
reason = re.sub(r"!\[([^\]]*)\]\([^\)]+\)", "", reason) # Strip images
97+
return reason.strip()
98+
99+
100+
def classify_issue(title, body):
101+
"""Call the LLM to classify the issue."""
102+
token = os.environ["GITHUB_TOKEN"]
103+
user_message = f"Issue title: {title}\n\nIssue body:\n{body or '(empty)'}"
104+
max_length = 4000
105+
if len(user_message) > max_length:
106+
user_message = user_message[:max_length] + "\n\n[Truncated]"
107+
108+
payload = {
109+
"model": MODEL,
110+
"messages": [
111+
{"role": "system", "content": SYSTEM_PROMPT},
112+
{"role": "user", "content": user_message},
113+
],
114+
"temperature": 0.0,
115+
}
116+
117+
data = json.dumps(payload).encode()
118+
req = urllib.request.Request(
119+
f"{INFERENCE_API}/chat/completions", data=data, method="POST"
120+
)
121+
req.add_header("Authorization", f"Bearer {token}")
122+
req.add_header("Content-Type", "application/json")
123+
124+
try:
125+
with urllib.request.urlopen(req, timeout=60) as resp:
126+
result = json.loads(resp.read())
127+
except urllib.error.HTTPError as e:
128+
error_body = e.read().decode(errors="replace") if e.fp else ""
129+
raise RuntimeError(f"LLM API call failed ({e.code}): {error_body}") from e
130+
131+
content = result["choices"][0]["message"]["content"].strip()
132+
133+
# Parse the JSON response, stripping markdown fences if present
134+
json_match = re.search(r"\{.*\}", content, re.DOTALL)
135+
if json_match:
136+
content = json_match.group(0)
137+
138+
parsed = json.loads(content)
139+
label = parsed["label"]
140+
reason = sanitize_reason(parsed.get("reason", ""))
141+
142+
if label not in TRIAGE_LABELS:
143+
print(f"::warning::LLM returned unknown label '{label}', defaulting to 'question'")
144+
label = "question"
145+
reason = reason or "Could not determine the issue type."
146+
147+
return label, reason
148+
149+
150+
def main():
151+
required_vars = ["GITHUB_TOKEN", "REPO", "ISSUE_NUMBER"]
152+
missing = [v for v in required_vars if not os.environ.get(v)]
153+
if missing:
154+
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
155+
156+
repo = os.environ["REPO"]
157+
issue_number = os.environ["ISSUE_NUMBER"]
158+
159+
# Fetch the issue
160+
issue = github_request("GET", f"/repos/{repo}/issues/{issue_number}")
161+
existing_labels = {lbl["name"] for lbl in issue.get("labels", [])}
162+
163+
# Skip if the issue already has a triage label (manually set by maintainer)
164+
overlap = existing_labels & TRIAGE_LABELS
165+
if overlap:
166+
print(f"Issue #{issue_number} already has triage label(s): {overlap}. Skipping.")
167+
return
168+
169+
title = issue.get("title", "")
170+
body = issue.get("body", "")
171+
172+
print(f"Classifying issue #{issue_number}: {title}")
173+
label, reason = classify_issue(title, body)
174+
print(f"Classification: {label}{reason}")
175+
176+
# Add the label
177+
github_request("POST", f"/repos/{repo}/issues/{issue_number}/labels", {"labels": [label]})
178+
print(f"Added label '{label}' to issue #{issue_number}")
179+
180+
# Post a comment
181+
comment_body = COMMENT_TEMPLATES[label].format(reason=reason)
182+
github_request("POST", f"/repos/{repo}/issues/{issue_number}/comments", {"body": comment_body})
183+
print(f"Posted triage comment on issue #{issue_number}")
184+
185+
186+
if __name__ == "__main__":
187+
try:
188+
main()
189+
except Exception as e:
190+
print(f"::error::Triage failed: {e}")
191+
sys.exit(1)

.github/workflows/issue-triage.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Issue Triage
2+
3+
on:
4+
issues:
5+
types: [opened]
6+
workflow_dispatch:
7+
inputs:
8+
issue_number:
9+
description: 'Issue number to triage'
10+
required: true
11+
type: number
12+
13+
permissions:
14+
issues: write
15+
16+
jobs:
17+
triage:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v4
21+
with:
22+
sparse-checkout: .github/scripts
23+
24+
- uses: actions/setup-python@v5
25+
with:
26+
python-version: '3.12'
27+
28+
- name: Triage issue
29+
env:
30+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31+
ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.issue.number }}
32+
REPO: ${{ github.repository }}
33+
run: python .github/scripts/triage-issue.py

0 commit comments

Comments
 (0)