The problem

GitHub and GitLab each miss some features that the other platform has. One topic, that is quite common for people who are using GitHub is the automatically assignment of reviewers depending on the CODEOWNERS file.

This feature is not available in GitLab and an issue mentioning this feature was closed on the 18.09.2024, after a bit more than two years.

This leaves us still with a missing feature.

Somewhere in the comments I found this gem:

#!/usr/bin/env python3

import os
import requests

import typing as t


class SuggestedApprover(t.TypedDict):
    id: int
    username: str
    name: str


def post_merge_request_note(
    *,
    message: str,
    ci_api_v4_url: str,
    ci_project_id: str,
    ci_merge_request_iid: str,
    gitlab_api_token: str,
) -> None:
    # https://docs.gitlab.com/ee/api/notes.html#merge-requests
    # POST /projects/:id/merge_requests/:merge_request_iid/notes
    resp: requests.Response = requests.post(
        f"{ci_api_v4_url}/projects/{ci_project_id}/merge_requests/{ci_merge_request_iid}/notes",
        params={
            "body": message,
        },
        headers={
            "PRIVATE-TOKEN": gitlab_api_token,
        },
    )
    try:
        resp.raise_for_status()
    except Exception:
        print(resp.text)
        raise


def get_merge_request_suggested_approvers(
    ci_api_v4_url: str,
    ci_project_id: str,
    ci_merge_request_iid: str,
    gitlab_api_token: str,
) -> list[SuggestedApprover]:
    # GET /projects/:id/merge_requests/:merge_request_iid/approvals
    resp: requests.Response = requests.get(
        f"{ci_api_v4_url}/projects/{ci_project_id}/merge_requests/{ci_merge_request_iid}/approvals",
        headers={
            "PRIVATE-TOKEN": gitlab_api_token,
        },
    )
    try:
        resp.raise_for_status()
    except Exception:
        print(resp.text)
        raise
    approvals: dict = resp.json()
    suggested_approvers: list[SuggestedApprover] = approvals[
        "suggested_approvers"
    ]
    return suggested_approvers


def get_merge_request_reviewers(
    *,
    ci_api_v4_url: str,
    ci_project_id: str,
    ci_merge_request_iid: str,
    gitlab_api_token: str,
) -> list[int]:
    # https://docs.gitlab.com/ee/api/merge_requests.html#get-single-merge-request-reviewers
    # GET /projects/:id/merge_requests/:merge_request_iid/reviewers
    resp: requests.Response = requests.get(
        f"{ci_api_v4_url}/projects/{ci_project_id}/merge_requests/{ci_merge_request_iid}/reviewers",
        headers={
            "PRIVATE-TOKEN": gitlab_api_token,
        },
    )
    try:
        resp.raise_for_status()
    except Exception:
        print(resp.text)
        raise
    reviewers: list[dict] = resp.json()
    reviewer_ids: list[int] = [reviewer["user"]["id"] for reviewer in reviewers]
    return reviewer_ids


def set_merge_request_reviewers(
    *,
    ci_api_v4_url: str,
    ci_project_id: str,
    ci_merge_request_iid: str,
    gitlab_api_token: str,
    reviewers: list[int],
) -> None:
    # https://docs.gitlab.com/ee/api/merge_requests.html#update-mr
    # PUT /projects/:id/merge_requests/:merge_request_iid
    resp: requests.Response = requests.put(
        f"{ci_api_v4_url}/projects/{ci_project_id}/merge_requests/{ci_merge_request_iid}",
        params={
            "reviewer_ids": ",".join(
                [str(reviewer) for reviewer in reviewers],
            ),
        },
        headers={
            "PRIVATE-TOKEN": gitlab_api_token,
        },
    )
    try:
        resp.raise_for_status()
    except Exception:
        print(resp.text)
        raise


def log(msg: str) -> None:
    print("[ASSIGN-REVIEWERS]", msg)


def main() -> None:
    gitlab_api_token: str = os.environ["GITLAB_API_TOKEN"]
    ci_project_id: str = os.environ["CI_PROJECT_ID"]
    ci_api_v4_url: str = os.environ["CI_API_V4_URL"]
    ci_merge_request_iid: str | None = os.environ.get("CI_MERGE_REQUEST_IID")

    if not ci_merge_request_iid:
        log("Not a pull request. Nothing to do...")
        return

    actual_reviewers: set[int] = set(
        get_merge_request_reviewers(
            ci_api_v4_url=ci_api_v4_url,
            ci_project_id=ci_project_id,
            ci_merge_request_iid=ci_merge_request_iid,
            gitlab_api_token=gitlab_api_token,
        )
    )

    suggested_reviewers: list[SuggestedApprover] = (
        get_merge_request_suggested_approvers(
            ci_api_v4_url=ci_api_v4_url,
            ci_project_id=ci_project_id,
            ci_merge_request_iid=ci_merge_request_iid,
            gitlab_api_token=gitlab_api_token,
        )
    )

    reviewers_to_add: set[int] = set()

    for suggested_reviewer in suggested_reviewers:
        suggested_reviewer_id: int = suggested_reviewer["id"]
        suggested_reviewer_name: str = suggested_reviewer["name"]
        suggested_reviewer_username: str = suggested_reviewer["username"]

        if suggested_reviewer_id in actual_reviewers:
            continue

        reviewers_to_add.add(suggested_reviewer_id)

        log(f"Will add {suggested_reviewer_name} to reviewers...")

        new_reviewer_message: str = f"""
Hey @{suggested_reviewer_username} :wave:,

I am adding you to reviewers because you are watching files changed in this MR.

Have a good day.

Your Friendly Review Assignment Script,
"""

        post_merge_request_note(
            message=new_reviewer_message,
            ci_api_v4_url=ci_api_v4_url,
            ci_project_id=ci_project_id,
            ci_merge_request_iid=ci_merge_request_iid,
            gitlab_api_token=gitlab_api_token,
        )

    if not reviewers_to_add:
        log("No reviewers to add...")
        return

    set_merge_request_reviewers(
        ci_api_v4_url=ci_api_v4_url,
        ci_project_id=ci_project_id,
        ci_merge_request_iid=ci_merge_request_iid,
        gitlab_api_token=gitlab_api_token,
        reviewers=list(actual_reviewers.union(reviewers_to_add)),
    )


if __name__ == "__main__":
    main()

This is a very nice and thorough script, but getting this one running was interesting for me.

Setup

  1. Create a token with the role Developer and access to api
  2. Put this token in a CI/CD-variable GITLAB_API_TOKEN (using the original script)
  3. Use a container-image with Python (and your custom CA)
  4. save the script (see above) at scripts/add_reviewers.py in your repo
  5. Add your requirements.txt for the Python dependencies (yes, I know this is old-fashioned)
  6. Configure your GitLab pipeline

Notes on the container-image

This container-image is not minimal, but shows the handling of custom CA in Alpine Linux.

The configuration of custom CAs is totally fine for the Linux, but there are some caveats when using Python with venv.

FROM alpine

ENV CUSTOM_CA_CERT_ROOT=https://<base-url-for-internal-certs>

RUN apk add --no-cache \
  curl \
  gcompat \
  git \
  idn2-utils \
  jq \
  cosign \
  ca-certificates \
  python3 \
  py3-pip

# Download CA Certificates and Update CA certificates
RUN curl -sS -f -k -o /usr/local/share/ca-certificates/CustomRootCA.crt ${CUSTOM_CA_CERT_ROOT}/Custom-RootCA.pem && \
    curl -sS -f -k -o /usr/local/share/ca-certificates/CustomRootCA-new.crt ${CUSTOM_CA_CERT_ROOT}/RootCA/Custom-ROOTCA02-new-final.cer && \
    update-ca-certificates

WORKDIR /

# Override ENTRYPOINT
ENTRYPOINT []

requirements.txt

This is a snapshot of the requirements.txt

charset-normalizer==3.4.0
idna==3.10
pip-system-certs==4.0
requests==2.32.3
typing==3.7.4.3
urllib3==2.2.3
wrapt==1.16.0

Q: What does this pip-system-certs module do?

A: This module ensures, that the system cert-store (CA) is used instead of some separate venv-truststore.

GitLab pipeline

The pipeline will run on the creation or update of a MR.

stages:
  - add-reviewers

add-reviewers:
  image: my-python-image:latest
  stage: add-reviewers
  rules:
    - if: $CI_PIPELINE_SOURCE =~ /web|schedule/
      when: never
    - if: $CI_COMMIT_BRANCH != "main"
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
  script:
    - python3 -m venv /path/to/venv
    - . /path/to/venv/bin/activate
    - pip install -r requirements.txt
    - python3 scripts/add_reviewers.py
    - deactivate

Afterword

It was really interesting to find out about the special handling of the truststore in a venv and how to overcome this specialty.

The script works nicely, supports groups and single users.