Пример #1
0
async def get_pr(
    install: str,
    owner: str,
    repo: str,
    number: int,
    dequeue_callback: Callable[[], Awaitable[None]],
    requeue_callback: Callable[[], Awaitable[None]],
    queue_for_merge_callback: QueueForMergeCallback,
) -> Optional[PRV2]:
    log = logger.bind(install=install, owner=owner, repo=repo, number=number)
    async with Client(installation_id=install, owner=owner, repo=repo) as api_client:
        event = await api_client.get_event_info(pr_number=number)
        if event is None:
            log.info("failed to find event")
            return None
        return PRV2(
            event,
            install=install,
            owner=owner,
            repo=repo,
            number=number,
            dequeue_callback=dequeue_callback,
            requeue_callback=requeue_callback,
            queue_for_merge_callback=queue_for_merge_callback,
        )
Пример #2
0
    async def set_status(
        self,
        msg: str,
        *,
        latest_commit_sha: str,
        markdown_content: Optional[str] = None,
    ) -> None:
        """
        Display a message to a user through a github check

        `markdown_content` is the message displayed on the detail view for a
        status check. This detail view is accessible via the "Details" link
        alongside the summary/detail content.
        """
        self.log.info("set_status",
                      message=msg,
                      markdown_content=markdown_content)
        async with Client(installation_id=self.install,
                          owner=self.owner,
                          repo=self.repo) as api_client:
            res = await api_client.create_notification(
                head_sha=self.event.pull_request.latest_sha,
                message=msg,
                summary=markdown_content,
            )
            try:
                res.raise_for_status()
            except HTTPError:
                self.log.exception("failed to create notification", res=res)
Пример #3
0
def api_client(mocker: MockFixture, github_installation_id: str) -> Client:
    mocker.patch(
        "kodiak.queries.get_thottler_for_installation", return_value=FakeThottler()
    )
    client = Client(installation_id=github_installation_id, owner="foo", repo="foo")
    mocker.patch.object(client, "send_query")
    return client
Пример #4
0
async def push(push_event: PushEvent) -> None:
    """
    Trigger evaluation of PRs that depend on the pushed branch.
    """
    owner = push_event.repository.owner.login
    repo = push_event.repository.name
    installation_id = str(push_event.installation.id)
    branch_name = get_branch_name(push_event.ref)
    log = logger.bind(ref=push_event.ref, branch_name=branch_name)
    if branch_name is None:
        log.info("could not extract branch name from ref")
        return
    async with Client(owner=owner, repo=repo,
                      installation_id=installation_id) as api_client:
        # find all the PRs that depend on the branch affected by this push and
        # queue them for evaluation.
        # Any PR that has a base ref matching our event ref is dependent.
        prs = await api_client.get_open_pull_requests(base=branch_name)
        if prs is None:
            log.info("api call to find pull requests failed")
            return None
        for pr in prs:
            await redis_webhook_queue.enqueue(event=WebhookEvent(
                repo_owner=owner,
                repo_name=repo,
                pull_request_number=pr.number,
                installation_id=installation_id,
            ))
Пример #5
0
async def get_pr(
    install: str,
    owner: str,
    repo: str,
    number: int,
    dequeue_callback: Callable[[], Awaitable],
    requeue_callback: Callable[[], Awaitable],
    queue_for_merge_callback: Callable[[], Awaitable[Optional[int]]],
) -> Optional[PRV2]:
    log = logger.bind(install=install, owner=owner, repo=repo, number=number)
    async with Client(installation_id=install, owner=owner,
                      repo=repo) as api_client:
        default_branch_name = await api_client.get_default_branch_name()
        if default_branch_name is None:
            log.info("failed to find default_branch_name")
            return None
        event = await api_client.get_event_info(
            branch_name=default_branch_name, pr_number=number)
        if event is None:
            log.info("failed to find event")
            return None
        return PRV2(
            event,
            install=install,
            owner=owner,
            repo=repo,
            number=number,
            dequeue_callback=dequeue_callback,
            requeue_callback=requeue_callback,
            queue_for_merge_callback=queue_for_merge_callback,
        )
Пример #6
0
 async def merge(
     self,
     merge_method: str,
     commit_title: Optional[str],
     commit_message: Optional[str],
 ) -> None:
     self.log.info("merge", method=merge_method)
     async with Client(installation_id=self.install,
                       owner=self.owner,
                       repo=self.repo) as api_client:
         res = await api_client.merge_pull_request(
             number=self.number,
             merge_method=merge_method,
             commit_title=commit_title,
             commit_message=commit_message,
         )
         try:
             res.raise_for_status()
         except HTTPError as e:
             if e.response is not None and e.response.status_code == 405:
                 self.log.info(
                     "branch is not mergeable. PR likely already merged.",
                     res=res)
             else:
                 self.log.exception("failed to merge pull request", res=res)
             # we raise an exception to retry this request.
             raise ApiCallException("merge")
Пример #7
0
def pr() -> PR:
    return PR(
        number=123,
        owner="tester",
        repo="repo",
        installation_id="abc",
        client=Client(owner="tester", repo="repo", installation_id="abc"),
    )
Пример #8
0
 async def approve_pull_request(self) -> None:
     self.log.info("approve_pull_request")
     async with Client(installation_id=self.install,
                       owner=self.owner,
                       repo=self.repo) as api_client:
         res = await api_client.approve_pull_request(pull_number=self.number
                                                     )
         try:
             res.raise_for_status()
         except HTTPError:
             self.log.exception("failed to approve pull request", res=res)
Пример #9
0
 async def trigger_test_commit(self) -> None:
     self.log.info("trigger_test_commit")
     async with Client(installation_id=self.install,
                       owner=self.owner,
                       repo=self.repo) as api_client:
         res = await api_client.get_pull_request(number=self.number)
         try:
             res.raise_for_status()
         except HTTPError:
             self.log.exception(
                 "failed to get pull request for test commit trigger",
                 res=res)
Пример #10
0
 async def update_branch(self) -> None:
     self.log.info("update_branch")
     async with Client(installation_id=self.install,
                       owner=self.owner,
                       repo=self.repo) as api_client:
         res = await api_client.update_branch(pull_number=self.number)
         try:
             res.raise_for_status()
         except HTTPError:
             self.log.exception("failed to update branch", res=res)
             # we raise an exception to retry this request.
             raise ApiCallException("update branch")
Пример #11
0
 async def pull_requests_for_ref(self, ref: str) -> Optional[int]:
     log = self.log.bind(ref=ref)
     log.info("pull_requests_for_ref", ref=ref)
     async with Client(installation_id=self.install,
                       owner=self.owner,
                       repo=self.repo) as api_client:
         prs = await api_client.get_open_pull_requests(base=ref)
         if prs is None:
             # our api request failed.
             log.info("failed to get pull request info for ref")
             return None
         return len(prs)
Пример #12
0
 async def create_comment(self, body: str) -> None:
     """
    create a comment on the specified `pr_number` with the given `body` as text.
     """
     self.log.info("create_comment", body=body)
     async with Client(installation_id=self.install,
                       owner=self.owner,
                       repo=self.repo) as api_client:
         res = await api_client.create_comment(body=body,
                                               pull_number=self.number)
         try:
             res.raise_for_status()
         except HTTPError:
             self.log.exception("failed to create comment", res=res)
Пример #13
0
 async def delete_branch(self, branch_name: str) -> None:
     self.log.info("delete_branch", branch_name=branch_name)
     async with Client(installation_id=self.install,
                       owner=self.owner,
                       repo=self.repo) as api_client:
         res = await api_client.delete_branch(branch=branch_name)
         try:
             res.raise_for_status()
         except HTTPError as e:
             if e.response is not None and e.response.status_code == 422:
                 self.log.info("branch already deleted, nothing to do",
                               res=res)
             else:
                 self.log.exception("failed to delete branch", res=res)
Пример #14
0
async def status_event(status_event: StatusEvent) -> None:
    """
    Trigger evaluation of all PRs associated with the status event commit SHA.
    """
    owner = status_event.repository.owner.login
    repo = status_event.repository.name
    installation_id = str(status_event.installation.id)
    log = logger.bind(owner=owner, repo=repo, install=installation_id)

    refs = find_branch_names_latest(sha=status_event.sha,
                                    branches=status_event.branches)

    async with Client(owner=owner, repo=repo,
                      installation_id=installation_id) as api_client:
        if len(refs) == 0:
            # when a pull request is from a fork the status event will not have
            # any `branches`, so to be able to trigger evaluation of the PR, we
            # fetch all pull requests.
            #
            # I think we could optimize this by selecting only the fork PRs, but
            # I worry that we might miss some events where `branches` is empty,
            # but not because of a fork.
            pr_results = [await api_client.get_open_pull_requests()]
            log.warning("could not find refs for status_event")
        else:
            pr_requests = [
                api_client.get_open_pull_requests(head=f"{owner}:{ref}")
                for ref in refs
            ]
            pr_results = cast(
                List[Optional[List[GetOpenPullRequestsResponse]]],
                await asyncio.gather(*pr_requests),
            )

        all_events: Set[WebhookEvent] = set()
        for prs in pr_results:
            if prs is None:
                continue
            for pr in prs:
                all_events.add(
                    WebhookEvent(
                        repo_owner=owner,
                        repo_name=repo,
                        pull_request_number=pr.number,
                        installation_id=str(installation_id),
                    ))
        for event in all_events:
            await redis_webhook_queue.enqueue(event=event)
Пример #15
0
 async def remove_label(self, label: str) -> None:
     """
     remove the PR label specified by `label_id` for a given `pr_number`
     """
     self.log.info("remove_label", label=label)
     async with Client(installation_id=self.install,
                       owner=self.owner,
                       repo=self.repo) as api_client:
         res = await api_client.delete_label(label, pull_number=self.number)
         try:
             res.raise_for_status()
         except HTTPError:
             self.log.exception("failed to delete label",
                                label=label,
                                res=res)
             # we raise an exception to retry this request.
             raise ApiCallException("delete label")
Пример #16
0
async def status_event(status_event: events.StatusEvent) -> None:
    assert status_event.installation
    sha = status_event.commit.sha
    owner = status_event.repository.owner.login
    repo = status_event.repository.name
    installation_id = str(status_event.installation.id)
    async with Client(owner=owner, repo=repo,
                      installation_id=installation_id) as api_client:
        prs = await api_client.get_pull_requests_for_sha(sha=sha)
        if prs is None:
            logger.warning("problem finding prs for sha")
            return None
        for pr in prs:
            await redis_webhook_queue.enqueue(event=WebhookEvent(
                repo_owner=owner,
                repo_name=repo,
                pull_request_number=pr.number,
                installation_id=str(installation_id),
            ))
Пример #17
0
async def repo_queue_consumer(*, queue_name: str,
                              connection: RedisConnection) -> typing.NoReturn:
    """
    Worker for a repo given by :queue_name:

    Pull webhook events off redis queue and process for mergeability.

    We only run one of these per repo as we can only merge one PR at a time
    to be efficient. This also alleviates the need of locks.
    """
    with sentry_sdk.configure_scope() as scope:
        scope.set_tag("queue", queue_name)
    log = logger.bind(queue=queue_name)
    log.info("start repo_consumer")
    while True:
        log.info("block for new repo event")
        webhook_event_json: BlockingZPopReply = await connection.bzpopmin(
            [queue_name])
        webhook_event = WebhookEvent.parse_raw(webhook_event_json.value)
        # mark this PR as being merged currently. we check this elsewhere to set proper status codes
        await connection.set(webhook_event.get_merge_target_queue_name(),
                             webhook_event.json())
        async with Client(
                owner=webhook_event.repo_owner,
                repo=webhook_event.repo_name,
                installation_id=webhook_event.installation_id,
        ) as api_client:
            pull_request = PR(
                owner=webhook_event.repo_owner,
                repo=webhook_event.repo_name,
                number=webhook_event.pull_request_number,
                installation_id=webhook_event.installation_id,
                client=api_client,
            )

            # mark that we're working on this PR
            await pull_request.set_status(summary="â›´ attempting to merge PR")
            while True:
                # there are two exits to this loop:
                # - OK MergeabilityResponse
                # - NOT_MERGEABLE MergeabilityResponse
                #
                # otherwise we continue to poll the Github API for a status change
                # from the other states: NEEDS_UPDATE, NEED_REFRESH, WAIT

                # TODO(chdsbd): Replace enum response with exceptions
                m_res, event = await pull_request.mergeability(merging=True)
                log = log.bind(res=m_res)
                if event is None or m_res == MergeabilityResponse.NOT_MERGEABLE:
                    log.info("cannot merge")
                    break
                if m_res == MergeabilityResponse.NEEDS_UPDATE:
                    # update pull request and poll for result
                    log.info("update pull request and don't attempt to merge")

                    # try multiple times in case of intermittent failure
                    retries = 5
                    while retries:
                        log.info("update branch")
                        res = await pull_request.update()
                        # if res is None:
                        if res is None:
                            break
                        retries -= 1
                        log.info("retry update branch")
                        await asyncio.sleep(RETRY_RATE_SECONDS)
                    log.error("failed to update branch")
                    await pull_request.set_status(
                        summary="🛑 could not update branch: {res}")
                    # break to find next PR to try and merge
                    break
                elif m_res == MergeabilityResponse.NEED_REFRESH:
                    # trigger a git mergeability check on Github's end and poll for result
                    log.info("needs refresh")
                    await pull_request.trigger_mergeability_check()
                    continue
                elif m_res == MergeabilityResponse.WAIT:
                    # continuously poll until we either get an OK or a failure for mergeability
                    log.info("waiting for status checks")
                    continue
                elif m_res == MergeabilityResponse.OK:
                    # continue to try and merge
                    pass
                else:
                    raise Exception("Unknown MergeabilityResponse")

                retries = 5
                while retries:
                    log.info("merge")
                    if await pull_request.merge(event):
                        # success merging
                        break
                    retries -= 1
                    log.info("retry merge")
                    await asyncio.sleep(RETRY_RATE_SECONDS)
                else:
                    log.error("Exhausted attempts to merge pull request")
Пример #18
0
def api_client(mocker: MockFixture, github_installation_id: str) -> Client:
    client = Client(installation_id=github_installation_id,
                    owner="foo",
                    repo="foo")
    mocker.patch.object(client, "send_query")
    return client
Пример #19
0
async def process_webhook_event(
    connection: RedisConnection,
    webhook_queue: RedisWebhookQueue,
    queue_name: str,
    log: structlog.BoundLogger,
) -> None:
    log.info("block for new webhook event")
    webhook_event_json: BlockingZPopReply = await connection.bzpopmin(
        [queue_name])
    webhook_event = WebhookEvent.parse_raw(webhook_event_json.value)
    async with Client(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            installation_id=webhook_event.installation_id,
    ) as api_client:
        pull_request = PR(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            number=webhook_event.pull_request_number,
            installation_id=webhook_event.installation_id,
            client=api_client,
        )
        is_merging = (await connection.get(
            webhook_event.get_merge_target_queue_name()
        ) == webhook_event.json())
        # trigger status updates
        m_res, event = await pull_request.mergeability()
        if event is None or m_res == MergeabilityResponse.NOT_MERGEABLE:
            # remove ineligible events from the merge queue
            await connection.zrem(webhook_event.get_merge_queue_name(),
                                  [webhook_event.json()])
            return
        if m_res == MergeabilityResponse.SKIPPABLE_CHECKS:
            log.info("skippable checks")
            return
        await update_pr_immediately_if_configured(m_res, event, pull_request,
                                                  log)

        if m_res not in (
                MergeabilityResponse.NEEDS_UPDATE,
                MergeabilityResponse.NEED_REFRESH,
                MergeabilityResponse.WAIT,
                MergeabilityResponse.OK,
                MergeabilityResponse.SKIPPABLE_CHECKS,
        ):
            raise Exception("Unknown MergeabilityResponse")

        if isinstance(event.config, V1) and event.config.merge.do_not_merge:
            # we duplicate the status messages found in the mergeability
            # function here because status messages for WAIT and NEEDS_UPDATE
            # are only set when Kodiak hits the merging logic.
            if m_res == MergeabilityResponse.WAIT:
                await pull_request.set_status(summary="⌛️ waiting for checks")
            if m_res in {
                    MergeabilityResponse.OK,
                    MergeabilityResponse.SKIPPABLE_CHECKS,
            }:
                await pull_request.set_status(summary="✅ okay to merge")
            log.debug(
                "skipping merging for PR because `merge.do_not_merge` is configured."
            )
            return

        if (isinstance(event.config, V1)
                and event.config.merge.prioritize_ready_to_merge
                and m_res == MergeabilityResponse.OK):
            merge_success = await pull_request.merge(event)
            if merge_success:
                return
            log.error("problem merging PR")

        # don't clobber statuses set in the merge loop
        # The following responses are okay to add to merge queue:
        #   + NEEDS_UPDATE - okay for merging
        #   + NEED_REFRESH - assume okay
        #   + WAIT - assume checks pass
        #   + OK - we've got the green
        webhook_event_jsons = await webhook_queue.enqueue_for_repo(
            event=webhook_event)
        if is_merging:
            return

        position = find_position(webhook_event_jsons, webhook_event_json.value)
        if position is None:
            return
        # use 1-based indexing
        humanized_position = inflection.ordinalize(position + 1)
        await pull_request.set_status(
            f"📦 enqueued for merge (position={humanized_position})")
Пример #20
0
async def process_repo_queue(log: structlog.BoundLogger,
                             connection: RedisConnection,
                             queue_name: str) -> None:
    log.info("block for new repo event")
    webhook_event_json: BlockingZPopReply = await connection.bzpopmin(
        [queue_name])
    webhook_event = WebhookEvent.parse_raw(webhook_event_json.value)
    # mark this PR as being merged currently. we check this elsewhere to set proper status codes
    await connection.set(webhook_event.get_merge_target_queue_name(),
                         webhook_event.json())
    async with Client(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            installation_id=webhook_event.installation_id,
    ) as api_client:
        pull_request = PR(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            number=webhook_event.pull_request_number,
            installation_id=webhook_event.installation_id,
            client=api_client,
        )

        # mark that we're working on this PR
        await pull_request.set_status(summary="⛴ attempting to merge PR")
        skippable_check_timeout = 4
        while True:
            # there are two exits to this loop:
            # - OK MergeabilityResponse
            # - NOT_MERGEABLE MergeabilityResponse
            #
            # otherwise we continue to poll the Github API for a status change
            # from the other states: NEEDS_UPDATE, NEED_REFRESH, WAIT

            # TODO(chdsbd): Replace enum response with exceptions
            m_res, event = await pull_request.mergeability(merging=True)
            log = log.bind(res=m_res)
            if event is None or m_res == MergeabilityResponse.NOT_MERGEABLE:
                log.info("cannot merge")
                break
            if m_res == MergeabilityResponse.SKIPPABLE_CHECKS:
                if skippable_check_timeout:
                    skippable_check_timeout -= 1
                    await asyncio.sleep(RETRY_RATE_SECONDS)
                    continue
                await pull_request.set_status(
                    summary="⌛️ waiting a bit for skippable checks")
                break

            if m_res == MergeabilityResponse.NEEDS_UPDATE:
                log.info("update pull request and don't attempt to merge")

                if await update_pr_with_retry(pull_request):
                    continue
                log.error("failed to update branch")
                await pull_request.set_status(
                    summary="🛑 could not update branch")
                # break to find next PR to try and merge
                break
            elif m_res == MergeabilityResponse.NEED_REFRESH:
                # trigger a git mergeability check on Github's end and poll for result
                log.info("needs refresh")
                await pull_request.trigger_mergeability_check()
                continue
            elif m_res == MergeabilityResponse.WAIT:
                # continuously poll until we either get an OK or a failure for mergeability
                log.info("waiting for status checks")
                continue
            elif m_res == MergeabilityResponse.OK:
                # continue to try and merge
                pass
            else:
                raise Exception("Unknown MergeabilityResponse")

            retries = 5
            while retries:
                log.info("merge")
                if await pull_request.merge(event):
                    # success merging
                    break
                retries -= 1
                log.info("retry merge")
                await asyncio.sleep(RETRY_RATE_SECONDS)
            else:
                log.error("Exhausted attempts to merge pull request")
Пример #21
0
async def webhook_event_consumer(*, connection: RedisConnection,
                                 webhook_queue: RedisWebhookQueue,
                                 queue_name: str) -> typing.NoReturn:
    """
    Worker to process incoming webhook events from redis

    1. process mergeability information and update github check status for pr
    2. enqueue pr into repo queue for merging, if mergeability passed
    """
    log = logger.bind(queue=queue_name)
    log.info("start webhook event consumer")

    while True:
        log.info("block for new webhook event")
        webhook_event_json: BlockingZPopReply = await connection.bzpopmin(
            [queue_name])
        webhook_event = WebhookEvent.parse_raw(webhook_event_json.value)
        async with Client(
                owner=webhook_event.repo_owner,
                repo=webhook_event.repo_name,
                installation_id=webhook_event.installation_id,
        ) as api_client:
            pull_request = PR(
                owner=webhook_event.repo_owner,
                repo=webhook_event.repo_name,
                number=webhook_event.pull_request_number,
                installation_id=webhook_event.installation_id,
                client=api_client,
            )
            is_merging = (await connection.get(
                webhook_event.get_merge_target_queue_name()
            ) == webhook_event.json())
            # trigger status updates
            m_res, event = await pull_request.mergeability()
            if event is None or m_res == MergeabilityResponse.NOT_MERGEABLE:
                # remove ineligible events from the merge queue
                await connection.zrem(webhook_event.get_merge_queue_name(),
                                      [webhook_event.json()])
                continue
            if m_res not in (
                    MergeabilityResponse.NEEDS_UPDATE,
                    MergeabilityResponse.NEED_REFRESH,
                    MergeabilityResponse.WAIT,
                    MergeabilityResponse.OK,
            ):
                raise Exception("Unknown MergeabilityResponse")

            # don't clobber statuses set in the merge loop
            # The following responses are okay to add to merge queue:
            #   + NEEDS_UPDATE - okay for merging
            #   + NEED_REFRESH - assume okay
            #   + WAIT - assume checks pass
            #   + OK - we've got the green
            webhook_event_jsons = await webhook_queue.enqueue_for_repo(
                event=webhook_event)
            if is_merging:
                continue

            position = find_position(webhook_event_jsons,
                                     webhook_event_json.value)
            if position is None:
                continue
            # use 1-based indexing
            humanized_position = inflection.ordinalize(position + 1)
            await pull_request.set_status(
                f"📦 enqueued for merge (position={humanized_position})")
Пример #22
0
async def process_webhook_event(
    connection: RedisConnection,
    webhook_queue: RedisWebhookQueue,
    queue_name: str,
    log: structlog.BoundLogger,
) -> None:
    log.info("block for new webhook event")
    webhook_event_json: BlockingZPopReply = await connection.bzpopmin(
        [queue_name])
    webhook_event = WebhookEvent.parse_raw(webhook_event_json.value)
    async with Client(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            installation_id=webhook_event.installation_id,
    ) as api_client:
        pull_request = PR(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            number=webhook_event.pull_request_number,
            installation_id=webhook_event.installation_id,
            client=api_client,
        )
        is_merging = (await connection.get(
            webhook_event.get_merge_target_queue_name()
        ) == webhook_event.json())
        # trigger status updates
        m_res, event = await pull_request.mergeability()
        if event is None or m_res == MergeabilityResponse.NOT_MERGEABLE:
            # remove ineligible events from the merge queue
            await connection.zrem(webhook_event.get_merge_queue_name(),
                                  [webhook_event.json()])
            return
        if m_res == MergeabilityResponse.SKIPPABLE_CHECKS:
            log.info("skippable checks")
            return
        await update_pr_immediately_if_configured(m_res, event, pull_request,
                                                  log)

        if m_res not in (
                MergeabilityResponse.NEEDS_UPDATE,
                MergeabilityResponse.NEED_REFRESH,
                MergeabilityResponse.WAIT,
                MergeabilityResponse.OK,
                MergeabilityResponse.SKIPPABLE_CHECKS,
        ):
            raise Exception("Unknown MergeabilityResponse")

        # don't clobber statuses set in the merge loop
        # The following responses are okay to add to merge queue:
        #   + NEEDS_UPDATE - okay for merging
        #   + NEED_REFRESH - assume okay
        #   + WAIT - assume checks pass
        #   + OK - we've got the green
        webhook_event_jsons = await webhook_queue.enqueue_for_repo(
            event=webhook_event)
        if is_merging:
            return

        position = find_position(webhook_event_jsons, webhook_event_json.value)
        if position is None:
            return
        # use 1-based indexing
        humanized_position = inflection.ordinalize(position + 1)
        await pull_request.set_status(
            f"📦 enqueued for merge (position={humanized_position})")