diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e703f0ca663..841732ce6c9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,9 +1,10 @@ name: 🐛 Bug Report description: Create a report to help us improve title: "[Bug] " -labels: ["bug-report"] +labels: ["bug"] body: - type: input + id: version attributes: label: RT-Thread Version description: | @@ -11,14 +12,79 @@ body: validations: required: true + - type: dropdown + id: area + attributes: + label: Affected area + description: Select the area most directly related to the problem. + options: + - Other / Not sure + - Kernel + - Board Support Package (BSP) + - Device drivers + - File systems (DFS) + - Networking + - IPC + - FinSH + - RT-Smart + - Architecture / libcpu + - Documentation + - Build system and tools + - CI / GitHub Actions + - Tests and examples + validations: + required: true + + - type: dropdown + id: hardware_vendor + attributes: + label: Hardware/BSP vendor + description: Select the closest vendor family, or Not applicable for generic issues. + options: + - Not applicable / Other + - STM32 + - NXP + - GD32 + - AT32 + - Infineon + - Renesas + - Phytium + - Cvitek + - WCH + - Raspberry Pi + - Rockchip + - Loongson + - ESP32 + - HC32 + - Novosns + - HPMicro + - Bluetrum + - K230 + - Nuvoton + validations: + required: true + + - type: dropdown + id: architecture + attributes: + label: Architecture + options: + - Not applicable / Other + - ARM / AArch64 + - RISC-V + validations: + required: true + - type: input + id: hardware attributes: - label: Hardware Type/Architectures - description: Specify your Hardware/BSP model. + label: Board and hardware details + description: Specify the board/BSP model and relevant peripherals. validations: required: true - type: dropdown + id: toolchain attributes: label: Develop Toolchain description: which toolchain are you using? @@ -33,6 +99,7 @@ body: required: true - type: textarea + id: description attributes: label: Describe the bug description: A clear and concise description of the bug. @@ -44,6 +111,7 @@ body: required: true - type: textarea + id: context attributes: label: Other additional context description: Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index a3745933072..cde161142a8 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,7 +1,7 @@ name: 🚀 Feature Request description: Suggest an idea for this project title: "[Feature] " -labels: ["feature-request"] +labels: ["enhancement"] body: - type: markdown attributes: @@ -9,7 +9,71 @@ body: ## Please note that feature requests are not 'fire and forget' It is a lot more likely that the feature you would like to have will be implemented if you keep watching your feature request, and provide more details to developers looking into implementing your feature, and help them with testing. + - type: dropdown + id: area + attributes: + label: Affected area + description: Select the area most directly related to the request. + options: + - Other / Not sure + - Kernel + - Board Support Package (BSP) + - Device drivers + - File systems (DFS) + - Networking + - IPC + - FinSH + - RT-Smart + - Architecture / libcpu + - Documentation + - Build system and tools + - CI / GitHub Actions + - Tests and examples + validations: + required: true + + - type: dropdown + id: hardware_vendor + attributes: + label: Hardware/BSP vendor + description: Select the closest vendor family, or Not applicable for generic requests. + options: + - Not applicable / Other + - STM32 + - NXP + - GD32 + - AT32 + - Infineon + - Renesas + - Phytium + - Cvitek + - WCH + - Raspberry Pi + - Rockchip + - Loongson + - ESP32 + - HC32 + - Novosns + - HPMicro + - Bluetrum + - K230 + - Nuvoton + validations: + required: true + + - type: dropdown + id: architecture + attributes: + label: Architecture + options: + - Not applicable / Other + - ARM / AArch64 + - RISC-V + validations: + required: true + - type: textarea + id: problem attributes: label: Describe problem solved by the proposed feature description: A clear and concise description of the problem, if any, this feature will solve. E.g. I'm always frustrated when ... @@ -17,6 +81,7 @@ body: required: true - type: textarea + id: solution attributes: label: Describe your preferred solution description: A clear and concise description of what you want to happen. @@ -24,6 +89,7 @@ body: required: false - type: textarea + id: alternatives attributes: label: Describe possible alternatives description: A clear and concise description of any alternative solutions or features you've considered. diff --git a/.github/workflows/issue_automation_tests.yml b/.github/workflows/issue_automation_tests.yml new file mode 100644 index 00000000000..479ab12c656 --- /dev/null +++ b/.github/workflows/issue_automation_tests.yml @@ -0,0 +1,50 @@ +name: Issue Automation Tests + +on: + push: + branches: [master] + paths: + - ".github/ISSUE_TEMPLATE/bug_report.yml" + - ".github/ISSUE_TEMPLATE/feature_request.yml" + - ".github/workflows/issue_*.yml" + - ".github/workflows/similar_issues*.yml" + - "tools/ci/issue_*.py" + - "tools/ci/similar_issues.py" + - "tools/ci/test_issue_*.py" + - "tools/ci/test_similar_issues.py" + pull_request: + branches: [master] + paths: + - ".github/ISSUE_TEMPLATE/bug_report.yml" + - ".github/ISSUE_TEMPLATE/feature_request.yml" + - ".github/workflows/issue_*.yml" + - ".github/workflows/similar_issues*.yml" + - "tools/ci/issue_*.py" + - "tools/ci/similar_issues.py" + - "tools/ci/test_issue_*.py" + - "tools/ci/test_similar_issues.py" + +concurrency: + group: issue-automation-tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Run issue automation tests + working-directory: tools/ci + run: >- + python3 -m unittest + test_issue_labeler.py + test_issue_claim.py + test_similar_issues.py diff --git a/.github/workflows/issue_claim.yml b/.github/workflows/issue_claim.yml new file mode 100644 index 00000000000..e4f9fd5b562 --- /dev/null +++ b/.github/workflows/issue_claim.yml @@ -0,0 +1,34 @@ +name: Issue Claim + +on: + issue_comment: + types: [created] + +concurrency: + group: issue-claim-${{ github.repository_id }}-${{ github.event.issue.number }} + queue: max + +permissions: {} + +jobs: + issue-claim: + if: >- + github.event.issue.pull_request == null && + github.event.comment.user.type != 'Bot' && + (github.event.comment.body == '/claim' || + github.event.comment.body == '/unclaim') + runs-on: ubuntu-22.04 + timeout-minutes: 5 + permissions: + contents: read + issues: write + steps: + - name: Checkout trusted workflow code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Handle issue command + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 tools/ci/issue_claim.py diff --git a/.github/workflows/issue_labeler.yml b/.github/workflows/issue_labeler.yml new file mode 100644 index 00000000000..f40811299b6 --- /dev/null +++ b/.github/workflows/issue_labeler.yml @@ -0,0 +1,30 @@ +name: Issue Labeler + +on: + issues: + types: [opened, edited, reopened] + +concurrency: + group: issue-labeler-${{ github.repository_id }}-${{ github.event.issue.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + label: + if: ${{ github.event.issue.pull_request == null }} + runs-on: ubuntu-22.04 + timeout-minutes: 5 + permissions: + contents: read + issues: write + steps: + - name: Checkout trusted workflow code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Add issue labels + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 tools/ci/issue_labeler.py diff --git a/.github/workflows/similar_issues.yml b/.github/workflows/similar_issues.yml new file mode 100644 index 00000000000..84fb0f05164 --- /dev/null +++ b/.github/workflows/similar_issues.yml @@ -0,0 +1,30 @@ +name: Suggest Similar Issues + +on: + issues: + types: [opened] + +concurrency: + group: similar-issues-${{ github.repository_id }}-${{ github.event.issue.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + suggest: + if: ${{ github.event.issue.pull_request == null }} + runs-on: ubuntu-22.04 + timeout-minutes: 5 + permissions: + contents: read + issues: write + steps: + - name: Checkout trusted workflow code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Suggest related issues + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 tools/ci/similar_issues.py diff --git a/.github/workflows/similar_issues_backfill.yml b/.github/workflows/similar_issues_backfill.yml new file mode 100644 index 00000000000..7ba7c8e446a --- /dev/null +++ b/.github/workflows/similar_issues_backfill.yml @@ -0,0 +1,48 @@ +name: Backfill Similar Issues + +on: + workflow_dispatch: + inputs: + dry_run: + description: Log suggestions without posting comments + required: true + default: true + type: boolean + max_issues: + description: Maximum open unassigned issues to inspect (1-50) + required: true + default: "20" + type: string + search_delay_seconds: + description: Delay between GitHub searches (2-30 seconds) + required: true + default: "3" + type: string + +concurrency: + group: similar-issues-backfill + cancel-in-progress: false + +permissions: {} + +jobs: + backfill: + runs-on: ubuntu-22.04 + timeout-minutes: 55 + permissions: + contents: read + issues: write + steps: + - name: Checkout trusted workflow code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Inspect recent open issues + env: + GITHUB_TOKEN: ${{ github.token }} + BACKFILL: "true" + DRY_RUN: ${{ inputs.dry_run }} + MAX_ISSUES: ${{ inputs.max_issues }} + SEARCH_DELAY_SECONDS: ${{ inputs.search_delay_seconds }} + run: python3 tools/ci/similar_issues.py diff --git a/tools/ci/issue_claim.py b/tools/ci/issue_claim.py new file mode 100644 index 00000000000..4da1a381ae9 --- /dev/null +++ b/tools/ci/issue_claim.py @@ -0,0 +1,616 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026, RT-Thread Development Team +# +# SPDX-License-Identifier: Apache-2.0 +# + +"""Handle /claim and /unclaim commands on selected GitHub issues.""" + +import json +import os +import re +import sys +import time +from dataclasses import dataclass +from email.utils import parsedate_to_datetime +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + + +COMMAND_PATTERN = re.compile(r"\A/(claim|unclaim)\Z", re.IGNORECASE) +PROGRESS_LABEL = "in progress" +MAINTAINER_ROLES = frozenset(("admin", "maintain")) +MARKER_TEMPLATE = "" +STATE_MARKER = re.compile( + r"" +) + + +@dataclass(frozen=True) +class EventContext: + command: str + repository: str + issue_number: int + actor: str + comment_id: int + + @property + def marker(self): + return MARKER_TEMPLATE.format(comment_id=self.comment_id) + + +@dataclass(frozen=True) +class Decision: + allowed: bool + reason: str + assignees: tuple = () + + +class GitHubAPIError(RuntimeError): + def __init__(self, status, message, rate_limit_reset=None): + super().__init__(message) + self.status = status + self.rate_limit_reset = rate_limit_reset + + def __str__(self): + detail = "GitHub API request failed" + if self.status is not None: + detail += " with status {}".format(self.status) + if self.rate_limit_reset: + detail += " (rate limit resets at {})".format(self.rate_limit_reset) + return "{}: {}".format(detail, super().__str__()) + + +class GitHubClient: + def __init__( + self, token, api_url="https://api.github.com", retries=3, sleep=time.sleep + ): + if not token: + raise ValueError("GITHUB_TOKEN is required") + self.token = token + self.api_url = api_url.rstrip("/") + self.retries = retries + self.sleep = sleep + + def request(self, method, path, payload=None, expected=(200,), retry=True): + url = self.api_url + path + body = None + headers = { + "Accept": "application/vnd.github+json", + "Authorization": "Bearer {}".format(self.token), + "User-Agent": "rt-thread-issue-claim", + "X-GitHub-Api-Version": "2022-11-28", + } + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + for attempt in range(self.retries + 1): + request = Request(url, data=body, headers=headers, method=method) + try: + with urlopen(request, timeout=30) as response: + status = response.getcode() + response_body = response.read() + if status not in expected: + raise GitHubAPIError(status, "unexpected response status") + if not response_body: + return None + return json.loads(response_body.decode("utf-8")) + except HTTPError as error: + message = self._error_message(error) + if retry and attempt < self.retries and self._is_retryable(error): + self.sleep(self._retry_delay(error.headers, attempt)) + continue + raise GitHubAPIError( + error.code, + message, + error.headers.get("X-RateLimit-Reset"), + ) from error + except URLError as error: + if retry and attempt < self.retries: + self.sleep(min(2 ** attempt, 10)) + continue + raise GitHubAPIError(None, str(error.reason)) from error + + raise GitHubAPIError(None, "retry limit exceeded") + + @staticmethod + def _error_message(error): + try: + response = json.loads(error.read().decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return str(error.reason) + return str(response.get("message", error.reason)) + + @staticmethod + def _is_retryable(error): + return ( + error.code == 429 + or error.code >= 500 + or ( + error.code == 403 + and error.headers.get("X-RateLimit-Remaining") == "0" + ) + ) + + @staticmethod + def _retry_delay(headers, attempt): + retry_after = headers.get("Retry-After") + if retry_after: + try: + return min(max(float(retry_after), 0), 30) + except ValueError: + try: + retry_time = parsedate_to_datetime(retry_after).timestamp() + return min(max(retry_time - time.time(), 0), 30) + except (TypeError, ValueError, OverflowError): + pass + + reset = headers.get("X-RateLimit-Reset") + if reset: + try: + return min(max(float(reset) - time.time(), 0), 30) + except ValueError: + pass + return min(2 ** attempt, 10) + + @staticmethod + def _repository_path(repository): + owner, name = repository.split("/", 1) + return "/repos/{}/{}".format(quote(owner, safe=""), quote(name, safe="")) + + def get_issue(self, repository, issue_number): + return self.request( + "GET", + "{}/issues/{}".format(self._repository_path(repository), issue_number), + ) + + def list_comments(self, repository, issue_number): + comments = [] + repository_path = self._repository_path(repository) + page = 1 + while True: + query = urlencode({"per_page": 100, "page": page}) + batch = self.request( + "GET", + "{}/issues/{}/comments?{}".format( + repository_path, issue_number, query + ), + ) + 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,), + retry=False, + ) + + def add_assignees(self, repository, issue_number, assignees): + return self.request( + "POST", + "{}/issues/{}/assignees".format( + self._repository_path(repository), issue_number + ), + {"assignees": list(assignees)}, + expected=(201,), + ) + + def remove_assignees(self, repository, issue_number, assignees): + return self.request( + "DELETE", + "{}/issues/{}/assignees".format( + self._repository_path(repository), issue_number + ), + {"assignees": list(assignees)}, + ) + + def get_permission(self, repository, actor): + try: + return self.request( + "GET", + "{}/collaborators/{}/permission".format( + self._repository_path(repository), quote(actor, safe="") + ), + ) + except GitHubAPIError as error: + if error.status == 404: + return {} + raise + + def can_assign(self, repository, actor): + try: + self.request( + "GET", + "{}/assignees/{}".format( + self._repository_path(repository), quote(actor, safe="") + ), + expected=(204,), + ) + return True + except GitHubAPIError as error: + if error.status == 404: + return False + raise + + def get_label(self, repository, label): + try: + return self.request( + "GET", + "{}/labels/{}".format( + self._repository_path(repository), quote(label, safe="") + ), + ) + except GitHubAPIError as error: + if error.status == 404: + return None + raise + + def add_labels(self, repository, issue_number, labels): + return self.request( + "POST", + "{}/issues/{}/labels".format( + self._repository_path(repository), issue_number + ), + {"labels": list(labels)}, + ) + + def remove_label(self, repository, issue_number, label): + try: + return self.request( + "DELETE", + "{}/issues/{}/labels/{}".format( + self._repository_path(repository), + issue_number, + quote(label, safe=""), + ), + ) + except GitHubAPIError as error: + if error.status == 404: + return None + raise + + +def parse_command(body): + if not isinstance(body, str): + return None + match = COMMAND_PATTERN.fullmatch(body) + return match.group(1).lower() if match else None + + +def is_bot(user): + if not isinstance(user, dict): + return False + login = str(user.get("login", "")).lower() + return str(user.get("type", "")).lower() == "bot" or login.endswith("[bot]") + + +def is_workflow_bot(user): + if not isinstance(user, dict): + return False + return str(user.get("login", "")).casefold() == "github-actions[bot]" + + +def event_context(event): + if not isinstance(event, dict) or event.get("action") != "created": + return None + + issue = event.get("issue") or {} + comment = event.get("comment") or {} + actor_data = event.get("sender") or comment.get("user") or {} + command = parse_command(comment.get("body")) + if not command or "pull_request" in issue or is_bot(actor_data): + return None + + repository = (event.get("repository") or {}).get("full_name") + actor = actor_data.get("login") + try: + issue_number = int(issue["number"]) + comment_id = int(comment["id"]) + except (KeyError, TypeError, ValueError): + return None + + if not repository or repository.count("/") != 1 or not actor: + return None + return EventContext( + command=command, + repository=repository, + issue_number=issue_number, + actor=str(actor), + comment_id=comment_id, + ) + + +def assignee_logins(issue): + assignees = [] + for assignee in issue.get("assignees") or []: + login = assignee.get("login") if isinstance(assignee, dict) else assignee + if isinstance(login, str) and login: + assignees.append(login) + return tuple(assignees) + + +def decide_claim(issue, claimant=None): + if str(issue.get("state", "")).casefold() != "open": + return Decision(False, "closed") + assignees = assignee_logins(issue) + if assignees: + return Decision(False, "assigned", assignees) + if claimant: + return Decision(False, "assigned", (claimant,)) + return Decision(True, "claim") + + +def has_maintainer_permission(permission): + if not isinstance(permission, dict): + return False + roles = { + str(permission.get("permission", "")).casefold(), + str(permission.get("role_name", "")).casefold(), + } + nested = (permission.get("user") or {}).get("permissions") or {} + return bool(roles.intersection(MAINTAINER_ROLES)) or bool( + nested.get("admin") or nested.get("maintain") + ) + + +def decide_unclaim(issue, actor, maintainer=False, claimant=None): + assignees = assignee_logins(issue) + if not assignees and not claimant: + return Decision(False, "unassigned") + if maintainer: + return Decision(True, "unclaim", assignees) + actor_matches = [ + login for login in assignees if login.casefold() == actor.casefold() + ] + if actor_matches: + return Decision(True, "unclaim", tuple(actor_matches)) + if claimant and claimant.casefold() == actor.casefold(): + return Decision(True, "unclaim") + return Decision(False, "unauthorized", assignees or (claimant,)) + + +def already_processed(comments, marker): + for comment in comments or []: + if not isinstance(comment, dict) or not is_workflow_bot( + comment.get("user") or {} + ): + continue + if marker in str(comment.get("body", "")): + return True + return False + + +def active_claimant(comments): + claimant = None + for comment in comments or []: + if not isinstance(comment, dict) or not is_workflow_bot( + comment.get("user") or {} + ): + continue + for state, actor in STATE_MARKER.findall(str(comment.get("body", ""))): + if state == "claimed": + claimant = actor + elif claimant and claimant.casefold() == actor.casefold(): + claimant = None + return claimant + + +def format_users(logins): + return ", ".join("@{}".format(login) for login in logins) + + +def rejection_message(command, decision): + if command == "claim": + if decision.reason == "closed": + return "This issue is closed and cannot be claimed." + if decision.reason == "assigned": + return "This issue is already assigned to {}.".format( + format_users(decision.assignees) + ) + return "This issue cannot be claimed." + + if decision.reason == "unassigned": + return "This issue has no assignee to remove." + if decision.reason == "unauthorized": + return "Only a current assignee or repository maintainer can use `/unclaim`." + return "This issue cannot be unclaimed." + + +def post_result(client, context, message, state=None, claimant=None): + markers = [context.marker] + if state and claimant: + markers.append( + "".format(state, claimant) + ) + body = "{}\n{}".format("\n".join(markers), message) + client.create_comment(context.repository, context.issue_number, body) + + +def update_progress_label(client, context, add): + label = client.get_label(context.repository, PROGRESS_LABEL) + if not label: + return + label_name = label.get("name", PROGRESS_LABEL) + if add: + client.add_labels(context.repository, context.issue_number, (label_name,)) + else: + client.remove_label(context.repository, context.issue_number, label_name) + + +def handle_claim(client, context, comments): + initial_issue = client.get_issue(context.repository, context.issue_number) + initial = decide_claim(initial_issue, active_claimant(comments)) + if not initial.allowed: + post_result(client, context, rejection_message("claim", initial)) + return initial.reason + + latest_comments = client.list_comments(context.repository, context.issue_number) + latest_issue = client.get_issue(context.repository, context.issue_number) + latest = decide_claim(latest_issue, active_claimant(latest_comments)) + if not latest.allowed: + post_result(client, context, rejection_message("claim", latest)) + return "race_{}".format(latest.reason) + + assignable = client.can_assign(context.repository, context.actor) + if assignable: + client.add_assignees(context.repository, context.issue_number, (context.actor,)) + confirmed_issue = client.get_issue(context.repository, context.issue_number) + confirmed_assignees = assignee_logins(confirmed_issue) + actor_is_assigned = any( + login.casefold() == context.actor.casefold() for login in confirmed_assignees + ) + other_assignees = tuple( + login + for login in confirmed_assignees + if login.casefold() != context.actor.casefold() + ) + if (assignable and not actor_is_assigned) or other_assignees: + if assignable and actor_is_assigned: + client.remove_assignees( + context.repository, context.issue_number, (context.actor,) + ) + decision = Decision(False, "assigned", other_assignees or confirmed_assignees) + post_result(client, context, rejection_message("claim", decision)) + return "race_assigned" + + if assignable: + update_progress_label(client, context, add=True) + message = "@{} has claimed this issue.".format(context.actor) + result = "claimed" + post_result(client, context, message) + else: + message = ( + "@{} has claimed this issue. GitHub cannot assign this account, so " + "the claim is tracked by this comment and the `in progress` label." + ).format(context.actor) + result = "claimed_by_comment" + post_result( + client, + context, + message, + state="claimed", + claimant=context.actor, + ) + update_progress_label(client, context, add=True) + return result + + +def handle_unclaim(client, context, comments): + permission = client.get_permission(context.repository, context.actor) + maintainer = has_maintainer_permission(permission) + initial_issue = client.get_issue(context.repository, context.issue_number) + initial = decide_unclaim( + initial_issue, context.actor, maintainer, active_claimant(comments) + ) + if not initial.allowed: + post_result(client, context, rejection_message("unclaim", initial)) + return initial.reason + + latest_comments = client.list_comments(context.repository, context.issue_number) + latest_issue = client.get_issue(context.repository, context.issue_number) + latest_claimant = active_claimant(latest_comments) + latest = decide_unclaim( + latest_issue, context.actor, maintainer, latest_claimant + ) + if not latest.allowed: + post_result(client, context, rejection_message("unclaim", latest)) + return "race_{}".format(latest.reason) + + if latest.assignees: + client.remove_assignees( + context.repository, context.issue_number, latest.assignees + ) + confirmed_issue = client.get_issue(context.repository, context.issue_number) + remaining = assignee_logins(confirmed_issue) + removed = {login.casefold() for login in latest.assignees}.isdisjoint( + login.casefold() for login in remaining + ) + if not removed: + raise GitHubAPIError(None, "assignee removal was not applied") + + release_logical = bool( + latest_claimant + and ( + maintainer + or latest_claimant.casefold() == context.actor.casefold() + ) + ) + released_users = list(latest.assignees) + if release_logical: + released_users.append(latest_claimant) + post_result( + client, + context, + "Released this issue from {}.".format( + format_users(dict.fromkeys(released_users)) + ), + state="unclaimed" if release_logical else None, + claimant=latest_claimant if release_logical else None, + ) + if not remaining and not (latest_claimant and not release_logical): + update_progress_label(client, context, add=False) + return "unclaimed" + + +def handle_event(event, client): + context = event_context(event) + if context is None: + return "ignored" + + comments = client.list_comments(context.repository, context.issue_number) + if already_processed(comments, context.marker): + return "duplicate" + + if context.command == "claim": + return handle_claim(client, context, comments) + return handle_unclaim(client, context, comments) + + +def load_event(path): + with open(path, "r", encoding="utf-8") as event_file: + return json.load(event_file) + + +def main(): + event_path = os.getenv("GITHUB_EVENT_PATH") + if not event_path: + print("GITHUB_EVENT_PATH is required", file=sys.stderr) + return 2 + + try: + event = load_event(event_path) + except (OSError, ValueError) as error: + print("Unable to read GitHub event: {}".format(error), file=sys.stderr) + return 2 + + if event_context(event) is None: + print("Ignoring event: no supported issue command") + return 0 + + try: + client = GitHubClient( + os.getenv("GITHUB_TOKEN", ""), + os.getenv("GITHUB_API_URL", "https://api.github.com"), + ) + result = handle_event(event, client) + except (GitHubAPIError, ValueError) as error: + print(str(error), file=sys.stderr) + return 1 + + print("Issue command result: {}".format(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ci/issue_labeler.py b/tools/ci/issue_labeler.py new file mode 100644 index 00000000000..d3878942e1f --- /dev/null +++ b/tools/ci/issue_labeler.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026, RT-Thread Development Team +# +# SPDX-License-Identifier: Apache-2.0 +# + +"""Add existing RT-Thread labels from structured GitHub issue-form fields.""" + +import json +import os +import re +import sys +import unicodedata +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + + +API_VERSION = "2022-11-28" +FORM_SECTION = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) + +AREA_LABELS = { + "kernel": ("Kernel",), + "board support package (bsp)": ("BSP",), + "device drivers": ("Component", "component: drivers"), + "file systems (dfs)": ("Component",), + "networking": ("Component", "component: net"), + "ipc": ("Component", "component: ipc"), + "finsh": ("Component", "component: finsh"), + "rt-smart": ("RT-Smart",), + "architecture / libcpu": ("libcpu",), + "documentation": ("Doc",), + "build system and tools": ("tools",), + "ci / github actions": ("action",), + "tests and examples": ("testcase",), +} + +VENDOR_LABELS = { + "stm32": ("BSP", "BSP: STM32"), + "nxp": ("BSP", "BSP: NXP"), + "gd32": ("BSP", "BSP: GD32"), + "at32": ("BSP", "BSP: AT32"), + "infineon": ("BSP", "BSP: Infineon"), + "renesas": ("BSP", "BSP: Renesas"), + "phytium": ("BSP", "BSP: Phytium"), + "cvitek": ("BSP", "BSP: Cvitek"), + "wch": ("BSP", "BSP: WCH"), + "raspberry pi": ("BSP", "BSP: Raspberry-Pi"), + "rockchip": ("BSP", "BSP: Rockchip"), + "loongson": ("BSP", "BSP: Loongson"), + "esp32": ("BSP", "BSP: ESP32"), + "hc32": ("BSP", "BSP: HC32"), + "novosns": ("BSP", "BSP: Novosns"), + "hpmicro": ("BSP", "BSP: HPMicro"), + "bluetrum": ("BSP", "BSP: Bluetrum"), + "k230": ("BSP", "BSP: K230"), + "nuvoton": ("BSP", "BSP: Nuvoton"), +} + +ARCHITECTURE_LABELS = { + "arm / aarch64": ("Arch: ARM/AArch64",), + "risc-v": ("Arch: RISC-V",), +} + +FIELD_MAPPINGS = { + "affected area": AREA_LABELS, + "hardware/bsp vendor": VENDOR_LABELS, + "architecture": ARCHITECTURE_LABELS, +} + +MUTUALLY_EXCLUSIVE_FIELDS = { + "hardware/bsp vendor": frozenset( + label.casefold() + for labels in VENDOR_LABELS.values() + for label in labels + if label.casefold().startswith("bsp:") + ), + "architecture": frozenset( + label.casefold() + for labels in ARCHITECTURE_LABELS.values() + for label in labels + ), +} +EMPTY_FIELD_VALUES = frozenset(("not applicable / other",)) + + +@dataclass(frozen=True) +class EventContext: + repository: str + issue_number: int + 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-issue-labeler", + "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 list_labels(self, repository): + labels = [] + repository_path = self.repository_path(repository) + for page in range(1, 11): + query = urlencode({"per_page": 100, "page": page}) + batch = self.request("GET", "{}/labels?{}".format(repository_path, query)) + labels.extend(batch) + if len(batch) < 100: + break + return labels + + def get_issue(self, repository, issue_number): + return self.request( + "GET", + "{}/issues/{}".format(self.repository_path(repository), issue_number), + ) + + def add_labels(self, repository, issue_number, labels): + return self.request( + "POST", + "{}/issues/{}/labels".format( + self.repository_path(repository), issue_number + ), + {"labels": list(labels)}, + ) + + def remove_label(self, repository, issue_number, label): + try: + return self.request( + "DELETE", + "{}/issues/{}/labels/{}".format( + self.repository_path(repository), + issue_number, + quote(label, safe=""), + ), + ) + except GitHubAPIError as error: + if error.status == 404: + return None + raise + + +def normalize(value): + return " ".join( + unicodedata.normalize("NFKC", str(value or "")).strip().casefold().split() + ) + + +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) + value = text[start:end].strip() + if value == "_No response_": + value = "" + sections[normalize(match.group(1))] = value + return sections + + +def desired_labels(body): + sections = parse_form_sections(body) + result = [] + seen = set() + for field, mapping in FIELD_MAPPINGS.items(): + value = normalize(sections.get(field, "")) + for label in mapping.get(value, ()): + key = label.casefold() + if key not in seen: + seen.add(key) + result.append(label) + return tuple(result) + + +def event_context(event): + if not isinstance(event, dict): + return None + if event.get("action") not in ("opened", "edited", "reopened"): + return None + issue = event.get("issue") or {} + if "pull_request" in issue: + return None + repository = (event.get("repository") or {}).get("full_name") + try: + issue_number = int(issue["number"]) + except (KeyError, TypeError, ValueError): + return None + if not repository or repository.count("/") != 1: + return None + return EventContext(repository, issue_number, issue) + + +def existing_label_names(issue): + names = set() + for label in issue.get("labels") or []: + name = label.get("name") if isinstance(label, dict) else label + if isinstance(name, str): + names.add(name.casefold()) + return names + + +def existing_label_map(issue): + labels = {} + for label in issue.get("labels") or []: + name = label.get("name") if isinstance(label, dict) else label + if isinstance(name, str): + labels[name.casefold()] = name + return labels + + +def obsolete_labels(body, issue): + sections = parse_form_sections(body) + existing = existing_label_map(issue) + obsolete = [] + for field, managed in MUTUALLY_EXCLUSIVE_FIELDS.items(): + value = normalize(sections.get(field, "")) + mapping = FIELD_MAPPINGS[field] + if value not in mapping and value not in EMPTY_FIELD_VALUES: + continue + selected = {label.casefold() for label in mapping.get(value, ())} + obsolete.extend( + existing[label] + for label in sorted(managed) + if label in existing and label not in selected + ) + return tuple(obsolete) + + +def apply_labels(client, context, dry_run=False): + issue = client.get_issue(context.repository, context.issue_number) or context.issue + requested = desired_labels(issue.get("body", "")) + available = { + label["name"].casefold(): label["name"] + for label in client.list_labels(context.repository) + if isinstance(label, dict) and isinstance(label.get("name"), str) + } + existing = existing_label_names(issue) + missing = [label for label in requested if label.casefold() not in available] + to_add = [ + available[label.casefold()] + for label in requested + if label.casefold() in available and label.casefold() not in existing + ] + to_remove = list(obsolete_labels(issue.get("body", ""), issue)) + + if missing: + print("Skipping labels that do not exist: {}".format(", ".join(missing))) + if to_add and not dry_run: + client.add_labels(context.repository, context.issue_number, to_add) + if to_remove and not dry_run: + for label in to_remove: + client.remove_label(context.repository, context.issue_number, label) + + return { + "requested": list(requested), + "added": to_add, + "removed": to_remove, + "missing": missing, + } + + +def load_event(path): + with open(path, "r", encoding="utf-8") as event_file: + return json.load(event_file) + + +def main(): + event_path = os.getenv("GITHUB_EVENT_PATH") + if not event_path: + print("GITHUB_EVENT_PATH is required", file=sys.stderr) + return 2 + try: + event = load_event(event_path) + except (OSError, ValueError) as error: + print("Unable to read GitHub event: {}".format(error), file=sys.stderr) + return 2 + + context = event_context(event) + if context is None: + print("Ignoring event: no supported issue") + return 0 + + try: + client = GitHubClient( + os.getenv("GITHUB_TOKEN", ""), + os.getenv("GITHUB_API_URL", "https://api.github.com"), + ) + result = apply_labels( + client, + context, + dry_run=normalize(os.getenv("DRY_RUN", "")) in ("1", "true", "yes"), + ) + except (GitHubAPIError, ValueError) as error: + print(str(error), file=sys.stderr) + return 1 + + print(json.dumps(result, ensure_ascii=True, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ci/similar_issues.py b/tools/ci/similar_issues.py new file mode 100644 index 00000000000..66a6c1f5440 --- /dev/null +++ b/tools/ci/similar_issues.py @@ -0,0 +1,622 @@ +#!/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 = "" +CLAIM_HINT_MARKER = "" +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()) diff --git a/tools/ci/test_issue_claim.py b/tools/ci/test_issue_claim.py new file mode 100644 index 00000000000..9aa265bbdcb --- /dev/null +++ b/tools/ci/test_issue_claim.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026, RT-Thread Development Team +# +# SPDX-License-Identifier: Apache-2.0 +# + +import unittest + +import issue_claim + + +def make_issue(state="open", labels=(), assignees=()): + return { + "state": state, + "labels": [{"name": label} for label in labels], + "assignees": [{"login": login} for login in assignees], + } + + +def make_event(command="/claim", actor="alice", comment_id=100): + return { + "action": "created", + "repository": {"full_name": "RT-Thread/rt-thread"}, + "issue": {"number": 42}, + "comment": { + "id": comment_id, + "body": command, + "created_at": "2026-07-23T08:00:00Z", + "user": {"login": actor, "type": "User"}, + }, + "sender": {"login": actor, "type": "User"}, + } + + +class FakeClient: + def __init__( + self, + issues, + permission=None, + comments=None, + progress_label=None, + assignable=True, + ): + self.issues = list(issues) + self.permission = permission or {} + self.comments = comments or [] + self.progress_label = progress_label + self.assignable = assignable + self.calls = [] + self.current_issue = self.issues[-1] if self.issues else make_issue() + + def list_comments(self, repository, issue_number): + self.calls.append(("list_comments", repository, issue_number)) + return self.comments + + def get_issue(self, repository, issue_number): + self.calls.append(("get_issue", repository, issue_number)) + if self.issues: + self.current_issue = self.issues.pop(0) + return self.current_issue + + def create_comment(self, repository, issue_number, body): + self.calls.append(("create_comment", repository, issue_number, body)) + + def add_assignees(self, repository, issue_number, assignees): + self.calls.append(("add_assignees", repository, issue_number, tuple(assignees))) + + def remove_assignees(self, repository, issue_number, assignees): + self.calls.append( + ("remove_assignees", repository, issue_number, tuple(assignees)) + ) + + def get_permission(self, repository, actor): + self.calls.append(("get_permission", repository, actor)) + return self.permission + + def can_assign(self, repository, actor): + self.calls.append(("can_assign", repository, actor)) + return self.assignable + + def get_label(self, repository, label): + self.calls.append(("get_label", repository, label)) + return self.progress_label + + def add_labels(self, repository, issue_number, labels): + self.calls.append(("add_labels", repository, issue_number, tuple(labels))) + + def remove_label(self, repository, issue_number, label): + self.calls.append(("remove_label", repository, issue_number, label)) + + +class ClientTests(unittest.TestCase): + def test_comment_creation_disables_automatic_retries(self): + class RecordingClient(issue_claim.GitHubClient): + def __init__(self): + self.call = None + + def request(self, *args, **kwargs): + self.call = (args, kwargs) + + client = RecordingClient() + client.create_comment("RT-Thread/rt-thread", 42, "result") + + self.assertFalse(client.call[1]["retry"]) + + def test_comment_pagination_reads_past_five_hundred(self): + class PagingClient(issue_claim.GitHubClient): + def __init__(self): + self.pages = 0 + + def request(self, *args, **kwargs): + self.pages += 1 + size = 100 if self.pages <= 5 else 1 + return [{"id": self.pages * 100 + index} for index in range(size)] + + client = PagingClient() + comments = client.list_comments("RT-Thread/rt-thread", 42) + + self.assertEqual(len(comments), 501) + self.assertEqual(client.pages, 6) + + +class CommandParsingTests(unittest.TestCase): + def test_accepts_only_standalone_commands(self): + self.assertEqual(issue_claim.parse_command("/claim"), "claim") + self.assertEqual(issue_claim.parse_command("/UNCLAIM"), "unclaim") + for body in ( + " /claim", + "/claim\n", + "/claim me", + "please /claim", + "/claim\nthanks", + "`/claim`", + "", + ): + self.assertIsNone(issue_claim.parse_command(body)) + + def test_filters_pull_requests_bots_and_other_actions(self): + pull_request = make_event() + pull_request["issue"]["pull_request"] = {"url": "https://example.test/pr/42"} + self.assertIsNone(issue_claim.event_context(pull_request)) + + bot = make_event(actor="helper[bot]") + self.assertIsNone(issue_claim.event_context(bot)) + + edited = make_event() + edited["action"] = "edited" + self.assertIsNone(issue_claim.event_context(edited)) + + +class PermissionTests(unittest.TestCase): + def test_only_admin_and_maintain_roles_are_maintainers(self): + self.assertTrue( + issue_claim.has_maintainer_permission({"role_name": "maintain"}) + ) + self.assertTrue(issue_claim.has_maintainer_permission({"permission": "admin"})) + self.assertTrue( + issue_claim.has_maintainer_permission( + {"user": {"permissions": {"maintain": True}}} + ) + ) + self.assertFalse(issue_claim.has_maintainer_permission({"permission": "write"})) + self.assertFalse(issue_claim.has_maintainer_permission({"role_name": "triage"})) + + def test_unclaim_targets_self_or_all_for_maintainer(self): + own = issue_claim.decide_unclaim( + make_issue(assignees=("alice", "bob")), "Alice" + ) + self.assertTrue(own.allowed) + self.assertEqual(own.assignees, ("alice",)) + + denied = issue_claim.decide_unclaim(make_issue(assignees=("bob",)), "alice") + self.assertFalse(denied.allowed) + self.assertEqual(denied.reason, "unauthorized") + + maintainer = issue_claim.decide_unclaim( + make_issue(assignees=("bob", "carol")), "alice", maintainer=True + ) + self.assertTrue(maintainer.allowed) + self.assertEqual(maintainer.assignees, ("bob", "carol")) + + +class ClaimStateTests(unittest.TestCase): + def test_claim_requires_open_unassigned_issue(self): + self.assertTrue(issue_claim.decide_claim(make_issue()).allowed) + self.assertTrue( + issue_claim.decide_claim(make_issue(labels=("bug",))).allowed + ) + self.assertEqual( + issue_claim.decide_claim(make_issue(state="closed")).reason, "closed" + ) + self.assertEqual( + issue_claim.decide_claim(make_issue(assignees=("bob",))).reason, + "assigned", + ) + + def test_duplicate_marker_must_come_from_a_bot(self): + marker = "" + user_comment = {"body": marker, "user": {"login": "alice", "type": "User"}} + bot_comment = { + "body": marker + "\nDone", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + other_bot_comment = { + "body": marker, + "user": {"login": "helper[bot]", "type": "Bot"}, + } + self.assertFalse(issue_claim.already_processed([user_comment], marker)) + self.assertFalse(issue_claim.already_processed([other_bot_comment], marker)) + self.assertTrue(issue_claim.already_processed([bot_comment], marker)) + + def test_last_bot_state_marker_controls_logical_claim(self): + comments = [ + { + "body": "", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + }, + { + "body": "", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + }, + { + "body": "", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + }, + ] + self.assertEqual(issue_claim.active_claimant(comments), "bob") + + def test_unclaim_marker_only_clears_the_matching_logical_claim(self): + comments = [ + { + "body": "", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + }, + { + "body": "", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + }, + ] + self.assertEqual(issue_claim.active_claimant(comments), "bob") + + +class HandlerTests(unittest.TestCase): + def test_successful_claim_rechecks_and_confirms_state(self): + client = FakeClient( + [ + make_issue(), + make_issue(), + make_issue(assignees=("alice",)), + ], + progress_label={"name": "in progress"}, + ) + result = issue_claim.handle_event(make_event(), client) + + self.assertEqual(result, "claimed") + self.assertIn( + ("add_assignees", "RT-Thread/rt-thread", 42, ("alice",)), + client.calls, + ) + self.assertIn( + ("add_labels", "RT-Thread/rt-thread", 42, ("in progress",)), + client.calls, + ) + comment = [call for call in client.calls if call[0] == "create_comment"][-1] + self.assertIn("", comment[3]) + self.assertNotIn("state:claimed", comment[3]) + + def test_unassignable_user_is_tracked_by_comment_and_label(self): + client = FakeClient( + [make_issue(), make_issue(), make_issue()], + progress_label={"name": "in progress"}, + assignable=False, + ) + + result = issue_claim.handle_event(make_event(), client) + + self.assertEqual(result, "claimed_by_comment") + self.assertFalse(any(call[0] == "add_assignees" for call in client.calls)) + self.assertIn( + ("add_labels", "RT-Thread/rt-thread", 42, ("in progress",)), + client.calls, + ) + comment = [call for call in client.calls if call[0] == "create_comment"][-1] + self.assertIn("state:claimed:alice", comment[3]) + + def test_existing_logical_claim_blocks_another_user(self): + comments = [ + { + "body": "", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + ] + client = FakeClient([make_issue()], comments=comments) + + result = issue_claim.handle_event(make_event(actor="alice"), client) + + self.assertEqual(result, "assigned") + self.assertFalse(any(call[0] == "add_assignees" for call in client.calls)) + comment = [call for call in client.calls if call[0] == "create_comment"][-1] + self.assertIn("@bob", comment[3]) + + def test_concurrent_claim_is_rejected_before_mutation(self): + client = FakeClient( + [make_issue(), make_issue(assignees=("bob",))] + ) + result = issue_claim.handle_event(make_event(), client) + + self.assertEqual(result, "race_assigned") + self.assertFalse(any(call[0] == "add_assignees" for call in client.calls)) + comment = [call for call in client.calls if call[0] == "create_comment"][-1] + self.assertIn("@bob", comment[3]) + + def test_post_assignment_race_rolls_back_actor(self): + client = FakeClient( + [ + make_issue(), + make_issue(), + make_issue(assignees=("alice", "bob")), + ] + ) + result = issue_claim.handle_event(make_event(), client) + + self.assertEqual(result, "race_assigned") + self.assertIn( + ("remove_assignees", "RT-Thread/rt-thread", 42, ("alice",)), + client.calls, + ) + self.assertFalse(any(call[0] == "add_labels" for call in client.calls)) + + def test_non_assignee_cannot_unclaim(self): + client = FakeClient([make_issue(assignees=("bob",))]) + result = issue_claim.handle_event(make_event("/unclaim"), client) + + self.assertEqual(result, "unauthorized") + self.assertFalse(any(call[0] == "remove_assignees" for call in client.calls)) + + def test_assignee_can_unclaim_and_remove_existing_progress_label(self): + client = FakeClient( + [ + make_issue(assignees=("alice",)), + make_issue(assignees=("alice",)), + make_issue(assignees=()), + ], + progress_label={"name": "in progress"}, + ) + result = issue_claim.handle_event(make_event("/unclaim"), client) + + self.assertEqual(result, "unclaimed") + self.assertIn( + ("remove_assignees", "RT-Thread/rt-thread", 42, ("alice",)), + client.calls, + ) + self.assertIn( + ("remove_label", "RT-Thread/rt-thread", 42, "in progress"), + client.calls, + ) + + def test_logical_claimant_can_unclaim_without_being_an_assignee(self): + comments = [ + { + "body": "", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + ] + client = FakeClient( + [make_issue(), make_issue(), make_issue()], + comments=comments, + progress_label={"name": "in progress"}, + assignable=False, + ) + + result = issue_claim.handle_event(make_event("/unclaim"), client) + + self.assertEqual(result, "unclaimed") + self.assertFalse(any(call[0] == "remove_assignees" for call in client.calls)) + self.assertIn( + ("remove_label", "RT-Thread/rt-thread", 42, "in progress"), + client.calls, + ) + comment = [call for call in client.calls if call[0] == "create_comment"][-1] + self.assertIn("state:unclaimed:alice", comment[3]) + + def test_actual_assignee_unclaim_does_not_clear_another_logical_claim(self): + comments = [ + { + "body": "", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + ] + client = FakeClient( + [ + make_issue(assignees=("alice",)), + make_issue(assignees=("alice",)), + make_issue(), + ], + comments=comments, + progress_label={"name": "in progress"}, + ) + + result = issue_claim.handle_event(make_event("/unclaim"), client) + + self.assertEqual(result, "unclaimed") + self.assertFalse(any(call[0] == "remove_label" for call in client.calls)) + comment = [call for call in client.calls if call[0] == "create_comment"][-1] + self.assertNotIn("state:unclaimed:bob", comment[3]) + + def test_logical_unclaim_preserves_an_unrelated_actual_assignee(self): + comments = [ + { + "body": "", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + ] + client = FakeClient( + [ + make_issue(assignees=("alice",)), + make_issue(assignees=("alice",)), + make_issue(assignees=("alice",)), + ], + comments=comments, + progress_label={"name": "in progress"}, + ) + + result = issue_claim.handle_event( + make_event("/unclaim", actor="bob"), client + ) + + self.assertEqual(result, "unclaimed") + self.assertFalse(any(call[0] == "remove_assignees" for call in client.calls)) + self.assertFalse(any(call[0] == "remove_label" for call in client.calls)) + comment = [call for call in client.calls if call[0] == "create_comment"][-1] + self.assertIn("state:unclaimed:bob", comment[3]) + + def test_duplicate_event_does_not_read_or_mutate_issue(self): + marker = "" + client = FakeClient( + [make_issue()], + comments=[ + { + "body": marker, + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + ], + ) + result = issue_claim.handle_event(make_event(), client) + + self.assertEqual(result, "duplicate") + self.assertFalse(any(call[0] == "get_issue" for call in client.calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ci/test_issue_labeler.py b/tools/ci/test_issue_labeler.py new file mode 100644 index 00000000000..6286cb487b6 --- /dev/null +++ b/tools/ci/test_issue_labeler.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026, RT-Thread Development Team +# +# SPDX-License-Identifier: Apache-2.0 +# + +import unittest + +import issue_labeler + + +def make_event(body, labels=(), action="opened"): + return { + "action": action, + "repository": {"full_name": "RT-Thread/rt-thread"}, + "issue": { + "number": 42, + "body": body, + "labels": [{"name": label} for label in labels], + }, + } + + +class FakeClient: + def __init__(self, available_labels, current_issue=None): + self.available_labels = [{"name": label} for label in available_labels] + self.current_issue = current_issue + self.calls = [] + + def list_labels(self, repository): + self.calls.append(("list_labels", repository)) + return self.available_labels + + def add_labels(self, repository, issue_number, labels): + self.calls.append(("add_labels", repository, issue_number, tuple(labels))) + + def get_issue(self, repository, issue_number): + self.calls.append(("get_issue", repository, issue_number)) + return self.current_issue + + def remove_label(self, repository, issue_number, label): + self.calls.append(("remove_label", repository, issue_number, label)) + + +class FormParsingTests(unittest.TestCase): + def test_maps_only_structured_allowlisted_values(self): + body = """### Affected area + +Device drivers + +### Hardware/BSP vendor + +STM32 + +### Architecture + +ARM / AArch64 + +### Describe the bug + +Mentions GD32 and RISC-V here, but free text must not add labels. +""" + self.assertEqual( + issue_labeler.desired_labels(body), + ( + "Component", + "component: drivers", + "BSP", + "BSP: STM32", + "Arch: ARM/AArch64", + ), + ) + + def test_unknown_and_no_response_values_are_ignored(self): + body = """### Affected area + +Other / Not sure + +### Hardware/BSP vendor + +_No response_ + +### Architecture + +x86 +""" + self.assertEqual(issue_labeler.desired_labels(body), ()) + + def test_headings_and_values_are_case_insensitive(self): + body = "### AFFECTED AREA\n\nnetworking\n\n### Architecture\n\nrisc-v\n" + self.assertEqual( + issue_labeler.desired_labels(body), + ("Component", "component: net", "Arch: RISC-V"), + ) + + +class EventTests(unittest.TestCase): + def test_accepts_supported_issue_actions(self): + context = issue_labeler.event_context(make_event("", action="edited")) + self.assertEqual(context.issue_number, 42) + + def test_rejects_pull_requests_and_other_actions(self): + pull_request = make_event("") + pull_request["issue"]["pull_request"] = {"url": "https://example.test"} + self.assertIsNone(issue_labeler.event_context(pull_request)) + self.assertIsNone( + issue_labeler.event_context(make_event("", action="closed")) + ) + + +class LabelApplicationTests(unittest.TestCase): + def test_adds_only_existing_labels_and_preserves_current_labels(self): + body = """### Affected area + +Kernel + +### Hardware/BSP vendor + +GD32 +""" + context = issue_labeler.event_context( + make_event(body, labels=("Kernel", "bug")) + ) + client = FakeClient(("Kernel", "BSP", "bug")) + + result = issue_labeler.apply_labels(client, context) + + self.assertEqual(result["added"], ["BSP"]) + self.assertEqual(result["missing"], ["BSP: GD32"]) + self.assertIn( + ("add_labels", "RT-Thread/rt-thread", 42, ("BSP",)), client.calls + ) + + def test_replaces_stale_vendor_and_architecture_labels(self): + body = """### Hardware/BSP vendor + +NXP + +### Architecture + +RISC-V +""" + context = issue_labeler.event_context( + make_event( + body, + labels=("bug", "BSP", "BSP: STM32", "Arch: ARM/AArch64"), + ) + ) + client = FakeClient( + ( + "bug", + "BSP", + "BSP: STM32", + "BSP: NXP", + "Arch: ARM/AArch64", + "Arch: RISC-V", + ), + current_issue=context.issue, + ) + + result = issue_labeler.apply_labels(client, context) + + self.assertEqual(result["added"], ["BSP: NXP", "Arch: RISC-V"]) + self.assertEqual(result["removed"], ["BSP: STM32", "Arch: ARM/AArch64"]) + self.assertIn( + ("remove_label", "RT-Thread/rt-thread", 42, "BSP: STM32"), + client.calls, + ) + self.assertIn( + ("remove_label", "RT-Thread/rt-thread", 42, "Arch: ARM/AArch64"), + client.calls, + ) + + def test_dry_run_reports_without_mutating(self): + body = "### Affected area\n\nDocumentation\n" + context = issue_labeler.event_context(make_event(body)) + client = FakeClient(("Doc",)) + + result = issue_labeler.apply_labels(client, context, dry_run=True) + + self.assertEqual(result["added"], ["Doc"]) + self.assertFalse(any(call[0] == "add_labels" for call in client.calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ci/test_similar_issues.py b/tools/ci/test_similar_issues.py new file mode 100644 index 00000000000..3eec6190dbe --- /dev/null +++ b/tools/ci/test_similar_issues.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026, RT-Thread Development Team +# +# SPDX-License-Identifier: Apache-2.0 +# + +import unittest + +import similar_issues + + +def make_issue(number, title, body="", labels=(), assignees=()): + return { + "number": number, + "title": title, + "body": body, + "labels": [{"name": label} for label in labels], + "assignees": [{"login": login} for login in assignees], + } + + +class FakeClient: + def __init__( + self, + search_items=(), + comments=(), + open_issues=(), + search_error=None, + comment_batches=None, + ): + self.search_items = list(search_items) + self.comments = list(comments) + self.open_issues = list(open_issues) + self.search_error = search_error + self.comment_batches = ( + [list(batch) for batch in comment_batches] + if comment_batches is not None + else None + ) + self.calls = [] + + def list_comments(self, repository, issue_number): + self.calls.append(("list_comments", repository, issue_number)) + if self.comment_batches: + return self.comment_batches.pop(0) + return self.comments + + def search_issues(self, repository, query): + self.calls.append(("search_issues", repository, query)) + if self.search_error: + raise self.search_error + return {"items": self.search_items} + + def create_comment(self, repository, issue_number, body): + self.calls.append(("create_comment", repository, issue_number, body)) + + def list_open_issues(self, repository, limit): + self.calls.append(("list_open_issues", repository, limit)) + return self.open_issues[:limit] + + +class TextTests(unittest.TestCase): + def test_ignores_issue_form_metadata(self): + body = """### RT-Thread Version + +v5.2.2 + +### Hardware/BSP vendor + +STM32 + +### Describe the bug + +rt_event_send resumes a timed out thread twice. +""" + relevant = similar_issues.relevant_body(body) + self.assertNotIn("STM32", relevant) + self.assertNotIn("v5.2.2", relevant) + self.assertIn("rt_event_send", relevant) + + def test_search_terms_prioritize_identifiers(self): + issue = make_issue(1, "[Bug] rt_event_send timeout race resumes thread twice") + self.assertEqual(similar_issues.search_terms(issue), "rt_event_send") + + +class RankingTests(unittest.TestCase): + def test_accepts_matching_technical_issue(self): + issue = make_issue( + 10, + "[Bug] rt_event_send can resume a timed out thread twice", + "The event send path races with a thread timeout.", + labels=("Kernel",), + ) + candidate = make_issue( + 9, + "rt_event_send timeout race resumes thread twice", + "A timed out thread can be resumed twice by event send.", + labels=("Kernel",), + ) + result = similar_issues.score_candidate(issue, candidate) + self.assertTrue(result["accepted"]) + + def test_accepts_similar_chinese_issue(self): + issue = make_issue(10, "[Bug] rt_vsnprintf格式化long long数值错误") + candidate = make_issue(9, "rt_vsnprintf处理long long格式化结果异常") + self.assertTrue(similar_issues.score_candidate(issue, candidate)["accepted"]) + + def test_rejects_unrelated_and_conflicting_bsp_issues(self): + issue = make_issue( + 10, "STM32 UART interrupt loses bytes", labels=("BSP: STM32",) + ) + unrelated = make_issue(9, "Update documentation links", labels=("Doc",)) + conflicting = make_issue( + 8, "STM32 UART interrupt loses bytes", labels=("BSP: NXP",) + ) + self.assertFalse(similar_issues.score_candidate(issue, unrelated)["accepted"]) + self.assertEqual( + similar_issues.score_candidate(issue, conflicting)["reason"], + "bsp-mismatch", + ) + + def test_derives_bsp_labels_from_structured_form_fields(self): + issue = make_issue( + 10, + "UART interrupt loses bytes", + "### Hardware/BSP vendor\n\nSTM32\n", + labels=("BSP: NXP",), + ) + candidate = make_issue( + 9, + "UART interrupt loses bytes", + "### Hardware/BSP vendor\n\nNXP\n", + ) + + result = similar_issues.score_candidate(issue, candidate) + + self.assertFalse(result["accepted"]) + self.assertEqual(result["reason"], "bsp-mismatch") + + def test_excludes_current_pr_and_limits_results(self): + issue = make_issue(10, "rt_event_send timeout race resumes thread twice") + candidates = [ + make_issue(number, "rt_event_send timeout race resumes thread twice") + for number in range(5, 11) + ] + pull_request = make_issue(4, "rt_event_send timeout race resumes thread twice") + pull_request["pull_request"] = {} + ranked = similar_issues.rank_candidates(issue, candidates + [pull_request]) + self.assertEqual(len(ranked), 3) + self.assertNotIn(10, [item["candidate"]["number"] for item in ranked]) + + +class SuggestionTests(unittest.TestCase): + def test_marker_makes_comment_idempotent(self): + issue = make_issue(10, "rt_event_send timeout race resumes thread twice") + client = FakeClient( + comments=[ + { + "body": similar_issues.COMMENT_MARKER, + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + ] + ) + self.assertEqual( + similar_issues.suggest_for_issue( + client, "RT-Thread/rt-thread", issue + ), + [], + ) + self.assertFalse(any(call[0] == "search_issues" for call in client.calls)) + + def test_user_cannot_spoof_the_idempotency_marker(self): + issue = make_issue(10, "rt_event_send timeout race resumes thread twice") + candidate = make_issue(9, "rt_event_send timeout race resumes thread twice") + client = FakeClient( + search_items=[candidate], + comments=[ + { + "body": similar_issues.COMMENT_MARKER, + "user": {"login": "alice", "type": "User"}, + } + ], + ) + + ranked = similar_issues.suggest_for_issue( + client, "RT-Thread/rt-thread", issue + ) + + self.assertEqual(len(ranked), 1) + self.assertTrue(any(call[0] == "create_comment" for call in client.calls)) + + def test_rechecks_marker_before_creating_a_comment(self): + issue = make_issue(10, "rt_event_send timeout race resumes thread twice") + candidate = make_issue(9, "rt_event_send timeout race resumes thread twice") + bot_marker = { + "body": similar_issues.COMMENT_MARKER, + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + client = FakeClient( + search_items=[candidate], comment_batches=[[], [bot_marker]] + ) + + ranked = similar_issues.suggest_for_issue( + client, "RT-Thread/rt-thread", issue + ) + + self.assertEqual(ranked, []) + comments = [call for call in client.calls if call[0] == "create_comment"] + self.assertEqual(len(comments), 1) + self.assertIn(similar_issues.CLAIM_HINT_MARKER, comments[0][3]) + + def test_dry_run_does_not_create_comment(self): + issue = make_issue(10, "rt_event_send timeout race resumes thread twice") + candidate = make_issue(9, "rt_event_send timeout race resumes thread twice") + client = FakeClient(search_items=[candidate]) + ranked = similar_issues.suggest_for_issue( + client, "RT-Thread/rt-thread", issue, dry_run=True + ) + self.assertEqual(len(ranked), 1) + self.assertFalse(any(call[0] == "create_comment" for call in client.calls)) + + def test_claim_hint_is_posted_when_no_matches(self): + issue = make_issue(10, "rt_event_send timeout race resumes thread twice") + client = FakeClient() + + ranked = similar_issues.suggest_for_issue( + client, "RT-Thread/rt-thread", issue + ) + + self.assertEqual(ranked, []) + comments = [call for call in client.calls if call[0] == "create_comment"] + self.assertEqual(len(comments), 1) + self.assertIn(similar_issues.CLAIM_HINT_MARKER, comments[0][3]) + self.assertIn("如需认领", comments[0][3]) + self.assertIn("`/claim`", comments[0][3]) + + def test_claim_hint_is_separate_from_similar_issue_comment(self): + issue = make_issue(10, "rt_event_send timeout race resumes thread twice") + candidate = make_issue(9, "rt_event_send timeout race resumes thread twice") + client = FakeClient(search_items=[candidate]) + + ranked = similar_issues.suggest_for_issue( + client, "RT-Thread/rt-thread", issue + ) + + self.assertEqual(len(ranked), 1) + comments = [call[3] for call in client.calls if call[0] == "create_comment"] + self.assertEqual(len(comments), 2) + self.assertIn(similar_issues.CLAIM_HINT_MARKER, comments[0]) + self.assertNotIn(similar_issues.COMMENT_MARKER, comments[0]) + self.assertIn(similar_issues.COMMENT_MARKER, comments[1]) + self.assertNotIn(similar_issues.CLAIM_HINT_MARKER, comments[1]) + + def test_rate_limit_is_best_effort(self): + issue = make_issue(10, "rt_event_send timeout race resumes thread twice") + client = FakeClient( + search_error=similar_issues.GitHubAPIError(403, "rate limit exceeded") + ) + self.assertEqual( + similar_issues.suggest_for_issue( + client, "RT-Thread/rt-thread", issue + ), + [], + ) + + def test_backfill_reports_rate_limits_as_failures(self): + issue = make_issue(10, "rt_event_send timeout race resumes thread twice") + client = FakeClient( + open_issues=[issue], + search_error=similar_issues.GitHubAPIError(403, "rate limit exceeded"), + ) + + summary = similar_issues.process_backfill( + client, + "RT-Thread/rt-thread", + max_issues=1, + delay_seconds=0, + dry_run=True, + ) + + self.assertEqual(summary["processed"], 1) + self.assertEqual(summary["failures"][0]["number"], 10) + + def test_backfill_does_not_add_claim_hint(self): + issue = make_issue(10, "rt_event_send timeout race resumes thread twice") + client = FakeClient(open_issues=[issue]) + + summary = similar_issues.process_backfill( + client, + "RT-Thread/rt-thread", + max_issues=1, + delay_seconds=0, + dry_run=False, + ) + + self.assertEqual(summary["processed"], 1) + self.assertEqual(summary["suggested"], 0) + self.assertFalse(any(call[0] == "create_comment" for call in client.calls)) + + def test_comment_is_bilingual_and_cautious(self): + ranked = [{"candidate": make_issue(9, "Example"), "score": 1.0}] + comment = similar_issues.format_comment(ranked) + self.assertIn(similar_issues.COMMENT_MARKER, comment) + self.assertIn("#9", comment) + self.assertIn("尚未确认重复", comment) + self.assertIn("not confirmed duplicates", comment) + + +class ClientTests(unittest.TestCase): + def test_comment_pagination_reads_past_five_hundred(self): + class PagingClient(similar_issues.GitHubClient): + def __init__(self): + self.pages = 0 + + def request(self, *args, **kwargs): + self.pages += 1 + size = 100 if self.pages <= 5 else 1 + return [{"id": self.pages * 100 + index} for index in range(size)] + + client = PagingClient() + comments = client.list_comments("RT-Thread/rt-thread", 42) + + self.assertEqual(len(comments), 501) + self.assertEqual(client.pages, 6) + + +if __name__ == "__main__": + unittest.main()