gitlab-mr-commenter 2.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitlab_mr_commenter-2.0.1/.github/workflows/publish.yml +79 -0
- gitlab_mr_commenter-2.0.1/.github/workflows/release.yml +33 -0
- gitlab_mr_commenter-2.0.1/CHANGELOG.md +28 -0
- gitlab_mr_commenter-2.0.1/PKG-INFO +7 -0
- gitlab_mr_commenter-2.0.1/README.md +183 -0
- gitlab_mr_commenter-2.0.1/examples/gitlab-ci.yml +34 -0
- gitlab_mr_commenter-2.0.1/examples/python-script.py +26 -0
- gitlab_mr_commenter-2.0.1/gitlab_mr_commenter/__init__.py +190 -0
- gitlab_mr_commenter-2.0.1/gitlab_mr_commenter/__main__.py +115 -0
- gitlab_mr_commenter-2.0.1/gitlab_mr_commenter/py.typed +0 -0
- gitlab_mr_commenter-2.0.1/pyproject.toml +34 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
name: Build distribution
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: astral-sh/setup-uv@v5
|
|
20
|
+
|
|
21
|
+
- name: Build sdist and wheel
|
|
22
|
+
run: uv build
|
|
23
|
+
|
|
24
|
+
- uses: actions/upload-artifact@v4
|
|
25
|
+
with:
|
|
26
|
+
name: dist
|
|
27
|
+
path: dist/
|
|
28
|
+
|
|
29
|
+
# Verify the package installs and the CLI entry point is present.
|
|
30
|
+
smoke-test:
|
|
31
|
+
name: Smoke test
|
|
32
|
+
needs: build
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/download-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
|
|
41
|
+
- uses: astral-sh/setup-uv@v5
|
|
42
|
+
|
|
43
|
+
- name: Install from wheel
|
|
44
|
+
run: |
|
|
45
|
+
uv venv
|
|
46
|
+
uv pip install dist/*.whl
|
|
47
|
+
|
|
48
|
+
- name: Verify CLI entry point
|
|
49
|
+
run: uv run gitlab-mr-commenter --help
|
|
50
|
+
|
|
51
|
+
- name: Verify library import
|
|
52
|
+
run: uv run python -c "from gitlab_mr_commenter import MRCommenter, post_comment; print('OK')"
|
|
53
|
+
|
|
54
|
+
publish:
|
|
55
|
+
name: Publish to PyPI
|
|
56
|
+
needs: [build, smoke-test]
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
|
|
59
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
60
|
+
|
|
61
|
+
environment:
|
|
62
|
+
name: pypi
|
|
63
|
+
url: https://pypi.org/p/gitlab-mr-commenter
|
|
64
|
+
|
|
65
|
+
# Trusted Publishing — no API token stored in GitHub secrets.
|
|
66
|
+
# Set this up once at https://pypi.org/manage/project/gitlab-mr-commenter/settings/publishing/
|
|
67
|
+
permissions:
|
|
68
|
+
id-token: write
|
|
69
|
+
|
|
70
|
+
steps:
|
|
71
|
+
- uses: actions/download-artifact@v4
|
|
72
|
+
with:
|
|
73
|
+
name: dist
|
|
74
|
+
path: dist/
|
|
75
|
+
|
|
76
|
+
- uses: astral-sh/setup-uv@v5
|
|
77
|
+
|
|
78
|
+
- name: Publish to PyPI
|
|
79
|
+
run: uv publish --trusted-publishing always
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
id-token: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
release:
|
|
14
|
+
name: Semantic release
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
with:
|
|
20
|
+
fetch-depth: 0
|
|
21
|
+
# Use a token that can push to protected branches and trigger
|
|
22
|
+
# downstream workflows (the default GITHUB_TOKEN cannot).
|
|
23
|
+
token: ${{ secrets.RELEASE_TOKEN }}
|
|
24
|
+
|
|
25
|
+
- uses: astral-sh/setup-uv@v5
|
|
26
|
+
|
|
27
|
+
- name: Install python-semantic-release
|
|
28
|
+
run: uv tool install python-semantic-release
|
|
29
|
+
|
|
30
|
+
- name: Run semantic-release
|
|
31
|
+
run: semantic-release version --push
|
|
32
|
+
env:
|
|
33
|
+
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
<!-- version list -->
|
|
4
|
+
|
|
5
|
+
## v2.0.1 (2026-02-19)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- **ci**: Use uv venv instead of --system for smoke-test
|
|
10
|
+
([`c0a8317`](https://github.com/gianklug/gitlab-mr-commenter/commit/c0a831799170348966dc6985e055f3093940399a))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## v2.0.0 (2026-02-19)
|
|
14
|
+
|
|
15
|
+
### Continuous Integration
|
|
16
|
+
|
|
17
|
+
- Add semantic release and fix smoke-test wheel install
|
|
18
|
+
([`3b96b0a`](https://github.com/gianklug/gitlab-mr-commenter/commit/3b96b0a139f6acd36d3ce571f8d8cab188cba41b))
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
- Switch to semrel
|
|
23
|
+
([`d866c23`](https://github.com/gianklug/gitlab-mr-commenter/commit/d866c23dcef0e94d59ed2f831d5948ff53a41f6e))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## v1.0.0 (2026-02-19)
|
|
27
|
+
|
|
28
|
+
- Initial Release
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# gitlab-mr-commenter
|
|
2
|
+
|
|
3
|
+
Post and **idempotently update** comments on GitLab merge requests.
|
|
4
|
+
|
|
5
|
+
Re-running with the same `comment_id` updates the existing note in place rather
|
|
6
|
+
than creating a duplicate — useful for plan/apply pipelines where you want a
|
|
7
|
+
single, always-current comment per environment.
|
|
8
|
+
|
|
9
|
+
Inspired by the `mr-commenter` sub-command in
|
|
10
|
+
[`gitlab-tofu-ctl`](https://gitlab.com/components/opentofu), reimplemented as a
|
|
11
|
+
standalone Python package with both a library API and a CLI.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Add to a project
|
|
19
|
+
uv add gitlab-mr-commenter
|
|
20
|
+
|
|
21
|
+
# Run without installing (ephemeral)
|
|
22
|
+
uvx gitlab-mr-commenter --help
|
|
23
|
+
|
|
24
|
+
# pip also works
|
|
25
|
+
pip install gitlab-mr-commenter
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Python 3.9+ is required. The only dependency is
|
|
29
|
+
[`python-gitlab`](https://python-gitlab.readthedocs.io/) ≥ 8.0.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
### CLI
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Content is read from stdin; IDs and token are read from CI environment variables.
|
|
39
|
+
echo "## Plan: no changes" | gitlab-mr-commenter plan-production
|
|
40
|
+
|
|
41
|
+
# Explicit flags override env vars.
|
|
42
|
+
echo "## Plan: no changes" | gitlab-mr-commenter \
|
|
43
|
+
--project-id 123 \
|
|
44
|
+
--mr-iid 45 \
|
|
45
|
+
--token "$GITLAB_TOKEN" \
|
|
46
|
+
--api-url "https://gitlab.com/api/v4" \
|
|
47
|
+
plan-production
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Python
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from gitlab_mr_commenter import MRCommenter
|
|
54
|
+
|
|
55
|
+
commenter = MRCommenter() # reads token + URL from env
|
|
56
|
+
commenter.post(
|
|
57
|
+
project_id=123,
|
|
58
|
+
mr_iid=45,
|
|
59
|
+
comment_id="plan-production",
|
|
60
|
+
content="## Plan\n\nNo changes.",
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
One-shot convenience function:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from gitlab_mr_commenter import post_comment
|
|
68
|
+
|
|
69
|
+
post_comment(123, 45, "plan-production", "## Plan\n\nNo changes.")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
All configuration can be supplied either as keyword arguments (library) / CLI
|
|
77
|
+
flags, or via environment variables. Environment variables are the natural
|
|
78
|
+
choice in CI pipelines.
|
|
79
|
+
|
|
80
|
+
| Environment variable | CLI flag | Description |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `GITLAB_MR_PLAN_TOKEN` | `--token` | GitLab API token (preferred) |
|
|
83
|
+
| `GITLAB_TOKEN` | `--token` | GitLab API token (fallback) |
|
|
84
|
+
| `CI_API_V4_URL` | `--api-url` | GitLab API base URL, e.g. `https://gitlab.com/api/v4` |
|
|
85
|
+
| `CI_PROJECT_ID` | `--project-id` | Numeric project ID |
|
|
86
|
+
| `CI_MERGE_REQUEST_IID` | `--mr-iid` | MR internal (project-scoped) ID |
|
|
87
|
+
|
|
88
|
+
> **Token scopes** — the token needs at least the `api` scope (or
|
|
89
|
+
> `write_repository` for project tokens). In GitLab CI, a
|
|
90
|
+
> [project access token](https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html)
|
|
91
|
+
> with **Developer** role and `api` scope is sufficient.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## How it works
|
|
96
|
+
|
|
97
|
+
Each managed comment has a hidden HTML marker appended to its body:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
<!-- gitlab-mr-commenter id="plan-production" -->
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
On every run the tool paginates through all notes on the MR looking for this
|
|
104
|
+
marker. If found, the note is updated in place. If not found, a new note is
|
|
105
|
+
created. The marker format is compatible with `gitlab-tofu-ctl mr-commenter`
|
|
106
|
+
so both tools can coexist on the same MR.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## CLI reference
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
usage: gitlab-mr-commenter [-h] [--project-id ID] [--mr-iid IID]
|
|
114
|
+
[--token TOKEN] [--api-url URL]
|
|
115
|
+
COMMENT_ID
|
|
116
|
+
|
|
117
|
+
positional arguments:
|
|
118
|
+
COMMENT_ID Unique name for this comment slot, e.g. 'plan-production'.
|
|
119
|
+
A second run with the same value updates the existing comment.
|
|
120
|
+
|
|
121
|
+
options:
|
|
122
|
+
-h, --help show this help message and exit
|
|
123
|
+
--project-id ID GitLab project ID. Defaults to $CI_PROJECT_ID.
|
|
124
|
+
--mr-iid IID MR internal ID. Defaults to $CI_MERGE_REQUEST_IID.
|
|
125
|
+
--token TOKEN GitLab API token. Defaults to $GITLAB_MR_PLAN_TOKEN
|
|
126
|
+
or $GITLAB_TOKEN.
|
|
127
|
+
--api-url URL GitLab API base URL. Defaults to $CI_API_V4_URL.
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Development setup
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
git clone https://github.com/your-org/gitlab-mr-commenter
|
|
136
|
+
cd gitlab-mr-commenter
|
|
137
|
+
|
|
138
|
+
# Install dependencies (creates .venv automatically)
|
|
139
|
+
uv sync
|
|
140
|
+
|
|
141
|
+
# Run the CLI
|
|
142
|
+
uv run gitlab-mr-commenter --help
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Running the CLI locally against a real MR
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
export GITLAB_MR_PLAN_TOKEN="glpat-xxxxxxxxxxxx"
|
|
149
|
+
export CI_API_V4_URL="https://gitlab.com/api/v4"
|
|
150
|
+
export CI_PROJECT_ID="12345678"
|
|
151
|
+
export CI_MERGE_REQUEST_IID="42"
|
|
152
|
+
|
|
153
|
+
echo "## Test comment $(date)" | uv run gitlab-mr-commenter test-local
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Publishing
|
|
159
|
+
|
|
160
|
+
Releases are published to [PyPI](https://pypi.org/project/gitlab-mr-commenter/)
|
|
161
|
+
automatically when a version tag is pushed (see `.github/workflows/publish.yml`).
|
|
162
|
+
The workflow uses [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/)
|
|
163
|
+
so no API token needs to be stored in GitHub secrets — set it up once on PyPI's
|
|
164
|
+
side under your project's Publishing settings.
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# Bump the version in pyproject.toml, then:
|
|
168
|
+
git tag v0.2.0
|
|
169
|
+
git push origin v0.2.0
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
To publish manually:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
uv build
|
|
176
|
+
uv publish
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Example: post a comment on a GitLab MR using gitlab-mr-commenter.
|
|
2
|
+
#
|
|
3
|
+
# Prerequisites
|
|
4
|
+
# -------------
|
|
5
|
+
# 1. Create a project access token (Developer role, `api` scope).
|
|
6
|
+
# 2. Add it as a masked CI/CD variable named GITLAB_MR_PLAN_TOKEN.
|
|
7
|
+
#
|
|
8
|
+
# The variables CI_PROJECT_ID, CI_MERGE_REQUEST_IID, and CI_API_V4_URL are
|
|
9
|
+
# provided automatically by GitLab in merge-request pipelines.
|
|
10
|
+
|
|
11
|
+
stages:
|
|
12
|
+
- comment
|
|
13
|
+
|
|
14
|
+
post-mr-comment:
|
|
15
|
+
stage: comment
|
|
16
|
+
image: python:3.12-slim
|
|
17
|
+
rules:
|
|
18
|
+
# Only run in merge-request pipelines so CI_MERGE_REQUEST_IID is set.
|
|
19
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
20
|
+
script:
|
|
21
|
+
- pip install uv
|
|
22
|
+
|
|
23
|
+
# Run whatever command you like and capture its output.
|
|
24
|
+
- OUTPUT=$(date -u)
|
|
25
|
+
|
|
26
|
+
# Pipe the output into gitlab-mr-commenter.
|
|
27
|
+
# Re-running this job updates the same comment rather than creating a new one.
|
|
28
|
+
# CI_PROJECT_ID, CI_MERGE_REQUEST_IID, and CI_API_V4_URL are picked up automatically.
|
|
29
|
+
- |
|
|
30
|
+
cat <<EOF | uvx gitlab-mr-commenter
|
|
31
|
+
## Pipeline info
|
|
32
|
+
|
|
33
|
+
Triggered by job [#${CI_JOB_ID}](${CI_JOB_URL}) at ${OUTPUT}.
|
|
34
|
+
EOF
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Example: call gitlab-mr-commenter from a Python script.
|
|
3
|
+
|
|
4
|
+
Run (env vars must be set, see README):
|
|
5
|
+
|
|
6
|
+
python examples/python-script.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from gitlab_mr_commenter import MRCommenter
|
|
12
|
+
|
|
13
|
+
output = subprocess.check_output(["date", "-u"], text=True).strip()
|
|
14
|
+
|
|
15
|
+
content = f"""\
|
|
16
|
+
## Pipeline info
|
|
17
|
+
|
|
18
|
+
Triggered at {output} by job [{os.environ.get('CI_JOB_ID', 'local')}]({os.environ.get('CI_JOB_URL', '')})."""
|
|
19
|
+
|
|
20
|
+
commenter = MRCommenter() # token + api_url from env
|
|
21
|
+
commenter.post(
|
|
22
|
+
project_id=int(os.environ["CI_PROJECT_ID"]),
|
|
23
|
+
mr_iid=int(os.environ["CI_MERGE_REQUEST_IID"]),
|
|
24
|
+
comment_id="my-comment-slot",
|
|
25
|
+
content=content,
|
|
26
|
+
)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""gitlab-mr-commenter: post and idempotently update comments on GitLab MRs.
|
|
2
|
+
|
|
3
|
+
Each comment slot is identified by a hidden HTML marker embedded at the end
|
|
4
|
+
of the comment body. Re-running with the same ``comment_id`` updates the
|
|
5
|
+
existing comment rather than creating a duplicate.
|
|
6
|
+
|
|
7
|
+
Typical CI usage
|
|
8
|
+
----------------
|
|
9
|
+
The following environment variables are read automatically when the
|
|
10
|
+
corresponding keyword argument is omitted:
|
|
11
|
+
|
|
12
|
+
* ``GITLAB_MR_PLAN_TOKEN`` (preferred) or ``GITLAB_TOKEN`` — API token
|
|
13
|
+
* ``CI_API_V4_URL`` — GitLab API base URL
|
|
14
|
+
* ``CI_PROJECT_ID`` — project ID
|
|
15
|
+
* ``CI_MERGE_REQUEST_IID`` — MR internal ID (project-scoped, visible in URLs)
|
|
16
|
+
|
|
17
|
+
Quick start
|
|
18
|
+
-----------
|
|
19
|
+
As a library::
|
|
20
|
+
|
|
21
|
+
from gitlab_mr_commenter import MRCommenter
|
|
22
|
+
|
|
23
|
+
commenter = MRCommenter() # reads token + URL from env
|
|
24
|
+
commenter.post("## Plan\\n\\nNo changes.") # project_id/mr_iid from env
|
|
25
|
+
|
|
26
|
+
# or explicitly:
|
|
27
|
+
commenter.post(
|
|
28
|
+
content="## Plan\\n\\nNo changes.",
|
|
29
|
+
project_id=123,
|
|
30
|
+
mr_iid=45,
|
|
31
|
+
comment_id="plan-production",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
As a one-shot function::
|
|
35
|
+
|
|
36
|
+
from gitlab_mr_commenter import post_comment
|
|
37
|
+
|
|
38
|
+
post_comment("## Plan\\n\\nNo changes.") # all from env
|
|
39
|
+
post_comment("## Plan\\n\\nNo changes.", project_id=123, mr_iid=45, comment_id="plan-production")
|
|
40
|
+
|
|
41
|
+
From the command line::
|
|
42
|
+
|
|
43
|
+
echo "## Plan" | gitlab-mr-commenter plan-production
|
|
44
|
+
echo "## Plan" | gitlab-mr-commenter --project-id 123 --mr-iid 45 plan-production
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
from typing import Optional
|
|
52
|
+
|
|
53
|
+
import gitlab
|
|
54
|
+
|
|
55
|
+
__all__ = ["COMMENT_IDENTIFIER", "MRCommenter", "post_comment"]
|
|
56
|
+
|
|
57
|
+
# Hidden HTML marker embedded at the end of every managed comment.
|
|
58
|
+
# Matches the Go reference: `<!-- gitlab-mr-commenter id=%q -->` (%q → JSON string).
|
|
59
|
+
COMMENT_IDENTIFIER = "<!-- gitlab-mr-commenter id={} -->"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _marker(comment_id: str) -> str:
|
|
63
|
+
"""Return the full hidden marker for *comment_id*.
|
|
64
|
+
|
|
65
|
+
Uses ``json.dumps`` to produce a double-quoted, escaped string — the same
|
|
66
|
+
output as Go's ``%q`` verb for plain ASCII identifiers.
|
|
67
|
+
"""
|
|
68
|
+
return COMMENT_IDENTIFIER.format(json.dumps(comment_id))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MRCommenter:
|
|
72
|
+
"""Post and idempotently update comments on a GitLab merge request.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
token:
|
|
77
|
+
GitLab personal / project / CI access token. Falls back to the
|
|
78
|
+
``GITLAB_MR_PLAN_TOKEN`` or ``GITLAB_TOKEN`` environment
|
|
79
|
+
variables.
|
|
80
|
+
api_url:
|
|
81
|
+
GitLab API v4 base URL (e.g. ``https://gitlab.com/api/v4``). Falls
|
|
82
|
+
back to ``CI_API_V4_URL``.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
token: Optional[str] = None,
|
|
88
|
+
api_url: Optional[str] = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
resolved_token = (
|
|
91
|
+
token
|
|
92
|
+
or os.environ.get("GITLAB_MR_PLAN_TOKEN")
|
|
93
|
+
or os.environ.get("GITLAB_TOKEN")
|
|
94
|
+
)
|
|
95
|
+
if not resolved_token:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"No GitLab token found. Pass token= or set "
|
|
98
|
+
"GITLAB_MR_PLAN_TOKEN / GITLAB_TOKEN."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
resolved_url = api_url or os.environ.get("CI_API_V4_URL")
|
|
102
|
+
if not resolved_url:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
"No GitLab API URL found. Pass api_url= or set CI_API_V4_URL."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self._gl = gitlab.Gitlab(resolved_url, private_token=resolved_token)
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Public API
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def post(
|
|
114
|
+
self,
|
|
115
|
+
content: str,
|
|
116
|
+
project_id: Optional[int] = None,
|
|
117
|
+
mr_iid: Optional[int] = None,
|
|
118
|
+
comment_id: str = "gitlab-mr-commenter",
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Post or update a comment on a merge request.
|
|
121
|
+
|
|
122
|
+
If a note whose body contains the hidden marker for *comment_id*
|
|
123
|
+
already exists, it is updated in place. Otherwise a new note is
|
|
124
|
+
created.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
content:
|
|
129
|
+
Markdown body of the comment (without the trailing marker — that
|
|
130
|
+
is appended automatically).
|
|
131
|
+
project_id:
|
|
132
|
+
Numeric GitLab project ID. Falls back to ``$CI_PROJECT_ID``.
|
|
133
|
+
mr_iid:
|
|
134
|
+
Merge request **internal** ID — the project-scoped number shown
|
|
135
|
+
in the URL. Falls back to ``$CI_MERGE_REQUEST_IID``.
|
|
136
|
+
comment_id:
|
|
137
|
+
Opaque string that uniquely names this comment slot. Two calls
|
|
138
|
+
with the same *comment_id* on the same MR update the same note.
|
|
139
|
+
Defaults to ``"gitlab-mr-commenter"``.
|
|
140
|
+
"""
|
|
141
|
+
if project_id is None:
|
|
142
|
+
raw = os.environ.get("CI_PROJECT_ID", "")
|
|
143
|
+
if not raw:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
"project_id not provided and CI_PROJECT_ID is not set."
|
|
146
|
+
)
|
|
147
|
+
project_id = int(raw)
|
|
148
|
+
|
|
149
|
+
if mr_iid is None:
|
|
150
|
+
raw = os.environ.get("CI_MERGE_REQUEST_IID", "")
|
|
151
|
+
if not raw:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"mr_iid not provided and CI_MERGE_REQUEST_IID is not set."
|
|
154
|
+
)
|
|
155
|
+
mr_iid = int(raw)
|
|
156
|
+
|
|
157
|
+
marker = _marker(comment_id)
|
|
158
|
+
body = f"{content}\n\n{marker}"
|
|
159
|
+
|
|
160
|
+
project = self._gl.projects.get(project_id)
|
|
161
|
+
mr = project.mergerequests.get(mr_iid)
|
|
162
|
+
|
|
163
|
+
# Walk all pages lazily; stop as soon as we find a matching note.
|
|
164
|
+
for note in mr.notes.list(as_list=False):
|
|
165
|
+
if marker in note.body:
|
|
166
|
+
note.body = body
|
|
167
|
+
note.save()
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# No existing comment — create one.
|
|
171
|
+
mr.notes.create({"body": body})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def post_comment(
|
|
175
|
+
content: str,
|
|
176
|
+
project_id: Optional[int] = None,
|
|
177
|
+
mr_iid: Optional[int] = None,
|
|
178
|
+
comment_id: str = "gitlab-mr-commenter",
|
|
179
|
+
*,
|
|
180
|
+
token: Optional[str] = None,
|
|
181
|
+
api_url: Optional[str] = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Convenience wrapper around :class:`MRCommenter`.
|
|
184
|
+
|
|
185
|
+
All parameters mirror :meth:`MRCommenter.post`; *token* and *api_url*
|
|
186
|
+
fall back to the same environment variables as the constructor.
|
|
187
|
+
"""
|
|
188
|
+
MRCommenter(token=token, api_url=api_url).post(
|
|
189
|
+
content, project_id, mr_iid, comment_id
|
|
190
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""CLI entry point for gitlab-mr-commenter.
|
|
2
|
+
|
|
3
|
+
Usage
|
|
4
|
+
-----
|
|
5
|
+
Minimal — all values from CI environment variables::
|
|
6
|
+
|
|
7
|
+
echo "## Plan" | gitlab-mr-commenter
|
|
8
|
+
|
|
9
|
+
With an explicit comment slot name::
|
|
10
|
+
|
|
11
|
+
echo "## Plan" | gitlab-mr-commenter plan-production
|
|
12
|
+
|
|
13
|
+
Fully explicit::
|
|
14
|
+
|
|
15
|
+
echo "## Plan" | gitlab-mr-commenter \\
|
|
16
|
+
--project-id 123 \\
|
|
17
|
+
--mr-iid 45 \\
|
|
18
|
+
--token "$GITLAB_TOKEN" \\
|
|
19
|
+
--api-url "https://gitlab.com/api/v4" \\
|
|
20
|
+
plan-production
|
|
21
|
+
|
|
22
|
+
Also callable as a module::
|
|
23
|
+
|
|
24
|
+
echo "## Plan" | python -m gitlab_mr_commenter
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
from . import MRCommenter
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_COMMENT_ID = "gitlab-mr-commenter"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> None:
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog="gitlab-mr-commenter",
|
|
41
|
+
description=(
|
|
42
|
+
"Post or update a GitLab MR comment identified by COMMENT_ID. "
|
|
43
|
+
"Content is read from stdin. Re-running with the same COMMENT_ID "
|
|
44
|
+
"updates the existing comment in place."
|
|
45
|
+
),
|
|
46
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
47
|
+
epilog=(
|
|
48
|
+
"environment variables (used when flags are omitted):\n"
|
|
49
|
+
" CI_PROJECT_ID GitLab project ID\n"
|
|
50
|
+
" CI_MERGE_REQUEST_IID MR internal ID\n"
|
|
51
|
+
" CI_API_V4_URL GitLab API base URL\n"
|
|
52
|
+
" GITLAB_MR_PLAN_TOKEN API token (preferred)\n"
|
|
53
|
+
" GITLAB_TOKEN API token (deprecated fallback)\n"
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"comment_id",
|
|
59
|
+
metavar="COMMENT_ID",
|
|
60
|
+
nargs="?",
|
|
61
|
+
default=DEFAULT_COMMENT_ID,
|
|
62
|
+
help=(
|
|
63
|
+
f"Unique name for this comment slot (default: '{DEFAULT_COMMENT_ID}'). "
|
|
64
|
+
"A second run with the same value updates the existing comment."
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--project-id",
|
|
69
|
+
type=int,
|
|
70
|
+
default=None,
|
|
71
|
+
metavar="ID",
|
|
72
|
+
help="GitLab project ID. Defaults to $CI_PROJECT_ID.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--mr-iid",
|
|
76
|
+
type=int,
|
|
77
|
+
default=None,
|
|
78
|
+
metavar="IID",
|
|
79
|
+
help="Merge request internal ID. Defaults to $CI_MERGE_REQUEST_IID.",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--token",
|
|
83
|
+
default=None,
|
|
84
|
+
metavar="TOKEN",
|
|
85
|
+
help=(
|
|
86
|
+
"GitLab API token. Defaults to $GITLAB_MR_PLAN_TOKEN " "or $GITLAB_TOKEN."
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--api-url",
|
|
91
|
+
default=None,
|
|
92
|
+
metavar="URL",
|
|
93
|
+
help="GitLab API base URL. Defaults to $CI_API_V4_URL.",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
args = parser.parse_args(argv)
|
|
97
|
+
|
|
98
|
+
content = sys.stdin.read()
|
|
99
|
+
if not content.strip():
|
|
100
|
+
parser.error("stdin is empty — nothing to post.")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
MRCommenter(token=args.token, api_url=args.api_url).post(
|
|
104
|
+
content=content,
|
|
105
|
+
project_id=args.project_id,
|
|
106
|
+
mr_iid=args.mr_iid,
|
|
107
|
+
comment_id=args.comment_id,
|
|
108
|
+
)
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gitlab-mr-commenter"
|
|
7
|
+
version = "2.0.1"
|
|
8
|
+
description = "Post and idempotently update comments on GitLab merge requests"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"python-gitlab>=8.0",
|
|
12
|
+
]
|
|
13
|
+
authors = [
|
|
14
|
+
{name = "Gian Klug", email = "gk@gk.wtf"}
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
gitlab-mr-commenter = "gitlab_mr_commenter.__main__:main"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["gitlab_mr_commenter"]
|
|
22
|
+
|
|
23
|
+
[tool.semantic_release]
|
|
24
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
25
|
+
branch = "main"
|
|
26
|
+
changelog_file = "CHANGELOG.md"
|
|
27
|
+
build_command = "uv build"
|
|
28
|
+
tag_format = "v{version}"
|
|
29
|
+
|
|
30
|
+
[tool.semantic_release.commit_parser_options]
|
|
31
|
+
# Conventional commits: feat → minor, fix/perf → patch, BREAKING CHANGE → major
|
|
32
|
+
allowed_tags = ["feat", "fix", "perf", "refactor", "docs", "chore", "ci"]
|
|
33
|
+
minor_tags = ["feat"]
|
|
34
|
+
patch_tags = ["fix", "perf"]
|