Пример #1
0
    async def _update_merge_queue_summary(
        self,
        ctxt: context.Context,
        rule: "rules.EvaluatedRule",
        q: merge_train.Train,
        car: typing.Optional[merge_train.TrainCar],
    ) -> None:

        if car and car.creation_state == "updated" and not ctxt.closed:
            # NOTE(sileht): This car doesn't have tmp pull, so we have the
            # MERGE_QUEUE_SUMMARY and train reset here
            queue_rule_evaluated = await self.queue_rule.get_evaluated_queue_rule(
                ctxt.repository,
                ctxt.pull["base"]["ref"],
                [ctxt.pull_request],
                ctxt.log,
                ctxt.has_been_refreshed_by_timer(),
            )
            await delayed_refresh.plan_next_refresh(
                ctxt, [queue_rule_evaluated], ctxt.pull_request
            )

            unexpected_changes: typing.Optional[merge_train.UnexpectedChange]
            if await ctxt.has_been_synchronized_by_user() or await ctxt.is_behind:
                unexpected_changes = merge_train.UnexpectedUpdatedPullRequestChange(
                    ctxt.pull["number"]
                )
                status = check_api.Conclusion.PENDING
                ctxt.log.info(
                    "train will be reset", unexpected_changes=unexpected_changes
                )
                await q.reset(unexpected_changes)
            else:
                unexpected_changes = None
                status = await merge_base.get_rule_checks_status(
                    ctxt.log,
                    ctxt.repository,
                    [ctxt.pull_request],
                    queue_rule_evaluated,
                    unmatched_conditions_return_failure=False,
                )
            await car.update_state(status, queue_rule_evaluated)
            await car.update_summaries(status, unexpected_change=unexpected_changes)
            await q.save()
Пример #2
0
def load_conclusions(
    ctxt: context.Context,
    summary_check: typing.Optional[github_types.CachedGitHubCheckRun],
) -> typing.Dict[str, check_api.Conclusion]:
    line = load_conclusions_line(ctxt, summary_check)
    if line:
        return {
            name: check_api.Conclusion(conclusion)
            for name, conclusion in yaml.safe_load(
                base64.b64decode(line[5:-4].encode()).decode()
            ).items()
        }

    if not ctxt.has_been_opened():
        ctxt.log.warning(
            "previous conclusion not found in summary",
            summary_check=summary_check,
        )
    return {}
Пример #3
0
async def run_pending_commands_tasks(ctxt: context.Context) -> 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, f"@Mergifyio {pending}", None, rerun=True)
Пример #4
0
    def _should_be_cancel(
        self, ctxt: context.Context, rule: "rules.EvaluatedRule"
    ) -> bool:
        # It's closed, it's not going to change
        if ctxt.pull["state"] == "closed":
            return True

        if ctxt.have_been_synchronized():
            return True

        need_look_at_checks = []
        for condition in rule.missing_conditions:
            if condition.attribute_name.startswith(
                "check-"
            ) or condition.attribute_name.startswith("status-"):
                # TODO(sileht): Just return True here, no need to checks checks anymore,
                # this method is no more used by teh merge queue
                need_look_at_checks.append(condition)
            else:
                # something else does not match anymore
                return True

        if need_look_at_checks:
            if not ctxt.checks:
                return False

            states = [
                state
                for name, state in ctxt.checks.items()
                for cond in need_look_at_checks
                if cond(FakePR(cond.attribute_name, name))
            ]
            if not states:
                return False

            for state in states:
                if state in ("pending", None):
                    return False

        return True
Пример #5
0
    def run(self, ctxt: context.Context, rule: rules.EvaluatedRule) -> check_api.Result:
        if not config.GITHUB_APP:
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                "Unavailable with GitHub Action",
                "Due to GitHub Action limitation, the `rebase` command is only available "
                "with the Mergify GitHub App.",
            )

        if ctxt.is_behind:
            if ctxt.github_workflow_changed():
                return check_api.Result(
                    check_api.Conclusion.ACTION_REQUIRED,
                    "Pull request must be rebased manually.",
                    "GitHub App like Mergify are not allowed to rebase pull request where `.github/workflows` is changed.",
                )

            output = branch_updater.pre_rebase_check(ctxt)
            if output:
                return output

            try:
                branch_updater.rebase_with_git(ctxt, self.config["bot_account"])
                return check_api.Result(
                    check_api.Conclusion.SUCCESS,
                    "Branch has been successfully rebased",
                    "",
                )
            except (
                branch_updater.AuthenticationFailure,
                branch_updater.BranchUpdateFailure,
            ) as e:
                return check_api.Result(
                    check_api.Conclusion.FAILURE, "Branch rebase failed", str(e)
                )
        else:
            return check_api.Result(
                check_api.Conclusion.SUCCESS, "Branch already up to date", ""
            )
Пример #6
0
async def pre_rebase_check(ctxt: context.Context) -> None:
    pre_update_check(ctxt)

    # If PR from a private fork but cannot be edited:
    # NOTE(jd): GitHub removed the ability to configure `maintainer_can_modify` on private
    # fork we which make rebase impossible
    if (
        ctxt.pull_from_fork
        and ctxt.pull["base"]["repo"]["private"]
        and not ctxt.pull["maintainer_can_modify"]
    ):
        raise BranchUpdateFailure(
            "Mergify needs the permission to update the base branch of the pull request.\n"
            "GitHub does not allow a GitHub App to modify base branch for a private fork.\n"
            "You cannot `rebase` a pull request from a private fork.",
            title="Pull request can't be updated with latest base branch changes",
        )
    elif not ctxt.can_change_github_workflow() and await ctxt.github_workflow_changed():
        raise BranchUpdateFailure(
            "The new Mergify permissions must be accepted to rebase pull request with `.github/workflows` changes.\n"
            "You can accept them at https://dashboard.mergify.com/.\n"
            "In the meantime, this pull request must be rebased manually.",
            title="Pull request can't be updated with latest base branch changes",
        )
Пример #7
0
    async def run(self, ctxt: context.Context,
                  rule: "rules.EvaluatedRule") -> check_api.Result:
        subscription_status = await self._subscription_status(ctxt)
        if subscription_status:
            return subscription_status

        if self.config["method"] == "fast-forward":
            if self.config["update_method"] != "rebase":
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    f"`update_method: {self.config['update_method']}` is not compatible with fast-forward merge method",
                    "`update_method` must be set to `rebase`.",
                )
            elif self.config["commit_message_template"] is not None:
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "Commit message can't be changed with fast-forward merge method",
                    "`commit_message_template` must not be set if `method: fast-forward` is set.",
                )
            elif self.queue_rule.config["batch_size"] > 1:
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "batch_size > 1 is not compatible with fast-forward merge method",
                    "The merge `method` or the queue configuration must be updated.",
                )
            elif self.queue_rule.config["speculative_checks"] > 1:
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "speculative_checks > 1 is not compatible with fast-forward merge method",
                    "The merge `method` or the queue configuration must be updated.",
                )
            elif not self.queue_rule.config["allow_inplace_checks"]:
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "allow_inplace_checks=False is not compatible with fast-forward merge method",
                    "The merge `method` or the queue configuration must be updated.",
                )

        protection = await ctxt.repository.get_branch_protection(
            ctxt.pull["base"]["ref"])
        if (protection and "required_status_checks" in protection
                and "strict" in protection["required_status_checks"]
                and protection["required_status_checks"]["strict"]):
            if self.queue_rule.config["batch_size"] > 1:
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "batch_size > 1 is not compatible with branch protection setting",
                    "The branch protection setting `Require branches to be up to date before merging` must be unset.",
                )
            elif self.queue_rule.config["speculative_checks"] > 1:
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "speculative_checks > 1 is not compatible with branch protection setting",
                    "The branch protection setting `Require branches to be up to date before merging` must be unset.",
                )

        # FIXME(sileht): we should use the computed update_bot_account in TrainCar.update_pull(),
        # not the original one
        try:
            await action_utils.render_bot_account(
                ctxt,
                self.config["update_bot_account"],
                option_name="update_bot_account",
                required_feature=subscription.Features.MERGE_BOT_ACCOUNT,
                missing_feature_message=
                "Queue with `update_bot_account` set is unavailable",
            )
        except action_utils.RenderBotAccountFailure as e:
            return check_api.Result(e.status, e.title, e.reason)

        try:
            merge_bot_account = await action_utils.render_bot_account(
                ctxt,
                self.config["merge_bot_account"],
                option_name="merge_bot_account",
                required_feature=subscription.Features.MERGE_BOT_ACCOUNT,
                missing_feature_message=
                "Queue with `merge_bot_account` set is unavailable",
                # NOTE(sileht): we don't allow admin, because if branch protection are
                # enabled, but not enforced on admins, we may bypass them
                required_permissions=["write", "maintain"],
            )
        except action_utils.RenderBotAccountFailure as e:
            return check_api.Result(e.status, e.title, e.reason)

        q = await merge_train.Train.from_context(ctxt)
        car = q.get_car(ctxt)
        await self._update_merge_queue_summary(ctxt, rule, q, car)

        if ctxt.user_refresh_requested() or ctxt.admin_refresh_requested():
            # NOTE(sileht): user ask a refresh, we just remove the previous state of this
            # check and the method _should_be_queued will become true again :)
            check = await ctxt.get_engine_check_run(
                constants.MERGE_QUEUE_SUMMARY_NAME)
            if check and check_api.Conclusion(check["conclusion"]) not in [
                    check_api.Conclusion.SUCCESS,
                    check_api.Conclusion.PENDING,
                    check_api.Conclusion.NEUTRAL,
            ]:
                await check_api.set_check_run(
                    ctxt,
                    constants.MERGE_QUEUE_SUMMARY_NAME,
                    check_api.Result(
                        check_api.Conclusion.PENDING,
                        "The pull request has been refreshed and is going to be re-embarked soon",
                        "",
                    ),
                )

        self._set_effective_priority(ctxt)

        result = await self.merge_report(ctxt)
        if result is None:
            if await self._should_be_queued(ctxt, q):
                await q.add_pull(
                    ctxt, typing.cast(queue.PullQueueConfig, self.config))
                try:
                    qf = await freeze.QueueFreeze.get(ctxt.repository,
                                                      self.config["name"])
                    if await self._should_be_merged(ctxt, q, qf):
                        result = await self._merge(ctxt, rule, q,
                                                   merge_bot_account)
                    else:
                        result = await self.get_queue_status(ctxt, rule, q, qf)

                except Exception:
                    await q.remove_pull(ctxt)
                    raise
            else:
                result = await self.get_unqueue_status(ctxt, q)

        if result.conclusion is not check_api.Conclusion.PENDING:
            await q.remove_pull(ctxt)

        # NOTE(sileht): Only refresh if the car still exists and is the same as
        # before we run the action
        new_car = q.get_car(ctxt)
        if (car and car.queue_pull_request_number is not None and new_car
                and new_car.creation_state == "created"
                and new_car.queue_pull_request_number is not None
                and new_car.queue_pull_request_number
                == car.queue_pull_request_number
                and self.need_draft_pull_request_refresh()
                and not ctxt.has_been_only_refreshed()):
            # NOTE(sileht): It's not only refreshed, so we need to
            # update the associated transient pull request.
            # This is mandatory to filter out refresh to avoid loop
            # of refreshes between this PR and the transient one.
            await utils.send_pull_refresh(
                ctxt.repository.installation.redis.stream,
                ctxt.pull["base"]["repo"],
                pull_request_number=new_car.queue_pull_request_number,
                action="internal",
                source="forward from queue action (run)",
            )
        return result
Пример #8
0
async def handle(queue_rules: rules.QueueRules, ctxt: context.Context) -> None:
    # FIXME: Maybe create a command to force the retesting to put back the PR in the queue?

    train = await merge_train.Train.from_context(ctxt)

    car = train.get_car_by_tmp_pull(ctxt)
    if not car:
        if ctxt.closed:
            ctxt.log.info(
                "train car temporary pull request has been closed", sources=ctxt.sources
            )
        else:
            ctxt.log.warning(
                "train car not found for an opened merge queue pull request",
                sources=ctxt.sources,
            )

        return

    if car.checks_conclusion != check_api.Conclusion.PENDING and ctxt.closed:
        ctxt.log.info(
            "train car temporary pull request has been closed", sources=ctxt.sources
        )
        return

    if car.queue_pull_request_number is None:
        raise RuntimeError(
            "Got draft pull request event on car without queue_pull_request_number"
        )

    ctxt.log.info(
        "handling train car temporary pull request event",
        sources=ctxt.sources,
        gh_pulls_queued=[
            ep.user_pull_request_number for ep in car.still_queued_embarked_pulls
        ],
    )

    queue_name = car.still_queued_embarked_pulls[0].config["name"]
    try:
        queue_rule = queue_rules[queue_name]
    except KeyError:
        ctxt.log.warning(
            "queue_rule not found for this train car",
            gh_pulls_queued=[
                ep.user_pull_request_number for ep in car.still_queued_embarked_pulls
            ],
            queue_rules=queue_rules,
            queue_name=queue_name,
        )
        return

    pull_requests = await car.get_pull_requests_to_evaluate()
    evaluated_queue_rule = await queue_rule.get_evaluated_queue_rule(
        ctxt.repository,
        ctxt.pull["base"]["ref"],
        pull_requests,
        ctxt.log,
        ctxt.has_been_refreshed_by_timer(),
    )

    for pull_request in pull_requests:
        await delayed_refresh.plan_next_refresh(
            ctxt, [evaluated_queue_rule], pull_request
        )

    if not ctxt.sources:
        # NOTE(sileht): Only comment/command, don't need to go further
        return None

    unexpected_changes: typing.Optional[merge_train.UnexpectedChange] = None
    if await have_unexpected_draft_pull_request_changes(ctxt, car):
        unexpected_changes = merge_train.UnexpectedDraftPullRequestChange(
            car.queue_pull_request_number
        )
    else:
        current_base_sha = await train.get_base_sha()
        if not await train.is_synced_with_the_base_branch(current_base_sha):
            unexpected_changes = merge_train.UnexpectedBaseBranchChange(
                current_base_sha
            )

    if unexpected_changes is None:
        real_status = status = await checks_status.get_rule_checks_status(
            ctxt.log,
            ctxt.repository,
            pull_requests,
            evaluated_queue_rule,
            unmatched_conditions_return_failure=False,
        )
        if real_status == check_api.Conclusion.FAILURE and (
            not car.has_previous_car_status_succeeded()
            or len(car.initial_embarked_pulls) != 1
        ):
            # NOTE(sileht): we can't set it as failed as we don't known
            # yet which pull request is responsible for the failure.
            # * one of the batch ?
            # * one of the parent car ?
            status = check_api.Conclusion.PENDING
    else:
        real_status = status = check_api.Conclusion.PENDING

    ctxt.log.info(
        "train car temporary pull request evaluation",
        gh_pull_queued=[
            ep.user_pull_request_number for ep in car.still_queued_embarked_pulls
        ],
        evaluated_queue_rule=evaluated_queue_rule.conditions.get_summary(),
        unexpected_changes=unexpected_changes,
        temporary_status=status,
        real_status=real_status,
        event_types=[se["event_type"] for se in ctxt.sources],
    )

    await car.update_state(real_status, evaluated_queue_rule)
    await car.update_summaries(status, unexpected_change=unexpected_changes)
    await train.save()

    if unexpected_changes:
        ctxt.log.info(
            "train will be reset",
            gh_pull_queued=[
                ep.user_pull_request_number for ep in car.still_queued_embarked_pulls
            ],
            unexpected_changes=unexpected_changes,
        )
        await train.reset(unexpected_changes)

        await ctxt.client.post(
            f"{ctxt.base_url}/issues/{ctxt.pull['number']}/comments",
            json={
                "body": f"This pull request has unexpected changes: {unexpected_changes}. The whole train will be reset."
            },
        )
Пример #9
0
    def _merge(
        self,
        ctxt: context.Context,
        rule: rules.Rule,
        missing_conditions: typing.List[filter.Filter],
        q: queue.Queue,
    ) -> check_api.Result:
        if self.config["method"] != "rebase" or ctxt.pull["rebaseable"]:
            method = self.config["method"]
        elif self.config["rebase_fallback"]:
            method = self.config["rebase_fallback"]
        else:
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Automatic rebasing is not possible, manual intervention required",
                "",
            )

        data = {}

        try:
            commit_title_and_message = self._get_commit_message(
                ctxt.pull_request,
                self.config["commit_message"],
            )
        except context.RenderTemplateFailure as rmf:
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Invalid commit message",
                str(rmf),
            )

        if commit_title_and_message is not None:
            title, message = commit_title_and_message
            if title:
                data["commit_title"] = title
            if message:
                data["commit_message"] = message

        data["sha"] = ctxt.pull["head"]["sha"]
        data["merge_method"] = method

        bot_account = self.config["merge_bot_account"]
        if bot_account:
            oauth_token = ctxt.subscription.get_token_for(bot_account)
            if not oauth_token:
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    f"Unable to rebase: user `{bot_account}` is unknown. ",
                    f"Please make sure `{bot_account}` has logged in Mergify dashboard.",
                )
        else:
            oauth_token = None

        try:
            ctxt.client.put(
                f"{ctxt.base_url}/pulls/{ctxt.pull['number']}/merge",
                oauth_token=oauth_token,  # type: ignore
                json=data,
            )
        except http.HTTPClientSideError as e:  # pragma: no cover
            ctxt.update()
            if ctxt.pull["merged"]:
                ctxt.log.info("merged in the meantime")
            else:
                return self._handle_merge_error(e, ctxt, rule,
                                                missing_conditions, q)
        else:
            ctxt.update()
            ctxt.log.info("merged")

        result = helpers.merge_report(ctxt, self.config["strict"])
        if result:
            return result
        else:
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                "Unexpected after merge pull request state",
                "The pull request have been merged, but GitHub API still report it open",
            )
Пример #10
0
async def run_actions(
    ctxt: context.Context,
    match: rules.RulesEvaluator,
    checks: typing.Dict[str, github_types.GitHubCheckRun],
    previous_conclusions: typing.Dict[str, check_api.Conclusion],
) -> typing.Dict[str, check_api.Conclusion]:
    """
    What action.run() and action.cancel() return should be reworked a bit. Currently the
    meaning is not really clear, it could be:
    - None - (succeed but no dedicated report is posted with check api
    - (None, "<title>", "<summary>") - (action is pending, for merge/backport/...)
    - ("success", "<title>", "<summary>")
    - ("failure", "<title>", "<summary>")
    - ("neutral", "<title>", "<summary>")
    - ("cancelled", "<title>", "<summary>")
    """

    user_refresh_requested = ctxt.user_refresh_requested()
    admin_refresh_requested = ctxt.admin_refresh_requested()
    actions_ran = set()
    conclusions = {}

    # NOTE(sileht): We put first rules with missing conditions to do cancellation first.
    # In case of a canceled merge action and another that need to be run. We want first
    # to remove the PR from the queue and then add it back with the new config and not the
    # reverse
    matching_rules = sorted(match.matching_rules,
                            key=lambda rule: len(rule.missing_conditions) == 0)

    method_name: typing.Literal["run", "cancel"]

    for rule in matching_rules:
        for action, action_obj in rule.actions.items():
            check_name = f"Rule: {rule.name} ({action})"

            done_by_another_action = action_obj.only_once and action in actions_ran

            action_rule = await action_obj.get_rule(ctxt)

            if rule.missing_conditions or action_rule.missing_conditions:
                method_name = "cancel"
                expected_conclusions = [
                    check_api.Conclusion.NEUTRAL,
                    check_api.Conclusion.CANCELLED,
                ]
            else:
                method_name = "run"
                expected_conclusions = [
                    check_api.Conclusion.SUCCESS,
                    check_api.Conclusion.FAILURE,
                ]
                actions_ran.add(action)

            previous_conclusion = get_previous_conclusion(
                previous_conclusions, check_name, checks)

            need_to_be_run = (
                action_obj.always_run or admin_refresh_requested
                or (user_refresh_requested
                    and previous_conclusion == check_api.Conclusion.FAILURE)
                or previous_conclusion not in expected_conclusions)

            # TODO(sileht): refactor it to store the whole report in the check summary,
            # not just the conclusions

            if not need_to_be_run:
                report = check_api.Result(previous_conclusion,
                                          "Already in expected state", "")
                message = "ignored, already in expected state"

            elif done_by_another_action:
                # NOTE(sileht) We can't run two action merge for example,
                # This assumes the action produce a report
                report = check_api.Result(
                    check_api.Conclusion.SUCCESS,
                    f"Another {action} action already ran",
                    "",
                )
                message = "ignored, another has already been run"

            else:
                # NOTE(sileht): check state change so we have to run "run" or "cancel"
                report = await exec_action(
                    method_name,
                    rule,
                    action,
                    ctxt,
                )
                message = "executed"

            if (report
                    and report.conclusion is not check_api.Conclusion.PENDING
                    and method_name == "run"):
                statsd.increment("engine.actions.count",
                                 tags=[f"name:{action}"])

            if report:
                if need_to_be_run and (not action_obj.silent_report
                                       or report.conclusion not in (
                                           check_api.Conclusion.SUCCESS,
                                           check_api.Conclusion.CANCELLED,
                                           check_api.Conclusion.PENDING,
                                       )):
                    external_id = (check_api.USER_CREATED_CHECKS
                                   if action_obj.allow_retrigger_mergify else
                                   None)
                    try:
                        await check_api.set_check_run(
                            ctxt,
                            check_name,
                            report,
                            external_id=external_id,
                        )
                    except Exception as e:
                        if exceptions.should_be_ignored(e):
                            ctxt.log.info("Fail to post check `%s`",
                                          check_name,
                                          exc_info=True)
                        elif exceptions.need_retry(e):
                            raise
                        else:
                            ctxt.log.error("Fail to post check `%s`",
                                           check_name,
                                           exc_info=True)
                conclusions[check_name] = report.conclusion
            else:
                # NOTE(sileht): action doesn't have report (eg:
                # comment/request_reviews/..) So just assume it succeed
                ctxt.log.error("action must return a conclusion",
                               action=action)
                conclusions[check_name] = expected_conclusions[0]

            ctxt.log.info(
                "action evaluation: `%s` %s: %s/%s -> %s",
                action,
                message,
                method_name,
                previous_conclusion.value,
                conclusions[check_name].value,
                report=report,
                previous_conclusion=previous_conclusion.value,
                conclusion=conclusions[check_name].value,
                action=action,
                check_name=check_name,
                event_types=[se["event_type"] for se in ctxt.sources],
            )

    return conclusions
Пример #11
0
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)
Пример #12
0
    async def _edit_draft_state(
        self, ctxt: context.Context, draft_converted: bool
    ) -> check_api.Result:

        if draft_converted:
            expected_state = True
            current_state = "draft"
            mutation = "convertPullRequestToDraft"
        else:
            expected_state = False
            current_state = "ready for review"
            mutation = "markPullRequestReadyForReview"

        if ctxt.pull["draft"] == expected_state:
            return check_api.Result(
                check_api.Conclusion.SUCCESS,
                f"Pull request is already {current_state}.",
                "",
            )

        try:
            bot_account = await action_utils.render_bot_account(
                ctxt,
                self.config["bot_account"],
                required_feature=subscription.Features.BOT_ACCOUNT,
                missing_feature_message=f"{current_state} with `bot_account` set is disabled",
                required_permissions=[],
            )
        except action_utils.RenderBotAccountFailure as e:
            return check_api.Result(e.status, e.title, e.reason)

        try:
            tokens = await user_tokens.UserTokens.select_users_for(ctxt, bot_account)
        except user_tokens.UserTokensUserNotFound as e:
            return check_api.Result(
                check_api.Conclusion.FAILURE, "Fail to convert pull request", e.reason
            )

        mutation = f"""
            mutation {{
                {mutation}(input:{{pullRequestId: "{ctxt.pull['node_id']}"}}) {{
                    pullRequest {{
                        isDraft
                    }}
                }}
            }}
        """
        try:
            await ctxt.client.graphql_post(
                mutation,
                oauth_token=tokens[0]["oauth_access_token"],
            )
        except github.GraphqlError as e:
            if "Field 'convertPullRequestToDraft' doesn't exist" in e.message:
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "Converting pull request to draft requires GHES >= 3.2",
                    "",
                )
            ctxt.log.error(
                "GraphQL API call failed, unable to convert PR.",
                current_state=current_state,
                response=e.message,
            )
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                f"GraphQL API call failed, pull request wasn't converted to {current_state}.",
                "",
            )
        ctxt.pull["draft"] = expected_state
        return check_api.Result(
            check_api.Conclusion.SUCCESS,
            f"Pull request successfully converted to {current_state}",
            "",
        )
Пример #13
0
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)
Пример #14
0
    async def cancel(
        self, ctxt: context.Context, rule: "rules.EvaluatedRule"
    ) -> check_api.Result:
        # FIXME(sileht): we should use the computed update_bot_account in TrainCar.update_pull(),
        # not the original one
        try:
            await action_utils.render_bot_account(
                ctxt,
                self.config["update_bot_account"],
                option_name="update_bot_account",
                required_feature=subscription.Features.MERGE_BOT_ACCOUNT,
                missing_feature_message="Queue with `update_bot_account` set is unavailable",
            )
        except action_utils.RenderBotAccountFailure as e:
            return check_api.Result(e.status, e.title, e.reason)

        self._set_effective_priority(ctxt)

        q = await merge_train.Train.from_context(ctxt)
        car = q.get_car(ctxt)
        await self._update_merge_queue_summary(ctxt, rule, q, car)

        result = await self.merge_report(ctxt)
        if result is None:
            # We just rebase the pull request, don't cancel it yet if CIs are
            # running. The pull request will be merged if all rules match again.
            # if not we will delete it when we received all CIs termination
            if await self._should_be_queued(ctxt, q):
                if await self._should_be_cancel(ctxt, rule, q):
                    result = actions.CANCELLED_CHECK_REPORT
                else:
                    result = await self.get_queue_status(ctxt, rule, q)
            else:
                result = await self.get_unqueue_status(ctxt, q)

        if result.conclusion is not check_api.Conclusion.PENDING:
            await q.remove_pull(ctxt)

        # The car may have been removed
        newcar = q.get_car(ctxt)
        # NOTE(sileht): Only refresh if the car still exists
        if (
            newcar
            and newcar.creation_state == "created"
            and newcar.queue_pull_request_number is not None
            and self.need_draft_pull_request_refresh()
            and not ctxt.has_been_only_refreshed()
        ):
            # NOTE(sileht): It's not only refreshed, so we need to
            # update the associated transient pull request.
            # This is mandatory to filter out refresh to avoid loop
            # of refreshes between this PR and the transient one.
            with utils.yaaredis_for_stream() as redis_stream:
                await utils.send_pull_refresh(
                    ctxt.repository.installation.redis,
                    redis_stream,
                    ctxt.pull["base"]["repo"],
                    pull_request_number=newcar.queue_pull_request_number,
                    action="internal",
                    source="forward from queue action (cancel)",
                )
        return result
Пример #15
0
async def run_actions(
    ctxt: context.Context,
    match: rules.RulesEvaluator,
    checks: typing.Dict[str, github_types.CachedGitHubCheckRun],
    previous_conclusions: typing.Dict[str, check_api.Conclusion],
) -> typing.Dict[str, check_api.Conclusion]:
    """
    What action.run() and action.cancel() return should be reworked a bit. Currently the
    meaning is not really clear, it could be:
    - None - (succeed but no dedicated report is posted with check api
    - (None, "<title>", "<summary>") - (action is pending, for merge/backport/...)
    - ("success", "<title>", "<summary>")
    - ("failure", "<title>", "<summary>")
    - ("neutral", "<title>", "<summary>")
    - ("cancelled", "<title>", "<summary>")
    """

    user_refresh_requested = ctxt.user_refresh_requested()
    admin_refresh_requested = ctxt.admin_refresh_requested()
    actions_ran = set()
    conclusions = {}

    # NOTE(sileht): We put first rules with missing conditions to do cancellation first.
    # In case of a canceled merge action and another that need to be run. We want first
    # to remove the PR from the queue and then add it back with the new config and not the
    # reverse
    matching_rules = sorted(
        match.matching_rules, key=lambda rule: rule.conditions.match
    )

    method_name: typing.Literal["run", "cancel"]

    for rule in matching_rules:
        for action, action_obj in rule.actions.items():
            check_name = rule.get_check_name(action)

            done_by_another_action = (
                actions.ActionFlag.DISALLOW_RERUN_ON_OTHER_RULES in action_obj.flags
                and action in actions_ran
            )

            if (
                not rule.conditions.match
                or rule.disabled is not None
                or (
                    ctxt.configuration_changed
                    and actions.ActionFlag.ALLOW_ON_CONFIGURATION_CHANGED
                    not in action_obj.flags
                )
            ):
                method_name = "cancel"
                expected_conclusions = [
                    check_api.Conclusion.NEUTRAL,
                    check_api.Conclusion.CANCELLED,
                ]
            else:
                method_name = "run"
                expected_conclusions = [
                    check_api.Conclusion.SUCCESS,
                    check_api.Conclusion.FAILURE,
                ]
                actions_ran.add(action)

            previous_conclusion = get_previous_conclusion(
                previous_conclusions, check_name, checks
            )

            need_to_be_run = (
                actions.ActionFlag.ALWAYS_RUN in action_obj.flags
                or (
                    actions.ActionFlag.SUCCESS_IS_FINAL_STATE in action_obj.flags
                    and previous_conclusion == check_api.Conclusion.SUCCESS
                )
                or admin_refresh_requested
                or (
                    user_refresh_requested
                    and previous_conclusion == check_api.Conclusion.FAILURE
                )
                or previous_conclusion not in expected_conclusions
            )

            # TODO(sileht): refactor it to store the whole report in the check summary,
            # not just the conclusions

            if not need_to_be_run:
                report = check_api.Result(
                    previous_conclusion, "Already in expected state", ""
                )
                message = "ignored, already in expected state"

            elif done_by_another_action:
                # NOTE(sileht) We can't run two action merge for example,
                # This assumes the action produce a report
                report = check_api.Result(
                    check_api.Conclusion.SUCCESS,
                    f"Another {action} action already ran",
                    "",
                )
                message = "ignored, another has already been run"

            else:
                with ddtrace.tracer.trace(
                    f"action.{action}",
                    span_type="worker",
                    resource=str(ctxt),
                ) as span:
                    # NOTE(sileht): check state change so we have to run "run" or "cancel"
                    report = await exec_action(
                        method_name,
                        rule,
                        action,
                        ctxt,
                    )
                    span.set_tags({"conclusion": str(report.conclusion)})

                message = "executed"

            conclusions[check_name] = report.conclusion

            if (
                report.conclusion is not check_api.Conclusion.PENDING
                and method_name == "run"
            ):
                statsd.increment("engine.actions.count", tags=[f"name:{action}"])

            if need_to_be_run and (
                actions.ActionFlag.ALWAYS_SEND_REPORT in action_obj.flags
                or report.conclusion
                not in (
                    check_api.Conclusion.SUCCESS,
                    check_api.Conclusion.CANCELLED,
                    check_api.Conclusion.PENDING,
                )
            ):
                external_id = (
                    check_api.USER_CREATED_CHECKS
                    if actions.ActionFlag.ALLOW_RETRIGGER_MERGIFY in action_obj.flags
                    else None
                )
                try:
                    await check_api.set_check_run(
                        ctxt,
                        check_name,
                        report,
                        external_id=external_id,
                    )
                except Exception as e:
                    if exceptions.should_be_ignored(e):
                        ctxt.log.info(
                            "Fail to post check `%s`", check_name, exc_info=True
                        )
                    elif exceptions.need_retry(e):
                        raise
                    else:
                        ctxt.log.error(
                            "Fail to post check `%s`", check_name, exc_info=True
                        )

            ctxt.log.info(
                "action evaluation: `%s` %s: %s/%s -> %s",
                action,
                message,
                method_name,
                previous_conclusion.value,
                conclusions[check_name].value,
                report=report,
                previous_conclusion=previous_conclusion.value,
                conclusion=conclusions[check_name].value,
                action=action,
                check_name=check_name,
                event_types=[se["event_type"] for se in ctxt.sources],
            )

    return conclusions
Пример #16
0
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

    command = load_command(mergify_config, comment)
    if not command:
        message = UNKNOWN_COMMAND_MESSAGE
        log(message)
        await post_comment(ctxt, message + footer)
        return

    if (
        ctxt.configuration_changed
        and not command.action.can_be_used_on_configuration_change
    ):
        message = CONFIGURATION_CHANGE_MESSAGE
        log(message)
        await post_comment(ctxt, message + footer)
        return

    if command.name != "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_command(ctxt, 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 + footer)
Пример #17
0
    async def merge_report(
        self,
        ctxt: context.Context,
    ) -> typing.Optional[check_api.Result]:
        if ctxt.pull["draft"]:
            conclusion = check_api.Conclusion.PENDING
            title = "Draft flag needs to be removed"
            summary = ""
        elif ctxt.pull["merged"]:
            if ctxt.pull["merged_by"] is None:
                mode = "somehow"
            elif ctxt.pull["merged_by"]["login"] == config.BOT_USER_LOGIN:
                mode = "automatically"
            else:
                mode = "manually"
            conclusion = check_api.Conclusion.SUCCESS
            title = f"The pull request has been merged {mode}"
            summary = f"The pull request has been merged {mode} at *{ctxt.pull['merge_commit_sha']}*"
        elif ctxt.closed:
            conclusion = check_api.Conclusion.CANCELLED
            title = "The pull request has been closed manually"
            summary = ""

        # NOTE(sileht): Take care of all branch protection state
        elif ctxt.pull["mergeable_state"] == "dirty":
            conclusion = check_api.Conclusion.CANCELLED
            title = "Merge conflict needs to be solved"
            summary = ""

        elif ctxt.pull["mergeable_state"] == "unknown":
            conclusion = check_api.Conclusion.FAILURE
            title = "Pull request state reported as `unknown` by GitHub"
            summary = ""
        # FIXME(sileht): We disable this check as github wrongly report
        # mergeable_state == blocked sometimes. The workaround is to try to merge
        # it and if that fail we checks for blocking state.
        # elif ctxt.pull["mergeable_state"] == "blocked":
        #     conclusion = "failure"
        #     title = "Branch protection settings are blocking automatic merging"
        #     summary = ""

        elif (await self._is_branch_protection_linear_history_enabled(ctxt)
              and self.config["method"] == "merge"):
            conclusion = check_api.Conclusion.FAILURE
            title = "Branch protection setting 'linear history' conflicts with Mergify configuration"
            summary = "Branch protection setting 'linear history' works only if `method: squash` or `method: rebase`."

        elif (not ctxt.can_change_github_workflow()
              and await ctxt.github_workflow_changed()):
            conclusion = check_api.Conclusion.ACTION_REQUIRED
            title = "Pull request must be merged manually"
            summary = """The new Mergify permissions must be accepted to merge pull request with `.github/workflows` changes.\n
You can accept them at https://dashboard.mergify.com/\n
\n
In the meantime, the pull request must be merged manually."
"""

        # NOTE(sileht): remaining state "behind, clean, unstable, has_hooks
        # are OK for us
        else:
            return None

        return check_api.Result(conclusion, title, summary)
Пример #18
0
    async def run(
        self, ctxt: context.Context, rule: rules.EvaluatedRule
    ) -> check_api.Result:
        if (
            not ctxt.can_change_github_workflow()
            and await ctxt.github_workflow_changed()
        ):
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                self.FAILURE_MESSAGE,
                "The new Mergify permissions must be accepted to create pull request with `.github/workflows` changes.\n"
                "You can accept them at https://dashboard.mergify.com/",
            )

        template_result = await self._verify_template(ctxt)
        if template_result is not None:
            return template_result

        try:
            bot_account = await action_utils.render_bot_account(
                ctxt,
                self.config["bot_account"],
                required_feature=subscription.Features.BOT_ACCOUNT,
                missing_feature_message=f"{self.KIND.capitalize()} with `bot_account` set is unavailable",
            )
        except action_utils.RenderBotAccountFailure as e:
            return check_api.Result(e.status, e.title, e.reason)

        branches: typing.List[github_types.GitHubRefType] = self.config["branches"]
        if self.config["regexes"]:
            branches.extend(
                [
                    branch["name"]
                    async for branch in typing.cast(
                        typing.AsyncGenerator[github_types.GitHubBranch, None],
                        ctxt.client.items(
                            f"{ctxt.base_url}/branches",
                            resource_name="branches",
                            page_limit=10,
                        ),
                    )
                    if any(
                        map(
                            lambda regex: regex.match(branch["name"]),
                            self.config["regexes"],
                        )
                    )
                ]
            )

        if len(branches) == 0:
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                self.FAILURE_MESSAGE,
                "No destination branches found",
            )

        results = [
            await self._copy(ctxt, branch_name, bot_account) for branch_name in branches
        ]

        # Pick the first status as the final_status
        conclusion = results[0][0]
        for r in results[1:]:
            if r[0] == check_api.Conclusion.FAILURE:
                conclusion = check_api.Conclusion.FAILURE
                # If we have a failure, everything is set to fail
                break
            elif r[0] == check_api.Conclusion.SUCCESS:
                # If it was None, replace with success
                # Keep checking for a failure just in case
                conclusion = check_api.Conclusion.SUCCESS

        if conclusion == check_api.Conclusion.SUCCESS:
            message = self.SUCCESS_MESSAGE
        elif conclusion == check_api.Conclusion.FAILURE:
            message = self.FAILURE_MESSAGE
        else:
            message = "Pending"

        return check_api.Result(
            conclusion,
            message,
            "\n".join(f"* {detail}" for detail in (r[1] for r in results)),
        )
Пример #19
0
    async def run(
        self, ctxt: context.Context, rule: rules.EvaluatedRule
    ) -> check_api.Result:

        if self.config["message"] is None:
            message_raw = DEFAULT_MESSAGE[self.config["when"]]
        else:
            message_raw = typing.cast(str, self.config["message"])

        try:
            message = await ctxt.pull_request.render_template(message_raw)
        except context.RenderTemplateFailure as rmf:
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                "Invalid dismiss reviews message",
                str(rmf),
            )

        if self.config["when"] == WHEN_SYNCHRONIZE and not ctxt.has_been_synchronized():
            return check_api.Result(
                check_api.Conclusion.SUCCESS,
                "Nothing to do, pull request has not been synchronized",
                "",
            )

        # FIXME(sileht): Currently sender id is not the bot by the admin
        # user that enroll the repo in Mergify, because branch_updater uses
        # his access_token instead of the Mergify installation token.
        # As workaround we track in redis merge commit id
        # This is only true for method="rebase"
        if (
            self.config["when"] == WHEN_SYNCHRONIZE
            and not await ctxt.has_been_synchronized_by_user()
        ):
            return check_api.Result(
                check_api.Conclusion.SUCCESS, "Updated by Mergify, ignoring", ""
            )

        requested_reviewers_login = [
            rr["login"] for rr in ctxt.pull["requested_reviewers"]
        ]

        to_dismiss = set()
        for review in (await ctxt.consolidated_reviews())[1]:
            conf = self.config.get(review["state"].lower(), False)
            if conf is True:
                to_dismiss.add(review["id"])
            elif conf == FROM_REQUESTED_REVIEWERS:
                if review["user"]["login"] in requested_reviewers_login:
                    to_dismiss.add(review["id"])
            elif isinstance(conf, list):
                if review["user"]["login"] in conf:
                    to_dismiss.add(review["id"])

        if not to_dismiss:
            return check_api.Result(
                check_api.Conclusion.SUCCESS, "Nothing to dismiss", ""
            )

        errors = set()
        for review_id in to_dismiss:
            try:
                await ctxt.client.put(
                    f"{ctxt.base_url}/pulls/{ctxt.pull['number']}/reviews/{review_id}/dismissals",
                    json={"message": message},
                )
            except http.HTTPClientSideError as e:  # pragma: no cover
                errors.add(f"GitHub error: [{e.status_code}] `{e.message}`")

        if errors:
            return check_api.Result(
                check_api.Conclusion.PENDING,
                "Unable to dismiss review",
                "\n".join(errors),
            )
        else:
            await signals.send(ctxt, "action.dismiss_reviews")
            return check_api.Result(
                check_api.Conclusion.SUCCESS, "Review dismissed", ""
            )
Пример #20
0
    def merge_report(self, ctxt: context.Context) -> typing.Optional[check_api.Result]:
        if ctxt.pull["draft"]:
            conclusion = check_api.Conclusion.PENDING
            title = "Draft flag needs to be removed"
            summary = ""
        elif ctxt.pull["merged"]:
            if ctxt.pull["merged_by"] is None:
                mode = "somehow"
            elif ctxt.pull["merged_by"]["login"] in [
                "mergify[bot]",
                "mergify-test[bot]",
            ]:
                mode = "automatically"
            else:
                mode = "manually"
            conclusion = check_api.Conclusion.SUCCESS
            title = "The pull request has been merged %s" % mode
            summary = "The pull request has been merged %s at *%s*" % (
                mode,
                ctxt.pull["merge_commit_sha"],
            )
        elif ctxt.pull["state"] == "closed":
            conclusion = check_api.Conclusion.CANCELLED
            title = "The pull request has been closed manually"
            summary = ""

        # NOTE(sileht): Take care of all branch protection state
        elif ctxt.pull["mergeable_state"] == "dirty":
            conclusion = check_api.Conclusion.CANCELLED
            title = "Merge conflict needs to be solved"
            summary = ""

        elif ctxt.pull["mergeable_state"] == "unknown":
            conclusion = check_api.Conclusion.FAILURE
            title = "Pull request state reported as `unknown` by GitHub"
            summary = ""
        # FIXME(sileht): We disable this check as github wrongly report
        # mergeable_state == blocked sometimes. The workaround is to try to merge
        # it and if that fail we checks for blocking state.
        # elif ctxt.pull["mergeable_state"] == "blocked":
        #     conclusion = "failure"
        #     title = "Branch protection settings are blocking automatic merging"
        #     summary = ""
        elif (
            ctxt.pull["mergeable_state"] == "behind"
            and self.config["strict"] is StrictMergeParameter.false
        ):
            # Strict mode has been enabled in branch protection but not in
            # mergify
            conclusion = check_api.Conclusion.FAILURE
            title = "Branch protection setting 'strict' conflicts with Mergify configuration"
            summary = ""

        elif ctxt.github_workflow_changed():
            conclusion = check_api.Conclusion.ACTION_REQUIRED
            title = "Pull request must be merged manually."
            summary = """GitHub App like Mergify are not allowed to merge pull request where `.github/workflows` is changed.
<br />
This pull request must be merged manually."""

        # NOTE(sileht): remaining state "behind, clean, unstable, has_hooks
        # are OK for us
        else:
            return None

        return check_api.Result(conclusion, title, summary)
Пример #21
0
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)
Пример #22
0
    async def run(self, ctxt: context.Context,
                  rule: rules.EvaluatedRule) -> check_api.Result:
        labels_added: typing.Set[str] = set()
        labels_removed: typing.Set[str] = set()

        pull_labels = {label["name"].lower() for label in ctxt.pull["labels"]}

        if self.config["add"]:
            for label in self.config["add"]:
                await ctxt.repository.ensure_label_exists(label)

            missing_labels = {label.lower()
                              for label in self.config["add"]} - pull_labels
            if missing_labels:
                await ctxt.client.post(
                    f"{ctxt.base_url}/issues/{ctxt.pull['number']}/labels",
                    json={"labels": list(missing_labels)},
                )
                labels_added = missing_labels
                labels_by_name = {
                    _l["name"].lower(): _l
                    for _l in await ctxt.repository.get_labels()
                }
                ctxt.pull["labels"].extend([
                    labels_by_name[label_name] for label_name in missing_labels
                ])
        if self.config["remove_all"]:
            if ctxt.pull["labels"]:
                await ctxt.client.delete(
                    f"{ctxt.base_url}/issues/{ctxt.pull['number']}/labels")
                labels_removed = {
                    label["name"]
                    for label in ctxt.pull["labels"]
                }
                ctxt.pull["labels"] = []

        elif self.config["remove"]:
            pull_labels = {
                label["name"].lower()
                for label in ctxt.pull["labels"]
            }
            for label in self.config["remove"]:
                if label.lower() in pull_labels:
                    label_escaped = parse.quote(label, safe="")
                    try:
                        await ctxt.client.delete(
                            f"{ctxt.base_url}/issues/{ctxt.pull['number']}/labels/{label_escaped}"
                        )
                    except http.HTTPClientSideError as e:
                        ctxt.log.warning(
                            "fail to delete label",
                            label=label,
                            status_code=e.status_code,
                            error_message=e.message,
                        )
                        continue
                    ctxt.pull["labels"] = [
                        _l for _l in ctxt.pull["labels"]
                        if _l["name"].lower() != label.lower()
                    ]
                    labels_removed.add(label)

        if labels_added or labels_removed:
            await signals.send(
                ctxt.repository,
                ctxt.pull["number"],
                "action.label",
                signals.EventLabelMetadata({
                    "added": sorted(labels_added),
                    "removed": sorted(labels_removed)
                }),
            )

            return check_api.Result(check_api.Conclusion.SUCCESS,
                                    "Labels added/removed", "")
        else:
            return check_api.Result(check_api.Conclusion.SUCCESS,
                                    "No label to add or remove", "")