def _add_assignees(self, ctxt: context.Context,
                       users_to_add: typing.List[str]) -> check_api.Result:
        assignees = self._wanted_users(ctxt, users_to_add)

        if assignees:
            try:
                ctxt.client.post(
                    f"{ctxt.base_url}/issues/{ctxt.pull['number']}/assignees",
                    json={"assignees": assignees},
                )
            except http.HTTPClientSideError as e:  # pragma: no cover
                return check_api.Result(
                    check_api.Conclusion.PENDING,
                    "Unable to add assignees",
                    f"GitHub error: [{e.status_code}] `{e.message}`",
                )

            return check_api.Result(
                check_api.Conclusion.SUCCESS,
                "Assignees added",
                ", ".join(assignees),
            )
        return check_api.Result(
            check_api.Conclusion.SUCCESS,
            "Empty users list",
            "No user added to assignees",
        )
Exemple #2
0
    async def _verify_template(
        self, ctxt: context.Context
    ) -> typing.Optional[check_api.Result]:
        try:
            await ctxt.pull_request.render_template(
                self.config["title"],
                extra_variables={"destination_branch": "whatever"},
            )
        except context.RenderTemplateFailure as rmf:
            # can't occur, template have been checked earlier
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                self.FAILURE_MESSAGE,
                f"Invalid title message: {rmf}",
            )

        try:
            await ctxt.pull_request.render_template(
                self.config["body"],
                extra_variables={
                    "destination_branch": "whatever",
                    "cherry_pick_error": "whatever",
                },
            )
        except context.RenderTemplateFailure as rmf:
            # can't occur, template have been checked earlier
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                self.FAILURE_MESSAGE,
                f"Invalid body message: {rmf}",
            )
        return None
Exemple #3
0
def pre_rebase_check(
        ctxt: context.Context) -> typing.Optional[check_api.Result]:
    # If PR from a public fork but cannot be edited
    if (ctxt.pull_from_fork and not ctxt.pull["base"]["repo"]["private"]
            and not ctxt.pull["maintainer_can_modify"]):
        return check_api.Result(
            check_api.Conclusion.FAILURE,
            "Pull request can't be updated with latest base branch changes",
            "Mergify needs the permission to update the base branch of the pull request.\n"
            f"{ctxt.pull['base']['repo']['owner']['login']} needs to "
            "[authorize modification on its base branch]"
            "(https://help.github.com/articles/allowing-changes-to-a-pull-request-branch-created-from-a-fork/).",
        )
    # 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
    elif (ctxt.pull_from_fork and ctxt.pull["base"]["repo"]["private"]
          and not ctxt.pull["maintainer_can_modify"]):
        return check_api.Result(
            check_api.Conclusion.FAILURE,
            "Pull request can't be updated with latest base branch changes",
            "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.",
        )
    else:
        return None
Exemple #4
0
    def run(self, ctxt, rule, missing_conditions) -> 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.",
                )

            try:
                branch_updater.update_with_git(ctxt, "rebase",
                                               self.config["bot_account"])
                return check_api.Result(
                    check_api.Conclusion.SUCCESS,
                    "Branch has been successfully rebased",
                    "",
                )
            except 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", "")
Exemple #5
0
def check_configuration_changes(ctxt):
    if ctxt.pull["base"]["repo"]["default_branch"] == ctxt.pull["base"]["ref"]:
        ref = None
        for f in ctxt.files:
            if f["filename"] in rules.MERGIFY_CONFIG_FILENAMES:
                ref = f["contents_url"].split("?ref=")[1]

        if ref is not None:
            try:
                rules.get_mergify_config(
                    ctxt.client, ctxt.pull["base"]["repo"]["name"], ref=ref
                )
            except rules.InvalidRules as e:
                # Not configured, post status check with the error message
                ctxt.set_summary_check(
                    check_api.Result(
                        check_api.Conclusion.FAILURE,
                        title="The new Mergify configuration is invalid",
                        summary=str(e),
                        annotations=e.get_annotations(e.filename),
                    )
                )
            else:
                ctxt.set_summary_check(
                    check_api.Result(
                        check_api.Conclusion.SUCCESS,
                        title="The new Mergify configuration is valid",
                        summary="This pull request must be merged manually because it modifies Mergify configuration",
                    )
                )

            return True
    return False
Exemple #6
0
    def run(self, ctxt: context.Context,
            rule: rules.EvaluatedRule) -> check_api.Result:
        wanted = set()
        for user in set(self.config["users"]):
            try:
                user = ctxt.pull_request.render_template(user)
            except context.RenderTemplateFailure as rmf:
                # NOTE: this should never happen since the template is validated when parsing the config ­Ъци
                return check_api.Result(check_api.Conclusion.FAILURE,
                                        "Invalid assignee", str(rmf))
            else:
                wanted.add(user)

        already = set((user["login"] for user in ctxt.pull["assignees"]))
        assignees = list(wanted - already)
        if assignees:
            try:
                ctxt.client.post(
                    f"{ctxt.base_url}/issues/{ctxt.pull['number']}/assignees",
                    json={"assignees": assignees},
                )
            except http.HTTPClientSideError as e:  # pragma: no cover
                return check_api.Result(
                    check_api.Conclusion.PENDING,
                    "Unable to add assignees",
                    f"GitHub error: [{e.status_code}] `{e.message}`",
                )

        return check_api.Result(
            check_api.Conclusion.SUCCESS,
            "Assignees added",
            ", ".join(self.config["users"]),
        )
Exemple #7
0
    def _subscription_status(
            self, ctxt: context.Context) -> typing.Optional[check_api.Result]:

        if self.config[
                "update_bot_account"] and not ctxt.subscription.has_feature(
                    subscription.Features.MERGE_BOT_ACCOUNT):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Merge with `update_bot_account` set is unavailable",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        elif self.config[
                "merge_bot_account"] and not ctxt.subscription.has_feature(
                    subscription.Features.MERGE_BOT_ACCOUNT):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Merge with `merge_bot_account` set is unavailable",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        elif self.config[
                "priority"] != merge_base.PriorityAliases.medium.value and not ctxt.subscription.has_feature(
                    subscription.Features.PRIORITY_QUEUES):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Merge with `priority` set is unavailable.",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        return None
Exemple #8
0
    async def run(self, ctxt: context.Context,
                  rule: rules.EvaluatedRule) -> check_api.Result:
        try:
            bot_account = await action_utils.render_bot_account(
                ctxt,
                self.config["bot_account"],
                option_name="bot_account",
                required_feature=subscription.Features.BOT_ACCOUNT,
                missing_feature_message=
                "Rebase with `update_bot_account` set is unavailable",
            )
        except action_utils.RenderBotAccountFailure as e:
            return check_api.Result(e.status, e.title, e.reason)

        try:
            await branch_updater.rebase_with_git(
                ctxt, subscription.Features.BOT_ACCOUNT, bot_account)
        except branch_updater.BranchUpdateFailure as e:
            return check_api.Result(check_api.Conclusion.FAILURE, e.title,
                                    e.message)

        await signals.send(ctxt, "action.rebase",
                           {"bot_account": bool(self.config["bot_account"])})

        return check_api.Result(
            check_api.Conclusion.SUCCESS,
            "Branch has been successfully rebased",
            "",
        )
Exemple #9
0
    async def run(self, ctxt: context.Context,
                  rule: rules.EvaluatedRule) -> check_api.Result:

        if self.config["message"] is None:
            return check_api.Result(check_api.Conclusion.SUCCESS,
                                    "Message is not set", "")

        if self.config["bot_account"] and not ctxt.subscription.has_feature(
                subscription.Features.BOT_ACCOUNT):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Comments with `bot_account` set are disabled",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        try:
            message = await ctxt.pull_request.render_template(
                self.config["message"])
        except context.RenderTemplateFailure as rmf:
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                "Invalid comment message",
                str(rmf),
            )

        if bot_account := self.config["bot_account"]:
            user_tokens = await ctxt.repository.installation.get_user_tokens()
            oauth_token = user_tokens.get_token_for(bot_account)
            if not oauth_token:
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    f"Unable to comment: user `{bot_account}` is unknown. ",
                    f"Please make sure `{bot_account}` has logged in Mergify dashboard.",
                )
Exemple #10
0
    async def _add_assignees(
            self, ctxt: context.Context,
            users_to_add: typing.List[str]) -> check_api.Result:
        users_to_add_parsed = await self.wanted_users(ctxt, users_to_add)
        assignees = list(users_to_add_parsed -
                         {a["login"]
                          for a in ctxt.pull["assignees"]})

        if assignees:
            try:
                await ctxt.client.post(
                    f"{ctxt.base_url}/issues/{ctxt.pull['number']}/assignees",
                    json={"assignees": assignees},
                )
            except http.HTTPClientSideError as e:  # pragma: no cover
                return check_api.Result(
                    check_api.Conclusion.PENDING,
                    "Unable to add assignees",
                    f"GitHub error: [{e.status_code}] `{e.message}`",
                )

            await signals.send(ctxt, "action.assign.added")
            return check_api.Result(
                check_api.Conclusion.SUCCESS,
                "Assignees added",
                ", ".join(assignees),
            )
        return check_api.Result(
            check_api.Conclusion.SUCCESS,
            "Empty users list",
            "No user added to assignees",
        )
Exemple #11
0
    async def run(
        self, ctxt: context.Context, rule: rules.EvaluatedRule
    ) -> check_api.Result:
        train = await merge_train.Train.from_context(ctxt)
        if await train.get_position(ctxt) is None:
            return check_api.Result(
                check_api.Conclusion.NEUTRAL,
                title="The pull request is not queued",
                summary="",
            )

        # manually set a status, to not automatically re-embark it
        await check_api.set_check_run(
            ctxt,
            constants.MERGE_QUEUE_SUMMARY_NAME,
            check_api.Result(
                check_api.Conclusion.CANCELLED,
                title="The pull request has been removed from the queue by an `unqueue` command",
                summary="",
            ),
        )
        await train.remove_pull(ctxt)
        await signals.send(
            ctxt.repository,
            ctxt.pull["number"],
            "action.unqueue",
            signals.EventNoMetadata(),
        )
        return check_api.Result(
            check_api.Conclusion.SUCCESS,
            title="The pull request has been removed from the queue",
            summary="",
        )
Exemple #12
0
    async def run(self, ctxt: context.Context,
                  rule: "rules.EvaluatedRule") -> check_api.Result:
        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=
                "Merge 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)

        if self.config["method"] == "fast-forward":
            if 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.",
                )

        report = await self.merge_report(ctxt)
        if report is not None:
            return report

        return await self._merge(ctxt, rule, None, merge_bot_account)
Exemple #13
0
    async def _remove_assignees(
            self, ctxt: context.Context,
            users_to_remove: typing.List[str]) -> check_api.Result:
        assignees = await self.wanted_users(ctxt, users_to_remove)

        if assignees:
            try:
                await ctxt.client.request(
                    "DELETE",
                    f"{ctxt.base_url}/issues/{ctxt.pull['number']}/assignees",
                    json={"assignees": assignees},
                )
            except http.HTTPClientSideError as e:  # pragma: no cover
                return check_api.Result(
                    check_api.Conclusion.PENDING,
                    "Unable to remove assignees",
                    f"GitHub error: [{e.status_code}] `{e.message}`",
                )

            return check_api.Result(
                check_api.Conclusion.SUCCESS,
                "Assignees removed",
                ", ".join(assignees),
            )
        return check_api.Result(
            check_api.Conclusion.SUCCESS,
            "Empty users list",
            "No user removed from assignees",
        )
Exemple #14
0
    async def _subscription_status(
            self, ctxt: context.Context) -> typing.Optional[check_api.Result]:
        if self.queue_rule.config[
                "speculative_checks"] > 1 and not ctxt.subscription.has_feature(
                    subscription.Features.QUEUE_ACTION):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Queue with `speculative_checks` set is unavailable.",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        elif self.config[
                "priority"] != merge_base.PriorityAliases.medium.value and not ctxt.subscription.has_feature(
                    subscription.Features.PRIORITY_QUEUES):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Queue with `priority` set is unavailable.",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        bot_account_result = await action_utils.validate_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",
        )
        if bot_account_result is not None:
            return bot_account_result

        return None
Exemple #15
0
    async def run(
        self, ctxt: context.Context, rule: rules.EvaluatedRule
    ) -> check_api.Result:
        labels_changed = False

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

        if self.config["add"]:
            all_label = [
                label["name"]
                async for label in ctxt.client.items(f"{ctxt.base_url}/labels")
            ]
            for label in self.config["add"]:
                if label not in all_label:
                    color = f"{random.randrange(16 ** 6):06x}"  # nosec
                    try:
                        await ctxt.client.post(
                            f"{ctxt.base_url}/labels",
                            json={"name": label, "color": color},
                        )
                    except http.HTTPClientSideError:
                        continue

            missing_labels = set(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_changed = True

        if self.config["remove_all"]:
            if ctxt.pull["labels"]:
                await ctxt.client.delete(
                    f"{ctxt.base_url}/issues/{ctxt.pull['number']}/labels"
                )
                labels_changed = True

        elif self.config["remove"]:
            for label in self.config["remove"]:
                if label 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:
                        continue
                    labels_changed = True

        if labels_changed:
            await signals.send(ctxt, "action.label")
            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", ""
            )
    async def run(
        self, ctxt: context.Context, rule: "rules.EvaluatedRule"
    ) -> check_api.Result:
        if not config.GITHUB_APP:
            if self.config["strict_method"] == "rebase":
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "Misconfigured for GitHub Action",
                    "Due to GitHub Action limitation, `strict_method: rebase` "
                    "is only available with the Mergify GitHub App",
                )

        if self.config["bot_account"] is not None:
            if ctxt.subscription.has_feature(subscription.Features.MERGE_BOT_ACCOUNT):
                ctxt.log.info("legacy bot_account used by paid plan")
            else:
                ctxt.log.info("legacy bot_account used by free plan")

        self._set_effective_priority(ctxt)

        ctxt.log.info("process merge", config=self.config)

        q = await self._get_queue(ctxt)

        report = await self.merge_report(ctxt)
        if report is not None:
            await q.remove_pull(ctxt)
            return report

        if self.config["strict"] in (
            StrictMergeParameter.fasttrack,
            StrictMergeParameter.ordered,
        ):
            if await self._should_be_queued(ctxt, q):
                await q.add_pull(ctxt, typing.cast(queue.PullQueueConfig, self.config))
            else:
                await q.remove_pull(ctxt)
                return check_api.Result(
                    check_api.Conclusion.CANCELLED,
                    "The pull request has been removed from the queue",
                    "The queue conditions cannot be satisfied due to failing checks.",
                )

        try:
            if await self._should_be_merged(ctxt, q):
                result = await self._merge(ctxt, rule, q)
            elif await self._should_be_synced(ctxt, q):
                result = await self._sync_with_base_branch(ctxt, rule, q)
            else:
                result = await self.get_queue_status(
                    ctxt, rule, q, is_behind=await ctxt.is_behind
                )
        except Exception:
            await q.remove_pull(ctxt)
            raise
        if result.conclusion is not check_api.Conclusion.PENDING:
            await q.remove_pull(ctxt)
        return result
Exemple #17
0
    async def _post(self, ctxt: context.Context,
                    rule: rules.EvaluatedRule) -> check_api.Result:
        # TODO(sileht): Don't run it if conditions contains the rule itself, as it can
        # created an endless loop of events.

        if not ctxt.subscription.has_feature(
                subscription.Features.CUSTOM_CHECKS):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Custom checks are disabled",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        extra_variables: typing.Dict[str, typing.Union[str, bool]] = {
            "check_rule_name": rule.name,
            "check_succeed": rule.conditions.match,
            "check_conditions": rule.conditions.get_summary(),
        }
        try:
            title = await ctxt.pull_request.render_template(
                self.config["title"],
                extra_variables,
            )
        except context.RenderTemplateFailure as rmf:
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                "Invalid title template",
                str(rmf),
            )

        try:
            summary = await ctxt.pull_request.render_template(
                self.config["summary"], extra_variables)
        except context.RenderTemplateFailure as rmf:
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                "Invalid summary template",
                str(rmf),
            )
        if rule.conditions.match:
            conclusion = check_api.Conclusion.SUCCESS
        else:
            conclusion = check_api.Conclusion.FAILURE

        check = await ctxt.get_engine_check_run(
            rule.get_check_name("post_check"))
        if (not check or check["conclusion"] != conclusion.value
                or check["output"]["title"] != title
                or check["output"]["summary"] != summary):
            await signals.send(
                ctxt.repository,
                ctxt.pull["number"],
                "action.post_check",
                signals.EventNoMetadata(),
            )

        return check_api.Result(conclusion, title, summary)
Exemple #18
0
    async def run(self, ctxt: context.Context,
                  rule: rules.EvaluatedRule) -> check_api.Result:
        if ctxt.pull_from_fork:
            return check_api.Result(check_api.Conclusion.SUCCESS,
                                    "Pull request come from fork", "")

        if not self.config["force"]:
            pulls_using_this_branch = [
                pull async for pull in ctxt.client.items(
                    f"{ctxt.base_url}/pulls",
                    resource_name="pulls",
                    page_limit=20,
                    params={"base": ctxt.pull["head"]["ref"]},
                )
            ] + [
                pull async for pull in ctxt.client.items(
                    f"{ctxt.base_url}/pulls",
                    resource_name="pulls",
                    page_limit=5,
                    params={"head": ctxt.pull["head"]["label"]},
                ) if pull["number"] is not ctxt.pull["number"]
            ]
            if pulls_using_this_branch:
                pulls_using_this_branch_formatted = "\n".join(
                    f"* Pull request #{p['number']}"
                    for p in pulls_using_this_branch)
                return check_api.Result(
                    check_api.Conclusion.NEUTRAL,
                    "Not deleting the head branch",
                    f"Branch `{ctxt.pull['head']['ref']}` was not deleted "
                    f"because it is used by:\n{pulls_using_this_branch_formatted}",
                )

        ref_to_delete = parse.quote(ctxt.pull["head"]["ref"], safe="")
        try:
            await ctxt.client.delete(
                f"{ctxt.base_url}/git/refs/heads/{ref_to_delete}")
        except http.HTTPClientSideError as e:
            if e.status_code == 404 or (e.status_code == 422
                                        and "Reference does not exist"
                                        in e.message):
                return check_api.Result(
                    check_api.Conclusion.SUCCESS,
                    f"Branch `{ctxt.pull['head']['ref']}` does not exist",
                    "",
                )
            else:
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "Unable to delete the head branch",
                    f"GitHub error: [{e.status_code}] `{e.message}`",
                )
        await signals.send(ctxt, "action.delete_head_branch")
        return check_api.Result(
            check_api.Conclusion.SUCCESS,
            f"Branch `{ctxt.pull['head']['ref']}` has been deleted",
            "",
        )
Exemple #19
0
    def run(self, ctxt: context.Context, rule: rules.EvaluatedRule) -> check_api.Result:
        payload = {"event": self.config["type"]}

        if self.config["message"]:
            try:
                body = ctxt.pull_request.render_template(self.config["message"])
            except context.RenderTemplateFailure as rmf:
                return check_api.Result(
                    check_api.Conclusion.FAILURE, "Invalid review message", str(rmf)
                )
        else:
            body = None

        if not body and self.config["type"] != "APPROVE":
            body = (
                "Pull request automatically reviewed by Mergify: %s"
                % self.config["type"]
            )

        if body:
            payload["body"] = body

        # TODO(sileht): We should catch it some how, when we drop pygithub for sure
        reviews = reversed(
            list(
                filter(
                    lambda r: r["user"]["id"] is not config.BOT_USER_ID, ctxt.reviews
                )
            )
        )
        for review in reviews:
            if (
                review["body"] == (body or "")
                and review["state"] == EVENT_STATE_MAP[self.config["type"]]
            ):
                # Already posted
                return check_api.Result(
                    check_api.Conclusion.SUCCESS, "Review already posted", ""
                )

            elif (
                self.config["type"] == "REQUEST_CHANGES"
                and review["state"] == "APPROVED"
            ):
                break

            elif (
                self.config["type"] == "APPROVE"
                and review["state"] == "CHANGES_REQUESTED"
            ):
                break

        ctxt.client.post(
            f"{ctxt.base_url}/pulls/{ctxt.pull['number']}/reviews", json=payload
        )

        return check_api.Result(check_api.Conclusion.SUCCESS, "Review posted", "")
Exemple #20
0
    async def _post(self, ctxt: context.Context,
                    rule: rules.EvaluatedRule) -> check_api.Result:
        # TODO(sileht): Don't run it if conditions contains the rule itself, as it can
        # created an endless loop of events.

        if not ctxt.subscription.has_feature(
                subscription.Features.CUSTOM_CHECKS):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Custom checks are disabled",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        check_succeed = not bool(rule.missing_conditions)
        check_conditions = ""
        for cond in rule.conditions:
            checked = " " if cond in rule.missing_conditions else "X"
            check_conditions += f"\n- [{checked}] `{cond}`"
            if cond.description:
                check_conditions += f" [{cond.description}]"

        extra_variables = {
            "check_rule_name": rule.name,
            "check_succeed": check_succeed,
            "check_conditions": check_conditions,
        }
        try:
            title = await ctxt.pull_request.render_template(
                self.config["title"],
                extra_variables,
            )
        except context.RenderTemplateFailure as rmf:
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                "Invalid title template",
                str(rmf),
            )

        try:
            summary = await ctxt.pull_request.render_template(
                self.config["summary"], extra_variables)
        except context.RenderTemplateFailure as rmf:
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                "Invalid summary template",
                str(rmf),
            )

        await signals.send(ctxt, "action.post_check")
        if rule.missing_conditions:
            return check_api.Result(check_api.Conclusion.FAILURE, title,
                                    summary)
        else:
            return check_api.Result(check_api.Conclusion.SUCCESS, title,
                                    summary)
Exemple #21
0
    def run(self, ctxt, rule, missing_conditions) -> check_api.Result:
        if not config.GITHUB_APP:
            if self.config["strict_method"] == "rebase":
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "Misconfigured for GitHub Action",
                    "Due to GitHub Action limitation, `strict_method: rebase` "
                    "is only available with the Mergify GitHub App",
                )

        if self.config[
                "update_bot_account"] and not ctxt.subscription.has_feature(
                    subscription.Features.MERGE_BOT_ACCOUNT):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Merge with `update_bot_account` set are disabled",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        if self.config[
                "merge_bot_account"] and not ctxt.subscription.has_feature(
                    subscription.Features.MERGE_BOT_ACCOUNT):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Merge with `merge_bot_account` set are disabled",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        self._set_effective_priority(ctxt)

        ctxt.log.info("process merge", config=self.config)

        q = queue.Queue.from_context(ctxt)

        result = helpers.merge_report(ctxt, self.config["strict"])
        if result:
            q.remove_pull(ctxt.pull["number"])
            return result

        if self.config["strict"] in ("smart+fasttrack", "smart+ordered"):
            q.add_pull(ctxt, self.config)

        if self._should_be_merged(ctxt, q):
            try:
                result = self._merge(ctxt, rule, missing_conditions, q)
                if result.conclusion is not check_api.Conclusion.PENDING:
                    q.remove_pull(ctxt.pull["number"])
                return result
            except Exception:
                q.remove_pull(ctxt.pull["number"])
                raise
        else:
            return self._sync_with_base_branch(ctxt, rule, missing_conditions,
                                               q)
Exemple #22
0
    async def run(self, ctxt: context.Context,
                  rule: rules.EvaluatedRule) -> check_api.Result:
        check = await ctxt.get_engine_check_run(
            constants.MERGE_QUEUE_SUMMARY_NAME)
        if not check:
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                title=
                "This pull request head commit has not been previously disembarked from queue.",
                summary="",
            )

        if check_api.Conclusion(check["conclusion"]) in [
                check_api.Conclusion.SUCCESS,
                check_api.Conclusion.NEUTRAL,
                check_api.Conclusion.PENDING,
        ]:
            return check_api.Result(
                check_api.Conclusion.NEUTRAL,
                title="This pull request is already queued",
                summary="",
            )

        await check_api.set_check_run(
            ctxt,
            constants.MERGE_QUEUE_SUMMARY_NAME,
            check_api.Result(
                check_api.Conclusion.NEUTRAL,
                "This pull request can be re-embarked automatically",
                "",
            ),
        )

        # NOTE(sileht): refresh it to maybe, retrigger the queue action.
        await utils.send_pull_refresh(
            ctxt.redis.stream,
            ctxt.pull["base"]["repo"],
            pull_request_number=ctxt.pull["number"],
            action="user",
            source="action/command/requeue",
        )

        await signals.send(
            ctxt.repository,
            ctxt.pull["number"],
            "action.requeue",
            signals.EventNoMetadata(),
        )

        return check_api.Result(
            check_api.Conclusion.SUCCESS,
            title=
            "The queue state of this pull request has been cleaned. It can be re-embarked automatically",
            summary="",
        )
Exemple #23
0
    def _sync_with_base_branch(
        self, ctxt: context.Context, rule: "rules.EvaluatedRule", q: queue.Queue
    ) -> check_api.Result:
        # If PR from a public fork but cannot be edited
        if (
            ctxt.pull_from_fork
            and not ctxt.pull["base"]["repo"]["private"]
            and not ctxt.pull["maintainer_can_modify"]
        ):
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                "Pull request can't be updated with latest base branch changes",
                "Mergify needs the permission to update the base branch of the pull request.\n"
                f"{ctxt.pull['base']['repo']['owner']['login']} needs to "
                "[authorize modification on its base branch]"
                "(https://help.github.com/articles/allowing-changes-to-a-pull-request-branch-created-from-a-fork/).",
            )
        # 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 strict mode broken
        elif (
            ctxt.pull_from_fork
            and ctxt.pull["base"]["repo"]["private"]
            and not ctxt.pull["maintainer_can_modify"]
        ):
            return check_api.Result(
                check_api.Conclusion.FAILURE,
                "Pull request can't be updated with latest base branch changes",
                "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 use strict mode with a pull request from a private fork.",
            )

        method = self.config["strict_method"]
        user = self.config["update_bot_account"] or self.config["bot_account"]
        try:
            if method == "merge":
                branch_updater.update_with_api(ctxt)
            else:
                branch_updater.update_with_git(ctxt, method, user)
        except branch_updater.BranchUpdateFailure as e:
            # NOTE(sileht): Maybe the PR has been rebased and/or merged manually
            # in the meantime. So double check that to not report a wrong status.
            ctxt.update()
            output = self.merge_report(ctxt)
            if output:
                return output
            else:
                q.move_pull_at_end(ctxt.pull["number"], self.config)
                return check_api.Result(
                    check_api.Conclusion.FAILURE,
                    "Base branch update has failed",
                    e.message,
                )
        else:
            return self.get_strict_status(ctxt, rule, q, is_behind=False)
Exemple #24
0
    async def run(self, ctxt: context.Context,
                  rule: rules.EvaluatedRule) -> check_api.Result:
        if ctxt.have_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 await ctxt.redis.get(f"branch-update-{ctxt.pull['head']['sha']}"
                                    ):
                return check_api.Result(
                    check_api.Conclusion.SUCCESS,
                    "Updated by Mergify, ignoring",
                    "",
                )

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

            errors = set()
            for review in (await ctxt.consolidated_reviews())[1]:
                conf = self.config.get(review["state"].lower(), False)
                if conf and (conf is True or review["user"]["login"] in conf):
                    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", "")
        else:
            return check_api.Result(
                check_api.Conclusion.SUCCESS,
                "Nothing to do, pull request have not been synchronized",
                "",
            )
    def run(self, ctxt, rule, missing_conditions) -> check_api.Result:
        if self._have_been_synchronized(ctxt):
            # 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"
            # FIXME ignore type: https://github.com/python/typeshed/pull/4655
            with utils.get_redis_for_cache() as redis:  # type: ignore
                if redis.get("branch-update-%s" % ctxt.pull["head"]["sha"]):
                    return check_api.Result(
                        check_api.Conclusion.SUCCESS,
                        "Rebased/Updated by us, nothing to do",
                        "",
                    )

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

            errors = set()
            for review in ctxt.consolidated_reviews[1]:
                conf = self.config.get(review["state"].lower(), False)
                if conf and (conf is True or review["user"]["login"] in conf):
                    try:
                        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:
                return check_api.Result(check_api.Conclusion.SUCCESS,
                                        "Review dismissed", "")
        else:
            return check_api.Result(
                check_api.Conclusion.SUCCESS,
                "Nothing to do, pull request have not been synchronized",
                "",
            )
Exemple #26
0
async def _check_configuration_changes(
    ctxt: context.Context,
    current_mergify_config_file: typing.Optional[context.MergifyConfigFile],
) -> bool:
    if ctxt.pull["base"]["repo"]["default_branch"] != ctxt.pull["base"]["ref"]:
        return False

    config_file_to_validate: typing.Optional[context.MergifyConfigFile] = None
    preferred_filename = (None if current_mergify_config_file is None else
                          current_mergify_config_file["path"])
    # NOTE(sileht): Just a shorcut to do two requests instead of three.
    if ctxt.pull["changed_files"] <= 100:
        for f in await ctxt.files:
            if f["filename"] in context.Repository.MERGIFY_CONFIG_FILENAMES:
                preferred_filename = f["filename"]
                break
        else:
            return False

    async for config_file in ctxt.repository.iter_mergify_config_files(
            ref=ctxt.pull["head"]["sha"],
            preferred_filename=preferred_filename):
        if (current_mergify_config_file is None
                or config_file["path"] != current_mergify_config_file["path"]):
            config_file_to_validate = config_file
            break
        elif config_file["sha"] != current_mergify_config_file["sha"]:
            config_file_to_validate = config_file
            break

    if config_file_to_validate is None:
        return False

    try:
        rules.get_mergify_config(config_file_to_validate)
    except rules.InvalidRules as e:
        # Not configured, post status check with the error message
        await ctxt.set_summary_check(
            check_api.Result(
                check_api.Conclusion.FAILURE,
                title="The new Mergify configuration is invalid",
                summary=str(e),
                annotations=e.get_annotations(e.filename),
            ))
    else:
        await ctxt.set_summary_check(
            check_api.Result(
                check_api.Conclusion.SUCCESS,
                title="The new Mergify configuration is valid",
                summary=
                "This pull request must be merged manually because it modifies Mergify configuration",
            ))
    return True
Exemple #27
0
    async def test_check_run_api(self) -> None:
        await self.setup_repo()
        p = await self.create_pr()
        ctxt = await context.Context.create(self.repository_ctxt, p, [])

        await check_api.set_check_run(
            ctxt,
            "Test",
            check_api.Result(check_api.Conclusion.PENDING,
                             title="PENDING",
                             summary="PENDING"),
        )
        checks = await ctxt.pull_engine_check_runs
        assert len(checks) == 1
        assert checks[0]["status"] == "in_progress"
        assert checks[0]["conclusion"] is None
        assert checks[0]["completed_at"] is None
        assert checks[0]["output"]["title"] == "PENDING"
        assert checks[0]["output"]["summary"] == "PENDING"

        await check_api.set_check_run(
            ctxt,
            "Test",
            check_api.Result(check_api.Conclusion.CANCELLED,
                             title="CANCELLED",
                             summary="CANCELLED"),
        )
        ctxt._caches = context.ContextCaches()
        checks = await ctxt.pull_engine_check_runs
        assert len(checks) == 1
        assert checks[0]["status"] == "completed"
        assert checks[0]["conclusion"] == "cancelled"
        assert checks[0]["completed_at"] is not None
        assert checks[0]["output"]["title"] == "CANCELLED"
        assert checks[0]["output"]["summary"] == "CANCELLED"

        await check_api.set_check_run(
            ctxt,
            "Test",
            check_api.Result(check_api.Conclusion.PENDING,
                             title="PENDING",
                             summary="PENDING"),
        )
        ctxt._caches = context.ContextCaches()
        checks = await ctxt.pull_engine_check_runs
        assert len(checks) == 1
        assert checks[0]["status"] == "in_progress"
        assert checks[0]["conclusion"] is None
        assert checks[0]["completed_at"] is None
        assert checks[0]["output"]["title"] == "PENDING"
        assert checks[0]["output"]["summary"] == "PENDING"
Exemple #28
0
    async def _subscription_status(
        self, ctxt: context.Context
    ) -> typing.Optional[check_api.Result]:
        if self.queue_count > 1 and not ctxt.subscription.has_feature(
            subscription.Features.QUEUE_ACTION
        ):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Queue with more than 1 rule set is unavailable.",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]
                ),
            )

        elif self.queue_rule.config[
            "speculative_checks"
        ] > 1 and not ctxt.subscription.has_feature(subscription.Features.QUEUE_ACTION):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Queue with `speculative_checks` set is unavailable.",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]
                ),
            )

        elif self.queue_rule.config[
            "batch_size"
        ] > 1 and not ctxt.subscription.has_feature(subscription.Features.QUEUE_ACTION):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Queue with `batch_size` set is unavailable.",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]
                ),
            )
        elif self.config[
            "priority"
        ] != queue.PriorityAliases.medium.value and not ctxt.subscription.has_feature(
            subscription.Features.PRIORITY_QUEUES
        ):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Queue with `priority` set is unavailable.",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]
                ),
            )

        return None
Exemple #29
0
    async def run(self, ctxt: context.Context,
                  rule: "rules.EvaluatedRule") -> check_api.Result:
        if not ctxt.subscription.has_feature(
                subscription.Features.QUEUE_ACTION):
            return check_api.Result(
                check_api.Conclusion.ACTION_REQUIRED,
                "Queue action is disabled",
                ctxt.subscription.missing_feature_reason(
                    ctxt.pull["base"]["repo"]["owner"]["login"]),
            )

        q = await merge_train.Train.from_context(ctxt)
        car = q.get_car(ctxt)
        if car and car.state == "updated":
            # NOTE(sileht): This car doesn't have tmp pull, so we have the
            # MERGE_QUEUE_SUMMARY and train reset here
            need_reset = ctxt.have_been_synchronized() or await ctxt.is_behind
            if need_reset:
                status = check_api.Conclusion.PENDING
                ctxt.log.info("train will be reset")
                await q.reset()
            else:
                queue_rule_evaluated = await self.queue_rule.get_pull_request_rule(
                    ctxt)
                status = await merge_train.get_queue_rule_checks_status(
                    ctxt, queue_rule_evaluated)
            await car.update_summaries(status, will_be_reset=need_reset)

        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_queue 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,
            ]:
                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",
                        "",
                    ),
                )

        return await super().run(ctxt, rule)
Exemple #30
0
async def _ensure_summary_on_head_sha(ctxt: context.Context) -> None:
    for check in await ctxt.pull_engine_check_runs:
        if check[
                "name"] == ctxt.SUMMARY_NAME and actions_runner.load_conclusions_line(
                    ctxt, check):
            return

    opened = ctxt.has_been_opened()
    sha = await ctxt.get_cached_last_summary_head_sha()
    if sha is None:
        if not opened:
            ctxt.log.warning(
                "the pull request doesn't have the last summary head sha stored in redis"
            )
        return

    previous_summary = await _get_summary_from_sha(ctxt, sha)
    if previous_summary:
        await ctxt.set_summary_check(
            check_api.Result(
                check_api.Conclusion(previous_summary["conclusion"]),
                title=previous_summary["output"]["title"],
                summary=previous_summary["output"]["summary"],
            ))
    elif not opened:
        ctxt.log.warning("the pull request doesn't have a summary")