diff --git a/gitlab_integration/gitlab_fetcher.py b/gitlab_integration/gitlab_fetcher.py index d57c2e9..16dd1cc 100644 --- a/gitlab_integration/gitlab_fetcher.py +++ b/gitlab_integration/gitlab_fetcher.py @@ -13,6 +13,16 @@ class GitlabMergeRequestFetcher: def __init__(self, project_id, merge_request_iid): + """ + Initialize a GitLab merge request fetcher. + + Assigns the project identifier and merge request IID, and sets up caches for + tracking changes, file content, and merge request information. + + Parameters: + project_id: The unique identifier for the GitLab project. + merge_request_iid: The internal identifier for the merge request. + """ self.project_id = project_id self.iid = merge_request_iid self._changes_cache = None @@ -22,17 +32,23 @@ def __init__(self, project_id, merge_request_iid): @retry(stop_max_attempt_number=3, wait_fixed=2000) def get_changes(self, force=False): """ - Get the changes of the merge request - :return: changes + Retrieve merge request changes via GitLab API. + + If cached changes are available and force is False, returns the cached data. + Otherwise, performs a GET request to fetch the latest changes, caches them on success, + and returns the list of changes. Returns None if the API request fails. + + Args: + force (bool): If True, bypasses the cache to fetch fresh changes. """ if self._changes_cache and not force: return self._changes_cache # URL for the GitLab API endpoint - url = f"{GITLAB_SERVER_URL}/api/v4/projects/{self.project_id}/merge_requests/{self.iid}/changes" + url = f"{gitlab_server_url}/api/v4/projects/{self.project_id}/merge_requests/{self.iid}/changes" # Headers for the request headers = { - "PRIVATE-TOKEN": GITLAB_PRIVATE_TOKEN + "PRIVATE-TOKEN": gitlab_private_token } # Make the GET request @@ -49,20 +65,31 @@ def get_changes(self, force=False): # 获取文件内容 def get_file_content(self, file_path, branch_name='main', force=False): """ - Get the content of the file - :param file_path: The path of the file - :return: The content of the file + Fetch the raw content of a repository file via the GitLab API. + + This method retrieves the file content from the specified branch by making a GET + request to the GitLab API. The provided file path is URL-encoded for proper API + endpoint formatting. Cached content is returned if available, unless the force + flag is set to True. + + Args: + file_path: The repository path of the file; forward slashes are URL-encoded. + branch_name: The branch to fetch the file from (default is 'main'). + force: If True, bypasses the cache to retrieve fresh content. + + Returns: + The raw file content as a string if the request is successful; otherwise, None. """ # 对file_path中的'/'转换为'%2F' file_path = file_path.replace('/', '%2F') if file_path in self._file_content_cache and not force: return self._file_content_cache[file_path] # URL for the GitLab API endpoint - url = f"{GITLAB_SERVER_URL}/api/v4/projects/{self.project_id}/repository/files/{file_path}/raw?ref={branch_name}" + url = f"{gitlab_server_url}/api/v4/projects/{self.project_id}/repository/files/{file_path}/raw?ref={branch_name}" # Headers for the request headers = { - "PRIVATE-TOKEN": GITLAB_PRIVATE_TOKEN + "PRIVATE-TOKEN": gitlab_private_token } # Make the GET request @@ -78,17 +105,26 @@ def get_file_content(self, file_path, branch_name='main', force=False): @retry(stop_max_attempt_number=3, wait_fixed=2000) def get_info(self, force=False): """ - Get the merge request information - :return: Merge request information + Retrieve merge request information. + + If cached data is available and force is False, the cached merge request details + are returned. Otherwise, the method calls the GitLab API to fetch fresh information, + caches the result, and returns it. If the API request fails, None is returned. + + Args: + force (bool): If True, bypass the cache and retrieve fresh data. + + Returns: + dict or None: The merge request information if successful; otherwise, None. """ if self._info_cache and not force: return self._info_cache # URL for the GitLab API endpoint - url = f"{GITLAB_SERVER_URL}/api/v4/projects/{self.project_id}/merge_requests/{self.iid}" + url = f"{gitlab_server_url}/api/v4/projects/{self.project_id}/merge_requests/{self.iid}" # Headers for the request headers = { - "PRIVATE-TOKEN": GITLAB_PRIVATE_TOKEN + "PRIVATE-TOKEN": gitlab_private_token } # Make the GET request @@ -104,6 +140,18 @@ def get_info(self, force=False): # gitlab仓库clone和管理 class GitlabRepoManager: def __init__(self, project_id, branch_name = ""): + """ + Initialize a GitlabRepoManager instance. + + Creates a unique repository path by combining the provided project ID with the current + timestamp. The repository is initially marked as not cloned. The optional branch name + parameter is accepted for potential branch-related operations, although it is not used + during initialization. + + Args: + project_id: Identifier for the GitLab project. + branch_name: Optional branch name for repository operations. + """ self.project_id = project_id self.timestamp = int(time.time() * 1000) self.repo_path = f"./repo/{self.project_id}_{self.timestamp}" @@ -111,15 +159,18 @@ def __init__(self, project_id, branch_name = ""): def get_info(self): """ - Get the project information - :return: Project information + Retrieve project information from GitLab. + + Makes a GET request to the GitLab API to fetch details for the project identified by the + instance's project_id. Returns the JSON-decoded response if the request is successful (HTTP 200); + otherwise, returns None. """ # URL for the GitLab API endpoint - url = f"{GITLAB_SERVER_URL}/api/v4/projects/{self.project_id}" + url = f"{gitlab_server_url}/api/v4/projects/{self.project_id}" # Headers for the request headers = { - "PRIVATE-TOKEN": GITLAB_PRIVATE_TOKEN + "PRIVATE-TOKEN": gitlab_private_token } # Make the GET request @@ -129,14 +180,20 @@ def get_info(self): if response.status_code == 200: return response.json() else: - log.error(f"获取项目信息失败: {response.status_code} {response.text}") return None @retry(stop_max_attempt_number=3, wait_fixed=2000) def shallow_clone(self, branch_name = "main"): """ - Perform a shallow clone of the repository - param branch_name: The name of the branch to clone + Shallow clones the repository to a local directory. + + Deletes any existing local clone, constructs an authenticated Git URL using + repository information, and executes a shallow clone (depth of 1) for the specified + branch. If cloning fails, an error is logged; otherwise, the repository is marked + as cloned. + + Args: + branch_name (str): The branch to clone (default "main"). """ # If the target directory exists, remove it self.delete_repo() @@ -159,6 +216,17 @@ def shallow_clone(self, branch_name = "main"): # 切换分支 def checkout_branch(self, branch_name, force=False): # Build the Git command + """ + Checks out the specified branch by performing a shallow clone if necessary. + + If the repository has not been cloned already, the method executes a shallow clone for the target branch. + If the repository is already cloned, it verifies whether the branch is already checked out (unless forced) + and performs a shallow clone if the branch differs or if force is True. + + Args: + branch_name: The name of the branch to check out. + force: If True, forces re-cloning of the branch even if it appears to be already checked out. + """ if not self.has_cloned: self.shallow_clone(branch_name) else: @@ -170,11 +238,30 @@ def checkout_branch(self, branch_name, force=False): # 删除库 def delete_repo(self): + """ + Deletes the cloned repository directory if it exists. + + This method checks whether the repository path exists on the filesystem and removes it along with its contents. If the directory is not present, no action is taken. + """ if os.path.exists(self.repo_path): shutil.rmtree(self.repo_path) # 查找相关文件列表 def find_files_by_keyword(self, keyword, branch_name="main"): + """ + Search for files whose content matches a regex pattern. + + Checks out the specified branch and recursively searches for files whose content + matches the provided regular expression. Files that cannot be read due to encoding, + permission, or existence issues are skipped. + + Args: + keyword: Regular expression pattern to search for in file contents. + branch_name: Branch to search in; defaults to "main". + + Returns: + A list of file paths for files containing a match to the keyword. + """ matching_files = [] regex = re.compile(keyword) self.checkout_branch(branch_name) @@ -196,23 +283,19 @@ def find_files_by_keyword(self, keyword, branch_name="main"): # 构建带有身份验证信息的 URL def _build_authenticated_url(self, repo_url): # 如果 URL 使用 https - token = GITLAB_PRIVATE_TOKEN + """ + Builds an authenticated URL for repository access. + + This method embeds an OAuth2 token into the provided repository URL, supporting only + URLs beginning with "http://" or "https://". For HTTPS URLs, it returns a URL in the + format "https://oauth2:{token}@" and similarly for HTTP URLs. If the URL + scheme is unsupported, a ValueError is raised. + """ + token = gitlab_private_token if repo_url.startswith("https://"): return f"https://oauth2:{token}@{repo_url[8:]}" # 如果 URL 使用 http elif repo_url.startswith("http://"): return f"http://oauth2:{token}@{repo_url[7:]}" else: - raise ValueError("Unsupported URL scheme") - -def is_merge_request_opened(gitlab_payload) -> bool: - """ - 判断是否是merge request打开事件 - """ - try: - gitlab_merge_request_old = gitlab_payload.get("object_attributes").get("state") == "opened" and gitlab_payload.get("object_attributes").get("merge_status") == "preparing" - gitlab_merge_request_new = gitlab_payload.get("object_attributes").get("state") == "merged" and gitlab_payload.get("object_attributes").get("merge_status") == "can_be_merged" - return gitlab_merge_request_old or gitlab_merge_request_new - except Exception as e: - log.error(f"判断是否是merge request打开事件失败: {e}") - return False \ No newline at end of file + raise ValueError("Unsupported URL scheme") \ No newline at end of file diff --git a/gitlab_integration/webhook_listener.py b/gitlab_integration/webhook_listener.py index 37d71f5..a152da9 100644 --- a/gitlab_integration/webhook_listener.py +++ b/gitlab_integration/webhook_listener.py @@ -7,7 +7,7 @@ from response_module.response_controller import ReviewResponse from review_engine.review_engine import ReviewEngine from utils.logger import log -from gitlab_integration.gitlab_fetcher import is_merge_request_opened + class WebhookListener: def __init__(self): @@ -25,6 +25,19 @@ def handle_webhook(self): return self.call_handle(gitlab_payload, event_type) def call_handle(self, gitlab_payload, event_type): + """ + Dispatches a GitLab webhook payload to the corresponding event handler. + + Determines the event type and builds a configuration dictionary for a ReviewResponse, + which is then passed to the appropriate handler—merge request, push, or other events. + + Args: + gitlab_payload: A dictionary containing data from a GitLab webhook. + event_type: A string specifying the event type (e.g., 'merge_request', 'push'). + + Returns: + The response object returned by the invoked event-specific handler. + """ if event_type == 'merge_request': config = { 'type': 'merge_request', @@ -51,9 +64,22 @@ def call_handle(self, gitlab_payload, event_type): def handle_merge_request(self, gitlab_payload, reply): """ - 处理合并请求事件 + Process a GitLab merge request event. + + When the merge request is in the "opened" state and its merge status is "preparing", this + function logs the event, extracts the project and merge request identifiers, and initializes + a ReviewEngine to process the merge asynchronously in a new thread using a GitlabMergeRequestFetcher + and a GitlabRepoManager. It returns a JSON response with a status of "success". If the event does + not meet these criteria, a JSON response indicating that no further check is needed is returned. + + Args: + gitlab_payload: A dictionary containing GitLab merge request event details. + reply: A ReviewResponse configuration object used for initializing the ReviewEngine. + + Returns: + A tuple with a JSON response and an HTTP status code (200). """ - if is_merge_request_opened(gitlab_payload): + if gitlab_payload.get("object_attributes").get("state") == "opened" and gitlab_payload.get("object_attributes").get("merge_status") == "preparing": log.info("首次merge_request ", gitlab_payload) project_id = gitlab_payload.get('project')['id'] merge_request_iid = gitlab_payload.get("object_attributes")["iid"] diff --git a/response_module/abstract_response.py b/response_module/abstract_response.py index a40e915..3f7b5e4 100644 --- a/response_module/abstract_response.py +++ b/response_module/abstract_response.py @@ -3,28 +3,66 @@ class AbstractResponse(ABC): @abstractmethod def __init__(self, config): + """ + Initialize the response instance with a configuration. + + The provided configuration is stored for use by subclasses. + """ self.config = config class AbstractResponseMessage(AbstractResponse): @abstractmethod def __init__(self, config): + """ + Initialize the instance with the provided configuration. + + Delegates to the superclass constructor to set up the instance. + """ super().__init__(config) @abstractmethod def send(self, message): + """ + Sends a message. + + Subclasses must override this method to deliver the provided message using the + appropriate communication mechanism. + + Args: + message: The content or payload of the message to be sent. + """ pass class AbstractResponseOther(AbstractResponse): @abstractmethod def __init__(self, config): + """ + Initialize the response instance with the specified configuration. + + This constructor passes the configuration to the parent initializer to establish + the necessary settings for the response object. + """ super().__init__(config) @abstractmethod def set_state(self, *args, **kwargs): + """ + Set the state of the response. + + Subclasses must override this method to update the internal state based on the + provided positional and keyword arguments. + """ pass @abstractmethod def send(self, *args, **kwargs): + """ + Sends a response using provided arguments. + + Subclasses must override this method to handle sending a response + or triggering appropriate actions based on supplied positional and keyword + arguments. + """ pass \ No newline at end of file diff --git a/response_module/response_controller.py b/response_module/response_controller.py index a1eaa24..0ee57f2 100644 --- a/response_module/response_controller.py +++ b/response_module/response_controller.py @@ -6,9 +6,17 @@ class ReviewResponse: def __init__(self, config): """ - 初始化 Reply 实例 + Initialize the ReviewResponse instance with the specified configuration. + + Validates that the provided configuration is a dictionary containing the required + 'type' field. Sets up internal storage for replies, a thread lock for safe concurrent + access, and a state dictionary for extra response data. + Args: - config (dict): 配置字典,包含初始化所需的配置信息 + config (dict): A configuration dictionary that must include a 'type' field. + + Raises: + Exception: If the configuration is not a dictionary or lacks the 'type' field. """ if not isinstance(config, dict): raise Exception('Reply config should be a dict.') @@ -21,9 +29,19 @@ def __init__(self, config): def add_reply(self, reply_msg): """ - 添加回复消息 + Adds a reply message to the response queue. + + Validates the reply message to ensure a 'content' field is present and, if provided, that 'msg_type' + is a comma-separated string. For messages whose type includes 'SINGLE', the message is sent immediately. + If absent, defaults for 'msg_type', 'target', 'title', and 'group_id' are assigned before the message + is appended to the replies list with thread-safety. + Args: - reply_msg (dict): 回复消息字典,必须包含 'content' 字段 + reply_msg (dict): The reply message dictionary. It must include a 'content' field and may include + an optional 'msg_type' (as a comma-separated string), 'target', 'title', and 'group_id'. + + Raises: + Exception: If the 'content' field is missing or if 'msg_type' is provided but not a string. """ if 'content' not in reply_msg: raise Exception('Reply format error, title and content are required.') @@ -47,11 +65,9 @@ def add_reply(self, reply_msg): def send(self): """ - 实时发送单条消息 - Args: - reply (dict): 单条回复消息字典,必须包含 'content' 字段 - Returns: - bool: 表示发送是否成功 + Sends all accumulated reply messages. + + This method groups reply messages by target and message type before dispatching them. Messages with a "MAIN" type are processed separately after clearing the stored replies in a thread-safe manner. For each target group, it retrieves a sender via ResponseFactory and sends the grouped messages. Returns True if all messages are successfully sent; otherwise, False. """ msg_groups = {} main_msg_group = [] @@ -76,12 +92,25 @@ def send(self): def send_single_message(self, reply): """ - 实时发送单条消息 - + Sends a single reply message to designated targets immediately. + + This function processes a reply message dictionary and dispatches the message to one or more + targets. It splits the 'target' field by commas, and if the keyword 'all' is present, it + replaces the targets with all available message targets from the ResponseFactory. Depending + on the 'msg_type' flag and the presence of a valid 'title', the function may prepend a formatted + title to the message content. It returns True only if the message is successfully sent to all + designated targets. + Args: - reply (dict): 单条回复消息字典,必须包含 'content' 字段 + reply (dict): A dictionary containing message details. Required keys: + - 'content': The text content of the message. + - 'target': A comma-separated string of target identifiers, or 'all' for all targets. + Optional keys: + - 'msg_type': A string that can include 'TITLE_IGNORE' or 'MAIN' to control title usage. + - 'title': A string that, if provided and applicable, is used as the message title. + Returns: - bool: 表示发送是否成功 + bool: True if the message was sent successfully to every target; False otherwise. """ targets = [t.strip() for t in reply['target'].split(',')] if 'all' in targets: @@ -98,6 +127,17 @@ def send_single_message(self, reply): return ret def __parse_msg(self, msg, msg_groups): + """ + Parse a message and group it by target and group ID. + + The function splits the message's target field into individual targets. If the target is missing or includes "all", all targets are retrieved via ResponseFactory. It then updates the msg_groups dictionary by creating or appending to a list of messages under each target and group identifier. When the message type indicates that the title should be ignored, or if it is marked as MAIN or missing a valid title, the content is inserted at the beginning of the group; otherwise, a Markdown header is prepended to the content before appending. + + Args: + msg (dict): A message dictionary expected to include 'target', 'msg_type', 'group_id', + 'content', and optionally 'title'. + msg_groups (dict): A nested dictionary grouping messages by target and group_id, which is + updated in place with the parsed message. + """ targets = [t.strip() for t in msg['target'].split(',')] if 'target' not in msg or 'all' in targets: targets = ResponseFactory.get_all_message_targets() @@ -114,9 +154,36 @@ def __parse_msg(self, msg, msg_groups): msg_groups[target][msg['group_id']].append(f"{title}{msg['content']}\n\n") def set_state(self, res_type, *args, **kwargs): + """ + Stores state information for a given response type. + + This method records additional state details provided as positional or keyword arguments + and saves them in an internal dictionary for later use. + + Args: + res_type: Identifier for the response type. + *args: Optional positional state details. + **kwargs: Optional keyword state details. + """ self.oter_res_state[res_type] = (args, kwargs) def send_by_other(self, response_type, *args, **kwargs): + """ + Sends a response using an alternative sender instance. + + Retrieves a sender from the ResponseFactory based on the given response type and the instance + configuration. If a sender is found and a stored state exists for that type, the state is applied + to the sender before sending the response with the provided arguments. Raises an Exception if + no sender instance for the specified response type exists. + + Args: + response_type: Identifier for the alternative response type. + *args: Positional arguments passed to the sender's send method. + **kwargs: Keyword arguments passed to the sender's send method. + + Returns: + The result of the sender's send method. + """ sender = ResponseFactory.get_other_instance(response_type, self.config) if sender is None: raise Exception(f'No such type {response_type} in other response.') diff --git a/response_module/response_factory.py b/response_module/response_factory.py index 521fb92..3418362 100644 --- a/response_module/response_factory.py +++ b/response_module/response_factory.py @@ -10,6 +10,20 @@ class ResponseFactory: @classmethod def register_target(cls, target, target_class): # 检测是否实现了AbstractResponseMessage接口 + """ + Registers a response target with its associated response class. + + Ensures that target_class is a subclass of AbstractResponse, raising a TypeError if not. + If target_class does not implement AbstractResponseMessage, it is also added to an alternate registry. + In all cases, target_class is registered as a message target. + + Args: + target: The identifier for the response target. + target_class: A class expected to implement AbstractResponse. + + Raises: + TypeError: If target_class is not a subclass of AbstractResponse. + """ if not issubclass(target_class, AbstractResponse): raise TypeError(f'{target_class} does not implement AbstractResponse') if not issubclass(target_class, AbstractResponseMessage): @@ -18,30 +32,81 @@ def register_target(cls, target, target_class): @classmethod def get_message_instance(cls, target, config): + """ + Retrieves a message response instance for a given target. + + Checks if the target is registered in the message registry and returns a new instance + of the associated message response class initialized with the provided configuration. + Returns None if the target is not found. + + Args: + target: The key identifying the registered message response. + config: The configuration used to instantiate the message response. + """ if target not in cls._registry_msg: return None return cls._registry_msg[target](config) @classmethod def get_other_instance(cls, target, config): + """ + Retrieve a non-message response instance for the given target. + + If the target is not registered in the non-message responses registry, returns None. + + Args: + target: Identifier for the response class to instantiate. + config: Configuration used to initialize the response instance. + + Returns: + The instantiated non-message response object if found; otherwise, None. + """ if target not in cls._registry_other: return None return cls._registry_other[target](config) @classmethod def get_all_message_instance(cls, config): + """ + Return instances of all registered message response classes. + + Args: + config: Configuration data used to initialize each message response instance. + + Returns: + List of instances for each registered message response. + """ return [target_class(config) for target_class in cls._registry_msg.values()] @classmethod def get_all_other_instance(cls, *args, **kwargs): + """ + Instantiates all registered other response classes. + + For each class stored in the other response registry, a new instance is created by + calling its constructor with the provided positional and keyword arguments. Returns + a list of these instances. + """ return [target_class(*args, **kwargs) for target_class in cls._registry_other.values()] @classmethod def get_all_message_targets(cls): + """ + Returns a list of all registered message targets. + + This method retrieves the keys from the internal message registry, which represent + the targets for the registered message response classes. + """ return list(cls._registry_msg.keys()) @classmethod def get_all_other_targets(cls): + """ + Returns a list of all registered non-message response targets. + + This method retrieves the keys from the registry of other response classes, + providing a collection of all targets not associated with message responses. + """ return list(cls._registry_other.keys()) diff --git a/response_module/response_target/msg_response/dingtalk_response.py b/response_module/response_target/msg_response/dingtalk_response.py index 4fd7287..6bc48d5 100644 --- a/response_module/response_target/msg_response/dingtalk_response.py +++ b/response_module/response_target/msg_response/dingtalk_response.py @@ -12,6 +12,18 @@ class DingtalkResponse(AbstractResponseMessage): def __init__(self, config): + """ + Initializes the DingtalkResponse instance with configuration settings. + + Processes the provided configuration dictionary to set the response type. If the + type is 'merge_request', additional identifiers for the project and merge request + are initialized from the configuration. + + Args: + config (dict): Dictionary containing configuration parameters. Must include + a 'type' key, and if 'type' is 'merge_request', also 'project_id' and + 'merge_request_iid' keys. + """ super().__init__(config) self.type = config['type'] if self.type == 'merge_request': @@ -36,7 +48,7 @@ def send_dingtalk_message_by_sign(self, message_text): """ timestamp = str(round(time.time() * 1000)) sign = self.__get_sign(timestamp) - webhookurl = f"{DINGDING_BOT_WEBHOOK}×tamp={timestamp}&sign={sign}" + webhookurl = f"{dingding_bot_webhook}×tamp={timestamp}&sign={sign}" # 构建请求头 headers = { "Content-Type": "application/json", @@ -75,7 +87,7 @@ def send_dingtalk_message_by_key_word(self, project_url): """ # 设置钉钉机器人的 Webhook URL - webhook_url = DINGDING_BOT_WEBHOOK + webhook_url = dingding_bot_webhook # 要发送的消息内容 message = f"新工程接入\nurl:{project_url}" @@ -91,13 +103,21 @@ def send_dingtalk_message_by_key_word(self, project_url): return response.json() def __get_sign(self, timestamp): - ''' - 计算签名 - :param timestamp: 时间戳 - :return: 签名 - ''' + """ + Computes a URL-encoded HMAC-SHA256 signature for the provided timestamp. + + This method concatenates the given timestamp and a predefined secret with a newline, + computes the HMAC-SHA256 hash of the resulting string using the secret as the key, + encodes the hash in Base64, and then URL-encodes the result. + + Parameters: + timestamp: The timestamp used in signature generation. + + Returns: + The URL-encoded signature string. + """ - secret = DINGDING_SECRET + secret = dingding_secret secret_enc = secret.encode('utf-8') string_to_sign = '{}\n{}'.format(timestamp, secret) string_to_sign_enc = string_to_sign.encode('utf-8') diff --git a/response_module/response_target/msg_response/gitlab_response.py b/response_module/response_target/msg_response/gitlab_response.py index 541198d..54e9454 100644 --- a/response_module/response_target/msg_response/gitlab_response.py +++ b/response_module/response_target/msg_response/gitlab_response.py @@ -7,6 +7,17 @@ # 继承AbstractReply类,实现send方法 class GitlabResponse(AbstractResponseMessage): def __init__(self, config): + """ + Initialize a GitlabResponse instance. + + Calls the parent initializer with the given configuration and sets the response type. + If the type is 'merge_request', also initializes project and merge request identifiers from + the configuration. + + Args: + config (dict): Configuration data including a 'type' key. Must also contain 'project_id' and + 'merge_request_iid' if 'type' is 'merge_request'. + """ super().__init__(config) self.type = config['type'] if self.type == 'merge_request': @@ -22,12 +33,12 @@ def send(self, message): @retry(stop_max_attempt_number=3, wait_fixed=2000) def send_merge(self, message): headers = { - "Private-Token": GITLAB_PRIVATE_TOKEN, + "Private-Token": gitlab_private_token, "Content-Type": "application/json" } project_id = self.project_id merge_request_id = self.merge_request_id - url = f"{GITLAB_SERVER_URL}/api/v4/projects/{project_id}/merge_requests/{merge_request_id}/notes" + url = f"{gitlab_server_url}/api/v4/projects/{project_id}/merge_requests/{merge_request_id}/notes" data = { "body": message } diff --git a/response_module/response_target/other_type_response/template_response.py b/response_module/response_target/other_type_response/template_response.py index 97eba49..9dc2772 100644 --- a/response_module/response_target/other_type_response/template_response.py +++ b/response_module/response_target/other_type_response/template_response.py @@ -3,8 +3,23 @@ class TemplateResponse(AbstractResponseOther): def __init__(self, config): + """ + Initializes a TemplateResponse instance with the given configuration. + + Args: + config: Configuration settings used to initialize the response. + """ super().__init__(config) def send(self, *args, **kwargs): + """ + Print the class name and provided arguments, then return True. + + This method outputs a formatted message to stdout that includes the name of the class + along with any positional and keyword arguments passed to it. + + Returns: + bool: Always returns True. + """ print(f'{self.__class__.__name__} send: {args} {kwargs}') return True \ No newline at end of file diff --git a/review_engine/abstract_handler.py b/review_engine/abstract_handler.py index 1900630..3f85d40 100644 --- a/review_engine/abstract_handler.py +++ b/review_engine/abstract_handler.py @@ -5,7 +5,19 @@ class ReviewHandle(object): def __init__(self): + """ + Initializes a ReviewHandle instance. + + This constructor currently does not perform any additional operations. + """ pass def merge_handle(self, gitlabMergeRequestFetcher, gitlabRepoManager, hook_info, reply, model): + """ + Handles merge request events by interfacing with GitLab services. + + This placeholder method is intended to process merge request events by utilizing GitLab services + to fetch merge request details and manage repository data, along with hook event data, a reply + handler, and a review model. The current implementation does not perform any operations. + """ pass \ No newline at end of file diff --git a/review_engine/handler/default_handler.py b/review_engine/handler/default_handler.py index 46a4760..d2e9f3c 100644 --- a/review_engine/handler/default_handler.py +++ b/review_engine/handler/default_handler.py @@ -2,27 +2,48 @@ import threading from retrying import retry -from config.config import GPT_MESSAGE, EXCLUDE_FILE_TYPES, IGNORE_FILE_TYPES, MAX_FILES + +from config.config import gpt_message from review_engine.abstract_handler import ReviewHandle from utils.gitlab_parser import filter_diff_content from utils.logger import log def chat_review(changes, generate_review, *args, **kwargs): + """ + Concurrently generates code review notes for eligible file changes. + + Filters the provided changes based on file extension criteria—processing only files + ending with .py, .java, .class, .vue, or .go while excluding those ending with + 'mod.go'. For each eligible change, the function concurrently calls the supplied + review generation function using any additional arguments provided, aggregates the + results, and returns them joined by two newlines. If no reviews are generated, an empty + string is returned. + """ log.info('开始code review') with concurrent.futures.ThreadPoolExecutor() as executor: review_results = [] result_lock = threading.Lock() def process_change(change): + """ + Processes a single code change and appends its review note to shared results. + + This function calls the external review generator with the provided change and + any additional arguments to produce a review note. It then appends the result to + a shared list in a thread-safe manner. + + Args: + change: The code change object to be reviewed. + """ result = generate_review(change, *args, **kwargs) with result_lock: review_results.append(result) futures = [] for change in changes: - if any(change["new_path"].endswith(ext) for ext in EXCLUDE_FILE_TYPES) and not any( - change["new_path"].endswith(ext) for ext in IGNORE_FILE_TYPES): + if any(change["new_path"].endswith(ext) for ext in ['.py', '.java', '.class', '.vue', ".go"]) and not any( + change["new_path"].endswith(ext) for ext in ["mod.go"]): futures.append(executor.submit(process_change, change)) else: log.info(f"{change['new_path']} 非目标检测文件!") @@ -33,11 +54,25 @@ def process_change(change): @retry(stop_max_attempt_number=3, wait_fixed=60000) def generate_review_note(change, model): + """ + Generates a formatted review note for a code change. + + Extracts and filters the diff content from the provided change, builds messages for the AI model + to review the change, and formats the response with token count and file path details. Logs the + progress and any errors encountered. + + Parameters: + change: Dictionary containing code change details, including 'diff' and 'new_path' keys. + model: AI model instance with methods to generate text and retrieve the generated content and token count. + + Returns: + A string containing the formatted review note. + """ try: content = filter_diff_content(change['diff']) messages = [ {"role": "system", - "content": GPT_MESSAGE + "content": gpt_message }, {"role": "user", "content": f"请review这部分代码变更{content}", @@ -60,12 +95,37 @@ def generate_review_note(change, model): class MainReviewHandle(ReviewHandle): def merge_handle(self, gitlabMergeRequestFetcher, gitlabRepoManager, hook_info, reply, model): + """ + Handles a merge request by fetching changes and merge information. + + Retrieves changes and merge details from the GitLab merge request fetcher and delegates + processing to default_handle along with hook info, reply data, and a review model. + """ changes = gitlabMergeRequestFetcher.get_changes() merge_info = gitlabMergeRequestFetcher.get_info() self.default_handle(changes, merge_info, hook_info, reply, model) def default_handle(self, changes, merge_info, hook_info, reply, model): - if changes and len(changes) <= MAX_FILES: + """ + Processes a merge request by generating and dispatching code review replies. + + This method checks whether the number of file changes in a merge request is within an acceptable + limit (50 files). For merge requests with changes equal to or below this limit, it attempts to + generate review content via a code review function. If review information is produced, it sends + detailed replies containing both the review and merge details; otherwise, it sends a reply indicating + that an existing merge request has already been processed. If the count of changed files exceeds + 50, a reply is dispatched to indicate that code review is skipped. When no change information is + provided, an error is logged. + + Args: + changes: A list of file changes included in the merge request. + merge_info: Merge request information (currently unused). + hook_info: A dictionary with hook details such as project info, MR URL, and branch names. + reply: An object used to send replies to various targets. + model: The model or mechanism used for generating the review note. + """ + maximum_files = 50 + if changes and len(changes) <= maximum_files: # Code Review 信息 review_info = chat_review(changes, generate_review_note, model) if review_info: @@ -108,7 +168,7 @@ def default_handle(self, changes, merge_info, hook_info, reply, model): }) - elif changes and len(changes) > MAX_FILES: + elif changes and len(changes) > maximum_files: reply.add_reply({ 'title': '__MAIN_REVIEW__', 'content': ( @@ -126,6 +186,6 @@ def default_handle(self, changes, merge_info, hook_info, reply, model): else: log.error(f"获取merge_request信息失败,project_id: {hook_info['project']['id']} |" - f" merge_iid: {hook_info['object_attributes']['iid']} | merge_info: {merge_info}") + f" merge_iid: {hook_info['object_attributes']['iid']}") diff --git a/review_engine/review_engine.py b/review_engine/review_engine.py index c323107..1fdd0ed 100644 --- a/review_engine/review_engine.py +++ b/review_engine/review_engine.py @@ -7,6 +7,15 @@ class ReviewEngine: def __init__(self, reply): + """Initializes a ReviewEngine instance. + + Sets the reply attribute and dynamically loads available review handles by importing + all subclasses of ReviewHandle from the review_engine.abstract_handler module. Each + handle is instantiated and added to the engine's handles list. + + Args: + reply: An object used to send responses (must implement a send() method). + """ self.handles = [] self.reply = reply # 动态导入所有的handle,位置在handle目录下 @@ -16,6 +25,14 @@ def __init__(self, reply): def handle_merge(self, gitlabMergeRequestFetcher, gitlabRepoManager, webhook_info): # 多线程处理 + """ + Handle a merge request concurrently. + + This method spawns a separate thread for each registered review handle to process the merge. + Each thread invokes the merge handling operation with the provided merge request fetcher, + repository manager, webhook information, the reply interface, and a new model instance. Once + all threads complete, it deletes the repository and sends a reply. + """ threads = [threading.Thread(target=handle.merge_handle, args=(gitlabMergeRequestFetcher, gitlabRepoManager, webhook_info, self.reply, LLMGenerator.new_model())) for handle in self.handles] diff --git a/utils/args_check.py b/utils/args_check.py index de30552..99c6fc6 100644 --- a/utils/args_check.py +++ b/utils/args_check.py @@ -9,8 +9,8 @@ def check_config(): results = [] try: import config.config as config - if check_exist(config, ["llm_api_impl", "api_config", "GPT_MESSAGE", - "GITLAB_SERVER_URL", "GITLAB_PRIVATE_TOKEN", "DINGDING_BOT_WEBHOOK", "DINGDING_SECRET"]): + if check_exist(config, ["llm_api_impl", "api_config", "gpt_message", + "gitlab_server_url", "gitlab_private_token", "dingding_bot_webhook", "dingding_secret"]): results.append(["Configuration parameter existence", "Passed", "", "✅ Required parameters are available."]) else: results.append(["Configuration parameter existence", "Failed", "Required parameters are missing", "❌ Required parameters are missing"]) @@ -49,8 +49,11 @@ def check_config(): def check_dingding_config(config): """ - Check the dingding configuration - :return: dict + Check DingTalk configuration by sending a test notification. + + Attempts to send a test message using DingtalkResponse to verify the DingTalk + integration settings. Returns a dictionary with a boolean flag indicating + if the check passed and a list of error messages for any failures. """ result = {'passed': True, 'errors': []} try: @@ -75,14 +78,14 @@ def check_gitlab_config(config): """ result = {'passed': True, 'errors': []} try: - response = requests.get(config.GITLAB_SERVER_URL) + response = requests.get(config.gitlab_server_url) if response.status_code != 200: - error_msg = f"Gitlab server URL {config.GITLAB_SERVER_URL} is not available" + error_msg = f"Gitlab server URL {config.gitlab_server_url} is not available" result['errors'].append(error_msg) result['passed'] = False - response = requests.get(f"{config.GITLAB_SERVER_URL}/api/v4/projects", - headers={"PRIVATE-TOKEN": config.GITLAB_PRIVATE_TOKEN}) + response = requests.get(f"{config.gitlab_server_url}/api/v4/projects", + headers={"PRIVATE-TOKEN": config.gitlab_private_token}) if response.status_code != 200: error_msg = "Gitlab private token is invalid" result['errors'].append(error_msg) diff --git a/utils/gitlab_parser.py b/utils/gitlab_parser.py index f329c6d..0102fd2 100644 --- a/utils/gitlab_parser.py +++ b/utils/gitlab_parser.py @@ -3,6 +3,13 @@ def filter_diff_content(diff_content): # 过滤掉以 - 开头的行和 @@ 开头的行 + """ + Processes diff content by removing deletion and diff header lines. + + This function removes lines starting with '-' or '@@' from the provided diff content. + In addition, it strips the leading '+' character from lines that indicate additions. + The resulting string comprises the cleaned diff content. + """ filtered_content = re.sub(r'(^-.*\n)|(^@@.*\n)', '', diff_content, flags=re.MULTILINE) # 处理代码,去掉以 + 开头的行的第一个字符 processed_code = '\n'.join([line[1:] if line.startswith('+') else line for line in filtered_content.split('\n')]) @@ -10,6 +17,13 @@ def filter_diff_content(diff_content): def filter_diff_new_line(diff_content): # 获取diff中的行号 + """ + Extracts new line numbers from diff content. + + This function scans the diff content for hunk headers (lines beginning with "@@") and extracts the + starting line number for the new file from each header. If a range is specified in the hunk header, + it computes the ending line number and adds it to the result. Returns a list of the extracted line numbers. + """ line_numbers = [] current_line_num = None diff --git a/utils/tools.py b/utils/tools.py index db05769..629364c 100644 --- a/utils/tools.py +++ b/utils/tools.py @@ -8,6 +8,16 @@ def import_submodules(package_name): + # 确保正确的工作目录 + """ + Imports all submodules of the specified package. + + Ensures the project root is added to sys.path for proper module resolution, then imports the package + and dynamically loads every submodule found in its __path__. + + Args: + package_name (str): The fully-qualified name of the package. + """ project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) if project_root not in sys.path: sys.path.insert(0, project_root) @@ -17,6 +27,20 @@ def import_submodules(package_name): importlib.import_module(f"{package_name}.{module_name}") def run_command(command): + """ + Execute a shell command and log its output. + + This function runs the specified command using subprocess.Popen and logs each line of + standard output in real time. After the process completes, it logs any remaining output + from the standard output and standard error streams, with errors logged as error messages. + It returns the exit code of the executed command. + + Args: + command: The command to execute, provided as a list of arguments or a string. + + Returns: + int: The exit code of the command. + """ process = subprocess.Popen( command, stdout=subprocess.PIPE, @@ -49,7 +73,20 @@ def run_command(command): from config.config import * def _build_authenticated_url(repo_url): # 如果 URL 使用 https - token = GITLAB_PRIVATE_TOKEN + """ + Construct an authenticated GitLab repository URL. + + Inserts an OAuth2 token from the global gitlab_private_token into the provided URL + for Git authentication. Supports URLs starting with "https://" or "http://", returning + a modified URL with the token embedded. Raises a ValueError for unsupported schemes. + + Args: + repo_url: The repository URL to be authenticated; must start with "http://" or "https://". + + Returns: + A string representing the authenticated URL. + """ + token = gitlab_private_token if repo_url.startswith("https://"): return f"https://oauth2:{token}@{repo_url[8:]}" # 如果 URL 使用 http @@ -57,7 +94,7 @@ def _build_authenticated_url(repo_url): return f"http://oauth2:{token}@{repo_url[7:]}" else: raise ValueError("Unsupported URL scheme") - authenticated_url = _build_authenticated_url(GITLAB_SERVER_URL) + authenticated_url = _build_authenticated_url(gitlab_server_url) # Build the Git command branch_name = "test3"