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
- Create a token with the role
Developer
and access toapi
- Put this token in a CI/CD-variable
GITLAB_API_TOKEN
(using the original script) - Use a container-image with Python (and your custom CA)
- save the script (see above) at
scripts/add_reviewers.py
in your repo - Add your
requirements.txt
for the Python dependencies (yes, I know this is old-fashioned) - 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.