|
| 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) |
0 commit comments