async def run_pending_commands_tasks( ctxt: context.Context, mergify_config: rules.MergifyConfig) -> None: if ctxt.is_merge_queue_pr(): # We don't allow any command yet return pendings = set() async for comment in ctxt.client.items( f"{ctxt.base_url}/issues/{ctxt.pull['number']}/comments"): if comment["user"]["id"] != config.BOT_USER_ID: continue match = COMMAND_RESULT_MATCHER.search(comment["body"]) if match: command = match[1] state = match[2] if state == "pending": pendings.add(command) elif command in pendings: pendings.remove(command) for pending in pendings: await handle(ctxt, mergify_config, f"@Mergifyio {pending}", None, rerun=True)
async def handle( ctxt: context.Context, mergify_config: rules.MergifyConfig, comment: str, user: typing.Optional[github_types.GitHubAccount], rerun: bool = False, ) -> None: # Run command only if this is a pending task or if user have permission to do it. if not rerun and not user: raise RuntimeError("user must be set if rerun is false") def log(comment_out: str, result: typing.Optional[check_api.Result] = None) -> None: ctxt.log.info( "ran command", user_login=None if user is None else user["login"], rerun=rerun, comment_in=comment, comment_out=comment_out, result=result, ) try: command = load_command(mergify_config, comment) except CommandInvalid as e: log(e.message) await post_comment(ctxt, e.message) return except NotACommand: return if user: if (user["id"] != ctxt.pull["user"]["id"] and user["id"] != config.BOT_USER_ID and not await ctxt.repository.has_write_permission(user)): message = f"@{user['login']} is not allowed to run commands" log(message) await post_comment(ctxt, message) return if (ctxt.configuration_changed and actions.ActionFlag.ALLOW_ON_CONFIGURATION_CHANGED not in command.action.flags): message = CONFIGURATION_CHANGE_MESSAGE log(message) await post_comment(ctxt, message) return if command.name != "refresh" and ctxt.is_merge_queue_pr(): log(MERGE_QUEUE_COMMAND_MESSAGE) await post_comment(ctxt, MERGE_QUEUE_COMMAND_MESSAGE) return result, message = await run_command(ctxt, mergify_config, command, user) if result.conclusion is check_api.Conclusion.PENDING and rerun: log("action still pending", result) return log(message, result) await post_comment(ctxt, message)
async def handle( ctxt: context.Context, mergify_config: rules.MergifyConfig, comment: str, user: typing.Optional[github_types.GitHubAccount], rerun: bool = False, ) -> None: # Run command only if this is a pending task or if user have permission to do it. if not rerun and not user: raise RuntimeError("user must be set if rerun is false") def log(comment_out: str, result: typing.Optional[check_api.Result] = None) -> None: ctxt.log.info( "ran command", user_login=None if user is None else user["login"], rerun=rerun, comment_in=comment, comment_out=comment_out, result=result, ) if "@mergifyio" in comment.lower(): # @mergify have been used instead footer = "" else: footer = "\n\n" + WRONG_ACCOUNT_MESSAGE if user: if (user["id"] != ctxt.pull["user"]["id"] and user["id"] != config.BOT_USER_ID and not await ctxt.repository.has_write_permission(user)): message = f"@{user['login']} is not allowed to run commands" log(message) await post_comment(ctxt, message + footer) return action = load_action(mergify_config, comment) if not action: message = UNKNOWN_COMMAND_MESSAGE log(message) await post_comment(ctxt, message + footer) return if action[0] != "refresh" and ctxt.is_merge_queue_pr(): log(MERGE_QUEUE_COMMAND_MESSAGE) await post_comment(ctxt, MERGE_QUEUE_COMMAND_MESSAGE + footer) return result, message = await run_action(ctxt, action, user) if result.conclusion is check_api.Conclusion.PENDING and rerun: log("action still pending", result) return log(message, result) await post_comment(ctxt, message + footer)
async def run( ctxt: context.Context, sources: typing.List[context.T_PayloadEventSource], ) -> None: LOG.debug("engine get context") ctxt.log.debug("engine start processing context") issue_comment_sources: typing.List[T_PayloadEventIssueCommentSource] = [] for source in sources: if source["event_type"] == "issue_comment": issue_comment_sources.append( typing.cast(T_PayloadEventIssueCommentSource, source)) else: ctxt.sources.append(source) ctxt.log.debug("engine run pending commands") await commands_runner.run_pending_commands_tasks(ctxt) if issue_comment_sources: ctxt.log.debug("engine handle commands") for ic_source in issue_comment_sources: await commands_runner.handle( ctxt, ic_source["data"]["comment"]["body"], ic_source["data"]["comment"]["user"], ) if not ctxt.sources: return if ctxt.client.auth.permissions_need_to_be_updated: await ctxt.set_summary_check( check_api.Result( check_api.Conclusion.FAILURE, title="Required GitHub permissions are missing.", summary="You can accept them at https://dashboard.mergify.io/", )) return config_file = await ctxt.repository.get_mergify_config_file() ctxt.log.debug("engine check configuration change") if await _check_configuration_changes(ctxt, config_file): ctxt.log.info("Configuration changed, ignoring") return ctxt.log.debug("engine get configuration") if config_file is None: ctxt.log.info("No need to proceed queue (.mergify.yml is missing)") return # BRANCH CONFIGURATION CHECKING try: mergify_config = rules.get_mergify_config(config_file) except rules.InvalidRules as e: # pragma: no cover ctxt.log.info( "The Mergify configuration is invalid", summary=str(e), annotations=e.get_annotations(e.filename), ) # Not configured, post status check with the error message for s in ctxt.sources: if s["event_type"] == "pull_request": event = typing.cast(github_types.GitHubEventPullRequest, s["data"]) if event["action"] in ("opened", "synchronize"): await ctxt.set_summary_check( check_api.Result( check_api.Conclusion.FAILURE, title="The Mergify configuration is invalid", summary=str(e), annotations=e.get_annotations(e.filename), )) break return # Add global and mandatory rules mergify_config["pull_request_rules"].rules.extend( DEFAULT_PULL_REQUEST_RULES.rules) if ctxt.pull["base"]["repo"][ "private"] and not ctxt.subscription.has_feature( subscription.Features.PRIVATE_REPOSITORY): ctxt.log.info("mergify disabled: private repository") await ctxt.set_summary_check( check_api.Result( check_api.Conclusion.FAILURE, title="Mergify is disabled", summary=ctxt.subscription.reason, )) return await _ensure_summary_on_head_sha(ctxt) # NOTE(jd): that's fine for now, but I wonder if we wouldn't need a higher abstraction # to have such things run properly. Like hooks based on events that you could # register. It feels hackish otherwise. for s in ctxt.sources: if s["event_type"] == "pull_request": event = typing.cast(github_types.GitHubEventPullRequest, s["data"]) if event["action"] == "closed": await ctxt.clear_cached_last_summary_head_sha() break ctxt.log.debug("engine handle actions") if ctxt.is_merge_queue_pr(): await queue_runner.handle(mergify_config["queue_rules"], ctxt) else: await actions_runner.handle(mergify_config["pull_request_rules"], ctxt)
async def run( ctxt: context.Context, sources: typing.List[context.T_PayloadEventSource], ) -> typing.Optional[check_api.Result]: LOG.debug("engine get context") ctxt.log.debug("engine start processing context") issue_comment_sources: typing.List[T_PayloadEventIssueCommentSource] = [] for source in sources: if source["event_type"] == "issue_comment": issue_comment_sources.append( typing.cast(T_PayloadEventIssueCommentSource, source)) else: ctxt.sources.append(source) permissions_need_to_be_updated = github_app.permissions_need_to_be_updated( ctxt.repository.installation.installation) if permissions_need_to_be_updated: return check_api.Result( check_api.Conclusion.FAILURE, title="Required GitHub permissions are missing.", summary="You can accept them at https://dashboard.mergify.com/", ) if ctxt.pull["base"]["repo"]["private"]: if not ctxt.subscription.has_feature( subscription.Features.PRIVATE_REPOSITORY): ctxt.log.info("mergify disabled: private repository", reason=ctxt.subscription.reason) return check_api.Result( check_api.Conclusion.FAILURE, title="Mergify is disabled", summary=ctxt.subscription.reason, ) else: if not ctxt.subscription.has_feature( subscription.Features.PUBLIC_REPOSITORY): ctxt.log.info("mergify disabled: public repository", reason=ctxt.subscription.reason) return check_api.Result( check_api.Conclusion.FAILURE, title="Mergify is disabled", summary=ctxt.subscription.reason, ) config_file = await ctxt.repository.get_mergify_config_file() try: ctxt.configuration_changed = await _check_configuration_changes( ctxt, config_file) except MultipleConfigurationFileFound as e: files = "\n * " + "\n * ".join(f["path"] for f in e.files) # NOTE(sileht): This replaces the summary, so we will may lost the # state of queue/comment action. But since we can't choice which config # file we need to use... we can't do much. return check_api.Result( check_api.Conclusion.FAILURE, title=constants.CONFIGURATION_MUTIPLE_FOUND_SUMMARY_TITLE, summary= f"You must keep only one of these configuration files in the repository: {files}", ) # BRANCH CONFIGURATION CHECKING try: mergify_config = await ctxt.repository.get_mergify_config() except rules.InvalidRules as e: # pragma: no cover ctxt.log.info( "The Mergify configuration is invalid", summary=str(e), annotations=e.get_annotations(e.filename), ) # Not configured, post status check with the error message for s in ctxt.sources: if s["event_type"] == "pull_request": event = typing.cast(github_types.GitHubEventPullRequest, s["data"]) if event["action"] in ("opened", "synchronize"): return check_api.Result( check_api.Conclusion.FAILURE, title="The current Mergify configuration is invalid", summary=str(e), annotations=e.get_annotations(e.filename), ) return None ctxt.log.debug("engine run pending commands") await commands_runner.run_pending_commands_tasks(ctxt, mergify_config) if issue_comment_sources: ctxt.log.debug("engine handle commands") for ic_source in issue_comment_sources: await commands_runner.handle( ctxt, mergify_config, ic_source["data"]["comment"]["body"], ic_source["data"]["comment"]["user"], ) await _ensure_summary_on_head_sha(ctxt) summary = await ctxt.get_engine_check_run(constants.SUMMARY_NAME) if (summary and summary["external_id"] is not None and summary["external_id"] != "" and summary["external_id"] != str(ctxt.pull["number"])): other_ctxt = await ctxt.repository.get_pull_request_context( github_types.GitHubPullRequestNumber(int(summary["external_id"]))) # NOTE(sileht): allow to override the summary of another pull request # only if this one is closed, but this can still confuse users as the # check-runs created by merge/queue action will not be cleaned. # TODO(sileht): maybe cancel all other mergify engine check-runs in this case? if not other_ctxt.closed: # TODO(sileht): try to report that without check-runs/statuses to the user # and without spamming him with comment ctxt.log.info( "sha collision detected between pull requests", other_pull=summary["external_id"], ) return None if not ctxt.has_been_opened() and summary is None: ctxt.log.warning( "the pull request doesn't have a summary", head_sha=ctxt.pull["head"]["sha"], ) ctxt.log.debug("engine handle actions") if ctxt.is_merge_queue_pr(): return await queue_runner.handle(mergify_config["queue_rules"], ctxt) else: return await actions_runner.handle( mergify_config["pull_request_rules"], ctxt)
async def run_pending_commands_tasks( ctxt: context.Context, mergify_config: rules.MergifyConfig) -> None: if ctxt.is_merge_queue_pr(): # We don't allow any command yet return pendings = set() async for comment in ctxt.client.items( f"{ctxt.base_url}/issues/{ctxt.pull['number']}/comments", resource_name="comments", page_limit=20, ): if comment["user"]["id"] != config.BOT_USER_ID: continue # Old format match = COMMAND_RESULT_MATCHER_OLD.search(comment["body"]) if match: command = match[1] state = match[2] if state == "pending": pendings.add(command) elif command in pendings: pendings.remove(command) continue # New format match = COMMAND_RESULT_MATCHER.search(comment["body"]) if match is None: continue try: payload = json.loads(match[1]) except Exception: LOG.warning("Unable to load command payload: %s", match[1]) continue command = payload.get("command") if not command: continue conclusion_str = payload.get("conclusion") try: conclusion = check_api.Conclusion(conclusion_str) except ValueError: LOG.error("Unable to load conclusions %s", conclusion_str) continue if conclusion == check_api.Conclusion.PENDING: pendings.add(command) elif command in pendings: try: pendings.remove(command) except KeyError: LOG.error("Unable to remove command: %s", command) for pending in pendings: await handle(ctxt, mergify_config, f"@Mergifyio {pending}", None, rerun=True)