Files
rt-thread/tools/ci/similar_issues.py

623 lines
19 KiB
Python

#!/usr/bin/env python3
#
# Copyright (c) 2026, RT-Thread Development Team
#
# SPDX-License-Identifier: Apache-2.0
#
"""Suggest potentially related GitHub issues using conservative local scoring."""
import json
import os
import re
import sys
import time
import unicodedata
from dataclasses import dataclass
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
import issue_labeler
API_VERSION = "2022-11-28"
COMMENT_MARKER = "<!-- rt-thread-similar-issues -->"
CLAIM_HINT_MARKER = "<!-- rt-thread-issue-claim-hint -->"
MAX_CANDIDATES = 3
SEARCH_RESULT_LIMIT = 30
MAX_BACKFILL_ISSUES = 50
FORM_SECTION = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE)
TITLE_PREFIX = re.compile(r"^\s*\[(?:bug|feature)\]\s*", re.IGNORECASE)
CJK_RUN = re.compile(r"[\u3400-\u9fff]+")
LATIN_TOKEN = re.compile(r"[a-z][a-z0-9_.-]{2,}")
IGNORED_SECTIONS = {
"rt-thread version",
"affected area",
"hardware/bsp vendor",
"architecture",
"board and hardware details",
"hardware type/architectures",
"develop toolchain",
"other additional context",
}
GENERIC_TOKENS = {
"about",
"after",
"before",
"board",
"build",
"component",
"could",
"error",
"feature",
"issue",
"problem",
"request",
"rt-thread",
"should",
"thread",
"using",
"with",
}
CLASSIFICATION_LABELS = {
"kernel",
"rt-smart",
"libcpu",
"doc",
"tools",
"testcase",
"action",
"bsp",
"component",
}
@dataclass(frozen=True)
class EventContext:
repository: str
issue: dict
class GitHubAPIError(RuntimeError):
def __init__(self, status, message):
super().__init__(message)
self.status = status
def __str__(self):
return "GitHub API request failed with status {}: {}".format(
self.status, super().__str__()
)
class GitHubClient:
def __init__(self, token, api_url="https://api.github.com"):
if not token:
raise ValueError("GITHUB_TOKEN is required")
self.token = token
self.api_url = api_url.rstrip("/")
def request(self, method, path, payload=None, expected=(200,)):
headers = {
"Accept": "application/vnd.github+json",
"Authorization": "Bearer {}".format(self.token),
"User-Agent": "rt-thread-similar-issues",
"X-GitHub-Api-Version": API_VERSION,
}
body = None
if payload is not None:
body = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(
self.api_url + path, data=body, headers=headers, method=method
)
try:
with urlopen(request, timeout=30) as response:
status = response.getcode()
response_body = response.read()
except HTTPError as error:
try:
response_body = json.loads(error.read().decode("utf-8"))
message = response_body.get("message", error.reason)
except (ValueError, UnicodeDecodeError):
message = error.reason
raise GitHubAPIError(error.code, str(message)) from error
except URLError as error:
raise GitHubAPIError("network", str(error.reason)) from error
if status not in expected:
raise GitHubAPIError(status, "unexpected response status")
return json.loads(response_body.decode("utf-8")) if response_body else None
@staticmethod
def repository_path(repository):
owner, name = repository.split("/", 1)
return "/repos/{}/{}".format(quote(owner, safe=""), quote(name, safe=""))
def search_issues(self, repository, query):
parameters = urlencode(
{
"q": "repo:{} is:issue {}".format(repository, query),
"per_page": SEARCH_RESULT_LIMIT,
}
)
return self.request("GET", "/search/issues?{}".format(parameters))
def list_comments(self, repository, issue_number):
comments = []
repository_path = self.repository_path(repository)
page = 1
while True:
parameters = urlencode({"per_page": 100, "page": page})
batch = self.request(
"GET",
"{}/issues/{}/comments?{}".format(
repository_path, issue_number, parameters
),
)
comments.extend(batch)
if len(batch) < 100:
break
page += 1
return comments
def create_comment(self, repository, issue_number, body):
return self.request(
"POST",
"{}/issues/{}/comments".format(
self.repository_path(repository), issue_number
),
{"body": body},
expected=(201,),
)
def list_open_issues(self, repository, limit):
issues = []
repository_path = self.repository_path(repository)
for page in range(1, 11):
parameters = urlencode(
{
"state": "open",
"sort": "updated",
"direction": "desc",
"per_page": 100,
"page": page,
}
)
batch = self.request(
"GET", "{}/issues?{}".format(repository_path, parameters)
)
issues.extend(
issue
for issue in batch
if "pull_request" not in issue and not (issue.get("assignees") or [])
)
if len(issues) >= limit or len(batch) < 100:
break
return sorted(issues, key=lambda issue: issue.get("number", 0), reverse=True)[
:limit
]
def normalize(value):
text = unicodedata.normalize("NFKC", str(value or "")).casefold()
return " ".join(text.split())
def strip_title_prefix(title):
return TITLE_PREFIX.sub("", str(title or ""), count=1).strip()
def parse_form_sections(body):
text = str(body or "")
matches = list(FORM_SECTION.finditer(text))
sections = []
for index, match in enumerate(matches):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
sections.append((normalize(match.group(1)), text[start:end].strip()))
return sections
def strip_markdown(value):
text = re.sub(r"```[\s\S]*?```", " ", str(value or ""))
text = re.sub(r"!\[[^]]*\]\([^)]*\)", " ", text)
text = re.sub(r"\[([^]]+)\]\([^)]*\)", r"\1", text)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"[`*~>#]", " ", text)
return " ".join(text.split())
def relevant_body(body):
sections = parse_form_sections(body)
if not sections:
return strip_markdown(body)
selected = [
value
for heading, value in sections
if heading not in IGNORED_SECTIONS and value != "_No response_"
]
return strip_markdown("\n".join(selected))
def semantic_tokens(value):
normalized = normalize(value)
tokens = {
token
for token in LATIN_TOKEN.findall(normalized)
if token not in GENERIC_TOKENS and not token.isdigit()
}
for run in CJK_RUN.findall(normalized):
if len(run) == 1:
continue
if len(run) <= 4:
tokens.add(run)
for index in range(len(run) - 1):
tokens.add(run[index : index + 2])
return tokens
def technical_tokens(value):
return {
token
for token in semantic_tokens(value)
if re.search(r"[0-9_.-]", token) and not CJK_RUN.fullmatch(token)
}
def character_ngrams(value, size=3):
compact = re.sub(r"[^a-z0-9\u3400-\u9fff]+", "", normalize(value))
if not compact:
return set()
gram_size = min(size, len(compact))
return {
compact[index : index + gram_size]
for index in range(len(compact) - gram_size + 1)
}
def dice(left, right):
if not left or not right:
return 0.0
return 2.0 * len(left.intersection(right)) / (len(left) + len(right))
def jaccard(left, right):
if not left or not right:
return 0.0
return len(left.intersection(right)) / len(left.union(right))
def label_names(issue):
result = set()
for label in issue.get("labels") or []:
name = label.get("name") if isinstance(label, dict) else label
if isinstance(name, str):
result.add(name.casefold())
return result
def classification_labels(issue):
labels = label_names(issue)
labels.difference_update(
label.casefold()
for label in issue_labeler.obsolete_labels(
issue.get("body", ""), issue
)
)
labels.update(
label.casefold()
for label in issue_labeler.desired_labels(issue.get("body", ""))
)
return {
label
for label in labels
if label in CLASSIFICATION_LABELS
or label.startswith("bsp:")
or label.startswith("arch:")
or label.startswith("component:")
}
def score_candidate(issue, candidate):
issue_labels = classification_labels(issue)
candidate_labels = classification_labels(candidate)
issue_bsp = {label for label in issue_labels if label.startswith("bsp:")}
candidate_bsp = {
label for label in candidate_labels if label.startswith("bsp:")
}
if issue_bsp and candidate_bsp and issue_bsp.isdisjoint(candidate_bsp):
return {"accepted": False, "score": 0.0, "reason": "bsp-mismatch"}
issue_title = strip_title_prefix(issue.get("title", ""))
candidate_title = strip_title_prefix(candidate.get("title", ""))
issue_body = relevant_body(issue.get("body", ""))[:1600]
candidate_body = relevant_body(candidate.get("body", ""))[:1600]
title_similarity = dice(
character_ngrams(issue_title), character_ngrams(candidate_title)
)
body_similarity = dice(
character_ngrams(issue_body), character_ngrams(candidate_body)
)
token_similarity = jaccard(
semantic_tokens(issue_title), semantic_tokens(candidate_title)
)
issue_technical = technical_tokens("{}\n{}".format(issue_title, issue_body))
candidate_technical = technical_tokens(
"{}\n{}".format(candidate_title, candidate_body)
)
technical_coverage = (
len(issue_technical.intersection(candidate_technical)) / len(issue_technical)
if issue_technical
else 0.0
)
label_similarity = jaccard(issue_labels, candidate_labels)
score = (
title_similarity * 0.50
+ body_similarity * 0.12
+ token_similarity * 0.20
+ technical_coverage * 0.13
+ label_similarity * 0.05
)
accepted = score >= 0.38 and (
title_similarity >= 0.24
or token_similarity >= 0.35
or technical_coverage >= 0.5
)
return {
"accepted": accepted,
"score": score,
"signals": {
"title": title_similarity,
"body": body_similarity,
"tokens": token_similarity,
"technical": technical_coverage,
"labels": label_similarity,
},
}
def rank_candidates(issue, candidates):
ranked = []
issue_number = int(issue.get("number", 0) or 0)
for candidate in candidates:
if "pull_request" in candidate:
continue
if int(candidate.get("number", 0) or 0) == issue_number:
continue
result = score_candidate(issue, candidate)
if result["accepted"]:
ranked.append({"candidate": candidate, **result})
ranked.sort(key=lambda item: item["score"], reverse=True)
return ranked[:MAX_CANDIDATES]
def search_terms(issue):
title = strip_title_prefix(issue.get("title", ""))
technical = sorted(technical_tokens(title), key=lambda token: (-len(token), token))
ordinary = sorted(
semantic_tokens(title).difference(technical),
key=lambda token: (-len(token), token),
)
# GitHub's issue search combines terms narrowly. Technical identifiers are
# strong enough to retrieve broadly on their own; local scoring filters the
# resulting candidates before anything is shown to users.
selected = technical[:3] if technical else ordinary[:3]
query = " ".join(selected)
if not query:
query = re.sub(r"[^a-zA-Z0-9\u3400-\u9fff_.-]+", " ", title)
return " ".join(query.split())[:256]
def is_workflow_comment(comment):
if not isinstance(comment, dict):
return False
user = comment.get("user") or {}
return str(user.get("login", "")).casefold() == "github-actions[bot]"
def has_marker(comments, marker=COMMENT_MARKER):
return any(
is_workflow_comment(comment)
and marker in str(comment.get("body", ""))
for comment in comments
)
def format_comment(ranked):
references = "\n".join(
"- #{}".format(item["candidate"]["number"]) for item in ranked
)
return (
"{}\n"
"以下 Issue 可能与当前问题相关;这是自动检索结果,尚未确认重复:\n\n"
"The following issues may be related. These automated suggestions are "
"not confirmed duplicates:\n\n{}"
).format(COMMENT_MARKER, references)
def format_claim_hint_comment():
return (
"{}\n"
"如需认领并处理此 Issue,请评论 `/claim`。\n\n"
"To claim and work on this issue, comment `/claim`."
).format(CLAIM_HINT_MARKER)
def ensure_claim_hint(client, repository, issue_number, comments, dry_run=False):
if has_marker(comments, CLAIM_HINT_MARKER):
print("Issue #{} already has a claim hint".format(issue_number))
return False
comment = format_claim_hint_comment()
if dry_run:
print("Dry run claim hint for #{}:\n{}".format(issue_number, comment))
else:
client.create_comment(repository, issue_number, comment)
return True
def is_rate_limit(error):
return error.status in (429,) or (
error.status == 403 and "rate limit" in str(error).casefold()
)
def suggest_for_issue(
client,
repository,
issue,
dry_run=False,
best_effort_rate_limit=True,
add_claim_hint=True,
):
issue_number = int(issue.get("number", 0) or 0)
if not issue_number or "pull_request" in issue:
return []
comments = client.list_comments(repository, issue_number)
if add_claim_hint:
ensure_claim_hint(
client, repository, issue_number, comments, dry_run=dry_run
)
if has_marker(comments):
print("Issue #{} already has a similar-issue comment".format(issue_number))
return []
query = search_terms(issue)
if len(query) < 2:
print("Issue #{} has too little searchable text".format(issue_number))
return []
try:
response = client.search_issues(repository, query)
except GitHubAPIError as error:
if best_effort_rate_limit and is_rate_limit(error):
print("Skipping #{} because search is rate limited".format(issue_number))
return []
raise
ranked = rank_candidates(issue, response.get("items") or [])
if not ranked:
print("No sufficiently similar issues found for #{}".format(issue_number))
return []
comment = format_comment(ranked)
if dry_run:
print("Dry run for #{}:\n{}".format(issue_number, comment))
else:
if has_marker(client.list_comments(repository, issue_number)):
print("Issue #{} received a similar-issue comment".format(issue_number))
return []
client.create_comment(repository, issue_number, comment)
return ranked
def process_backfill(client, repository, max_issues, delay_seconds, dry_run):
issues = client.list_open_issues(repository, max_issues)
summary = {"processed": 0, "suggested": 0, "candidates": 0, "failures": []}
for index, issue in enumerate(issues):
number = issue.get("number")
print("[{}/{}] Processing #{}".format(index + 1, len(issues), number))
try:
ranked = suggest_for_issue(
client,
repository,
issue,
dry_run=dry_run,
best_effort_rate_limit=False,
add_claim_hint=False,
)
if ranked:
summary["suggested"] += 1
summary["candidates"] += len(ranked)
except GitHubAPIError as error:
summary["failures"].append({"number": number, "error": str(error)})
summary["processed"] += 1
if index + 1 < len(issues):
time.sleep(delay_seconds)
return summary
def event_context(event):
if not isinstance(event, dict) or event.get("action") != "opened":
return None
issue = event.get("issue") or {}
if "pull_request" in issue:
return None
repository = (event.get("repository") or {}).get("full_name")
if not repository or repository.count("/") != 1:
return None
return EventContext(repository, issue)
def boolean_env(name, default=False):
value = os.getenv(name)
if value is None:
return default
return normalize(value) in ("1", "true", "yes", "on")
def integer_env(name, default, minimum, maximum):
try:
value = int(os.getenv(name, str(default)))
except ValueError as error:
raise ValueError("{} must be an integer".format(name)) from error
if value < minimum or value > maximum:
raise ValueError("{} must be between {} and {}".format(name, minimum, maximum))
return value
def load_event(path):
with open(path, "r", encoding="utf-8") as event_file:
return json.load(event_file)
def main():
try:
client = GitHubClient(
os.getenv("GITHUB_TOKEN", ""),
os.getenv("GITHUB_API_URL", "https://api.github.com"),
)
if boolean_env("BACKFILL"):
repository = os.getenv("GITHUB_REPOSITORY", "")
if repository.count("/") != 1:
raise ValueError("GITHUB_REPOSITORY is required")
summary = process_backfill(
client,
repository,
max_issues=integer_env(
"MAX_ISSUES", 20, minimum=1, maximum=MAX_BACKFILL_ISSUES
),
delay_seconds=integer_env(
"SEARCH_DELAY_SECONDS", 3, minimum=2, maximum=30
),
dry_run=boolean_env("DRY_RUN", default=True),
)
print(json.dumps(summary, ensure_ascii=True, sort_keys=True))
return 1 if summary["failures"] else 0
event_path = os.getenv("GITHUB_EVENT_PATH")
if not event_path:
raise ValueError("GITHUB_EVENT_PATH is required")
context = event_context(load_event(event_path))
if context is None:
print("Ignoring event: no supported issue")
return 0
suggest_for_issue(
client,
context.repository,
context.issue,
dry_run=boolean_env("DRY_RUN"),
)
return 0
except (GitHubAPIError, OSError, ValueError) as error:
print(str(error), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())