Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
UV ?= uv
UV_REQUIRED_VERSION := 0.10.11

.PHONY: check-uv init lint fmt test i18n docker
.PHONY: check-uv init lint fmt test run start i18n docker

check-uv:
@command -v $(UV) >/dev/null 2>&1 || { echo "uv is required. Install uv $(UV_REQUIRED_VERSION) before continuing."; exit 1; }
Expand All @@ -23,6 +23,11 @@ fmt: check-uv
test: lint
@$(UV) run pytest tests

run: check-uv
@$(UV) run python gitlab_bot.py

start: run

i18n:
xgettext -d base -o src/locales/gitlab-bot.pot *.py
msgfmt -o src/locales/en/LC_MESSAGES/gitlab-bot.mo src/locales/en/LC_MESSAGES/gitlab-bot.po
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@ Use the existing Makefile entry points for common tasks:
make lint
make fmt
make test
make run
```

These commands use `uv.lock`; they do not install packages globally.
`make run` starts the local webhook service using the configuration from `.env`. `make start` is an alias. These commands use `uv.lock`; they do not install packages globally.

## How to use

Expand Down Expand Up @@ -221,6 +222,8 @@ Merge requests can only be merged if the commit message follows a specific forma

This parameter controls whether the bot automatically approves merge requests that pass all checks. By default, it is set to `true`, allowing the bot to automatically approve merge requests. Setting it to `false` disables automatic approval, requiring manual approval for all merge requests.

When validation fails, the bot only attempts to revoke its own approval. It never resets other users' approvals; if the approval state cannot be confirmed, the failure comment reports that state instead of claiming the approval was revoked.

**`BOT_GITLAB_MERGE_REQUEST_AIREVIEW_LABEL_ENABLED`**

This parameter controls the feature to add a new status label "AI Review" to merge requests, facilitating better tracking of AI-assisted code reviews. When enabled, the label is automatically added when AI summary generation is enabled and completed. The label is removed when a Merge Request is updated but the AI feature is disabled.
Expand Down
23 changes: 22 additions & 1 deletion gitlab_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
# limitations under the License.

import logging
import warnings

from dotenv import load_dotenv
from gidgetlab.aiohttp import GitLabBot

from src.config import (
bot_gitlab_token,
Expand All @@ -31,6 +31,27 @@

load_dotenv() # isort:skip


def _load_gitlab_bot():
# gidgetlab 1.1.0 still imports the deprecated pkg_resources API.
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
category=UserWarning,
message=r"pkg_resources is deprecated as an API\.",
)
warnings.filterwarnings(
"ignore",
category=DeprecationWarning,
message=r"Deprecated call to `pkg_resources\.declare_namespace",
)
from gidgetlab.aiohttp import GitLabBot

return GitLabBot


GitLabBot = _load_gitlab_bot()

bot = GitLabBot(bot_gitlab_username, url=bot_gitlab_url, access_token=bot_gitlab_token)

issue_hooks = IssueHooks()
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ authors = [
requires-python = ">=3.9,<4.0"
dependencies = [
"gidgetlab[aiohttp]>=1.1.0,<2.0.0",
"setuptools<81",
"langchain==0.3.6",
"langchain-openai==0.2.5",
"langchain-google-genai==2.0.4",
"python-dotenv==1.0.1",
"urllib3<2",
]

[dependency-groups]
Expand Down
Binary file modified src/locales/en/LC_MESSAGES/gitlab-bot.mo
Binary file not shown.
22 changes: 21 additions & 1 deletion src/locales/en/LC_MESSAGES/gitlab-bot.po
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,27 @@ msgid "bot_review_success"
msgstr "😊 Review validation success and approve the merge request."

msgid "bot_review_fails"
msgstr "🙁 Review validation fails and approval has been revoked."
msgstr "🙁 Review validation failed."
"\n"
"{error_message}"

msgid "bot_review_fails_approval_revoked"
msgstr "🙁 Review validation failed and the bot's approval was revoked."
"\n"
"{error_message}"

msgid "bot_review_fails_approval_not_present"
msgstr "🙁 Review validation failed; no bot approval was present, so no approval was revoked."
"\n"
"{error_message}"

msgid "bot_review_fails_approval_not_revoked"
msgstr "🙁 Review validation failed; the bot approval could not be revoked."
"\n"
"{error_message}"

msgid "bot_review_fails_approval_unknown"
msgstr "🙁 Review validation failed; the bot approval status could not be confirmed."
"\n"
"{error_message}"

Expand Down
Binary file modified src/locales/zh/LC_MESSAGES/gitlab-bot.mo
Binary file not shown.
24 changes: 22 additions & 2 deletions src/locales/zh/LC_MESSAGES/gitlab-bot.po
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,27 @@ msgid "bot_review_success"
msgstr "😊合并请求验证成功,批准合并请求。"

msgid "bot_review_fails"
msgstr "🙁合并请求验证失败,并撤回批准。"
msgstr "🙁合并请求验证失败。"
"\n"
"{error_message}"

msgid "bot_review_fails_approval_revoked"
msgstr "🙁合并请求验证失败,机器人审批已撤销。"
"\n"
"{error_message}"

msgid "bot_review_fails_approval_not_present"
msgstr "🙁合并请求验证失败;未发现机器人审批,因此没有撤销任何审批。"
"\n"
"{error_message}"

msgid "bot_review_fails_approval_not_revoked"
msgstr "🙁合并请求验证失败;机器人审批未能撤销。"
"\n"
"{error_message}"

msgid "bot_review_fails_approval_unknown"
msgstr "🙁合并请求验证失败;无法确认机器人审批状态。"
"\n"
"{error_message}"

Expand Down Expand Up @@ -72,4 +92,4 @@ msgstr "Bot 帮助: \n"
"* **/bot-issue-outdated**: 添加 ~\"Outdated\" 标签到最近 17 天不活跃的 Issues,例如: /bot-issue-outdated"

msgid "marked_issue_outdated_summary"
msgstr "发现 {total} 个过时的问题(闲置超过 {days_ago} 天),标记为 ~\"{label_name}\""
msgstr "发现 {total} 个过时的问题(闲置超过 {days_ago} 天),标记为 ~\"{label_name}\""
59 changes: 41 additions & 18 deletions src/merge_request_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ async def generate_diff_description_summary(event, gl):

async def check_commit(event, gl):
project_id = event.project_id
approval_attempted = False
if event.data["event_type"] == "note":
commit_title = event.data["merge_request"]["last_commit"]["title"]
commit_author_name = event.data["merge_request"]["last_commit"]["author"]["name"]
Expand Down Expand Up @@ -224,32 +225,54 @@ async def check_commit(event, gl):
check_email(commit_author_name, commit_author_email)
check_commit_message(commit_title)
if bot_gitlab_merge_request_approval_enabled:
approval_attempted = True
await approval_merge_request(project_id, iid, gl)
message = _("bot_review_success")
approval_merge_request(project_id, iid, gl)
await gl.post(merge_request_post_note_url, data={"body": message})
except Exception as e:
message = _("bot_review_fails").format(error_message=str(e))
if bot_gitlab_merge_request_approval_enabled and not approval_attempted:
try:
bot_approval_revoked = await unapprove_merge_request(project_id, iid, gl)
except Exception as unapprove_error:
message = _("bot_review_fails_approval_not_revoked").format(
error_message=f"{e}; unapprove error: {unapprove_error}"
)
else:
if bot_approval_revoked:
message = _("bot_review_fails_approval_revoked").format(error_message=str(e))
else:
message = _("bot_review_fails_approval_not_present").format(error_message=str(e))
elif bot_gitlab_merge_request_approval_enabled:
message = _("bot_review_fails_approval_unknown").format(error_message=str(e))
else:
message = _("bot_review_fails").format(error_message=str(e))
await gl.post(merge_request_post_note_url, data={"body": message})

# Only support GitLab Premium in 13.9
# https://docs.gitlab.com/ee/api/merge_request_approvals.html#unapprove-merge-request
# merge_request_post_unapproval_url = (
# f"/projects/{project_id}/merge_requests/{iid}/unapprove"
# )
# await gl.post(merge_request_post_unapproval_url, data=None)

async def _bot_has_approval(project_id, iid, gl):
query_approvals_url = f"/projects/{project_id}/merge_requests/{iid}/approvals"
approvals = await gl.getitem(query_approvals_url)
if approvals.get("approved"):
for approval in approvals.get("approved_by") or []:
user = approval.get("user") or {}
if user.get("username") == bot_gitlab_username:
return True
return False


async def approval_merge_request(project_id, iid, gl):
query_approvals_url = f"/projects/{project_id}/merge_requests/{iid}/approvals"
approvals = gl.getitem(query_approvals_url)
bot_approved = False
if approvals.approved:
for approval in approvals.approved_by:
if approval.user.username == bot_gitlab_username:
bot_approved = True
return
if not bot_approved:
await gl.post(f"/projects/{project_id}/merge_requests/{iid}/approve", data=None)
if await _bot_has_approval(project_id, iid, gl):
return

await gl.post(f"/projects/{project_id}/merge_requests/{iid}/approve", data=None)


async def unapprove_merge_request(project_id, iid, gl):
if not await _bot_has_approval(project_id, iid, gl):
return False

await gl.post(f"/projects/{project_id}/merge_requests/{iid}/unapprove", data=None)
return True


def is_opened_merge_request(event):
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Reusable fixtures for GitLab bot tests."""
81 changes: 81 additions & 0 deletions tests/fixtures/approval_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from copy import deepcopy

APPROVALS_EMPTY_GET = {
"approved": False,
"approvals_required": None,
"approvals_left": None,
"approved_by": [],
}

APPROVALS_ROBOT_GET = {
"approved": True,
"approvals_required": None,
"approvals_left": None,
"approved_by": [
{
"user": {
"id": 28,
"username": "review-bot",
"name": "review-bot",
}
}
],
}

APPROVALS_OTHER_USER_GET = {
"approved": True,
"approvals_required": None,
"approvals_left": None,
"approved_by": [
{
"user": {
"id": 29,
"username": "other-reviewer",
"name": "Other Reviewer",
}
}
],
}

APPROVE_RESPONSE = {
"user_has_approved": True,
"user_can_approve": False,
"approved": True,
"approved_by": [
{
"user": {
"id": 28,
"username": "review-bot",
"name": "review-bot",
}
}
],
}

UNAPPROVE_RESPONSE = {
"user_has_approved": False,
"user_can_approve": True,
"approved": False,
"approved_by": [],
}


class ApprovalApiFake:
"""Async fake for the subset of gidgetlab used by approval tests."""

def __init__(self, responses):
self._responses = {key: list(values) for key, values in responses.items()}
self.calls = []

async def getitem(self, url):
return self._respond("GET", url, None)

async def post(self, url, data=None):
return self._respond("POST", url, data)

def _respond(self, method, url, data):
self.calls.append((method, url, data))
key = (method, url)
if key not in self._responses or not self._responses[key]:
raise AssertionError(f"No fake response configured for {method} {url}")
return deepcopy(self._responses[key].pop(0))
Loading
Loading