Automatically create Merge Requests in GitLab using a lightweight Go binary.
- ✅ Zero external dependencies - uses only Go standard library
- ✅ Small Docker image (Alpine-based)
- ✅ Fast execution
- ✅ Compatible with GitLab CI/CD
- ✅ Cross-platform support (Linux, macOS, Windows)
- ✅ Safe MR management - explicit control over creating or updating MRs
- ✅ Prevents accidental overwrites - requires
--update-mrflag to update existing MRs
Docker images are automatically built and published to GitHub Container Registry for each release.
latest- Latest stable releasevX.Y.Z- Specific versionvX.Y- Latest patch version of a minor releasevX- Latest minor and patch version of a major release (e.g.,v1)
The image runs as uid 10001, not root. The tool itself only needs outbound
HTTPS, but anything else in the same job that writes to the workspace must be
able to do so as that user.
# Latest release
docker pull ghcr.io/batonogov/gitlab-auto-mr:latest
# Specific version
docker pull ghcr.io/batonogov/gitlab-auto-mr:vX.Y.Zlinux/amd64linux/arm64
- Set required environment variables:
export GITLAB_PRIVATE_TOKEN="your-gitlab-token"
export GITLAB_USER_ID="your-user-id"
export CI_PROJECT_ID="12345"
export CI_PROJECT_URL="https://gitlab.com/user/project"
export CI_COMMIT_REF_NAME="feature/my-branch"- Run with Docker (recommended):
# Creates new MR or informs if MR already exists
docker run --rm \
-e GITLAB_PRIVATE_TOKEN \
-e GITLAB_USER_ID \
-e CI_PROJECT_ID \
-e CI_PROJECT_URL \
-e CI_COMMIT_REF_NAME \
ghcr.io/batonogov/gitlab-auto-mr:latest \
gitlab_auto_mr --target-branch main --commit-prefix "Ready"- Or build and run locally:
git clone https://github.com/user/gitlab-auto-mr.git
cd gitlab-auto-mr
task build
# Creates new MR or informs if MR already exists
./gitlab_auto_mr --target-branch main --commit-prefix "Ready"create_mr:
stage: create-mr
image: ghcr.io/batonogov/gitlab-auto-mr:latest
script:
- |
# Creates new MR or informs if MR already exists
gitlab_auto_mr \
--target-branch main \
--commit-prefix "Ready" \
--remove-branch \
--squash-commits
rules:
- if: $CI_COMMIT_BRANCH != "main" && $CI_PIPELINE_SOURCE != "merge_request_event"Every run that identifies an MR prints its browser URL, so it can be clicked straight out of the job log:
Created a new MR Draft: feature/new-thing, assigned to you.
MR URL: https://gitlab.example.com/group/project/-/merge_requests/42
update_mr:
stage: update-mr
image: ghcr.io/batonogov/gitlab-auto-mr:latest
script:
- |
# Updates existing MR (requires --update-mr flag)
gitlab_auto_mr \
--update-mr \
--target-branch main \
--commit-prefix "Ready" \
--description .gitlab/merge_request_template.md \
--use-issue-name \
--reviewer-id "${REVIEWER_IDS}"
rules:
- if: $CI_COMMIT_BRANCH != "main" && $CI_PIPELINE_SOURCE != "merge_request_event"# Force update only (fail if MR doesn't exist)
update_only:
script:
- gitlab_auto_mr --update-mr --target-branch main
# Force create only (fail if MR already exists)
create_only:
script:
- gitlab_auto_mr --create-only --target-branch mainGITLAB_PRIVATE_TOKEN- GitLab personal access token withapiscopeGITLAB_USER_ID- Your GitLab user ID (comma-separated for multiple assignees)CI_PROJECT_ID- GitLab project IDCI_PROJECT_URL- GitLab URLCI_COMMIT_REF_NAME- Source branch name
GITLAB_AUTO_MR_TARGET_BRANCH- Target branch for the MR. Overridden by--target-branch/-t; when neither is set, the project's default branch is used.GITLAB_AUTO_MR_LABELS- Labels for the MR (comma-separated). Overridden by--label.GITLAB_AUTO_MR_MILESTONE- Milestone ID for the MR. Overridden by--milestone.GITLAB_AUTO_MR_CA_CERT- Path to a PEM CA certificate to trust in addition to the system poolGITLAB_AUTO_MR_TIMEOUT- Timeout for a single API request (default30s)GITLAB_AUTO_MR_RETRIES- Retries for transient failures (default2)GITLAB_AUTO_MR_RETRY_DELAY- Delay before the first retry (default1s)
| Option | Short | Description | Default |
|---|---|---|---|
--target-branch |
-t |
Target branch for MR (GITLAB_AUTO_MR_TARGET_BRANCH) |
Project default branch |
--commit-prefix |
-c |
MR title prefix | Draft |
--title |
Custom MR title | Source branch name | |
--description |
-d |
Path to description file | - |
--remove-branch |
-r |
Delete source branch after merge | false |
--squash-commits |
-s |
Squash commits on merge | false |
--label |
Labels for the MR, comma-separated (GITLAB_AUTO_MR_LABELS) |
- | |
--milestone |
Milestone ID for the MR (GITLAB_AUTO_MR_MILESTONE) |
- | |
--draft |
Mark the MR as a draft | false |
|
--ready |
Mark the MR ready by removing a draft prefix | false |
|
--use-issue-name |
-i |
Use issue data from branch name | false |
--allow-collaboration |
-a |
Allow commits from merge target members | false |
--reviewer-id |
Reviewer user ID(s) (comma-separated) | - | |
--mr-exists |
Only check if MR exists (dry run) | false |
|
--update-mr |
Update existing MR (required to update, fail if none exists) | false |
|
--create-only |
Force create new MR (fail if already exists) | false |
|
--auto-merge |
Enable merge when pipeline succeeds (auto-merge) | false |
|
--trigger-pipeline |
Create a merge request pipeline for the created or updated MR | false |
|
--force-pipeline |
With --trigger-pipeline, create one even if the commit has one |
false |
|
--trigger-pipeline |
Create a merge request pipeline after the MR is created | false |
|
--timeout |
Timeout for a single API request | 30s |
|
--retries |
Retries for transient failures (5xx, 429, network) | 2 |
|
--retry-delay |
Delay before the first retry, doubled each time | 1s |
|
--ca-cert |
PEM CA certificate to trust (GITLAB_AUTO_MR_CA_CERT) |
- | |
--insecure |
-k |
Skip SSL certificate verification | false |
GitLab does not start a merge request pipeline on its own when the MR is created
through the API for a commit that already has a branch pipeline. If your CI
configuration runs jobs only on merge_request_event, those jobs never run for
the newly created MR — and the branch pipeline that did run may look green while
having executed none of them.
--trigger-pipeline asks GitLab for that pipeline explicitly, for the MR this
run created or updated — a branch that moved is exactly when the checks are
worth re-running:
open_merge_request:
image: $GITLAB_AUTO_MR_IMAGE
script:
- gitlab_auto_mr --target-branch main --trigger-pipelineThe flag acts at most once per commit. Before creating a pipeline the tool lists the MR's existing ones and skips when it finds one for the same head commit, reporting the skip so it is visible in the job log:
Merge request pipeline already exists for commit abc123de (ID: 7, status: running): https://gitlab.example.com/p/-/pipelines/7
Skipping pipeline creation; pass --force-pipeline to create another.
That is what keeps a retried job, a manual re-run, or a later run against the
same MR from stacking up pipelines for one commit. Use --force-pipeline when
re-running the checks is exactly the point — including when the existing
pipeline for that commit failed, since the check looks at the commit, not at the
result.
Only pipelines GitLab reports as merge request pipelines count. The branch pipeline running this job is attached to the same MR and the same commit, and treating it as a match would mean never creating the merge request pipeline at all. If the pipeline list cannot be read, the tool creates one anyway: a duplicate is a better outcome than checks that silently did not run. The same applies to projects using merged results pipelines, where the pipeline's commit is a simulated merge and never equals the branch head.
Two things to expect:
- On the push that creates the MR you will have two pipelines for the same
commit — the branch pipeline that ran this job, and the merge request
pipeline created here. A
workflowrule such as$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS == nullkeeps later pushes to the same branch down to one. - The token needs the
apiscope, and its owner needs at least the Developer role on the project. ACI_JOB_TOKENcannot be used: the Merge Requests API is read-only for job tokens, so it can neither create the MR nor request a pipeline for it.
The tool acts on behalf of whoever owns GITLAB_PRIVATE_TOKEN. That dependency is
easy to lose track of: merge requests start appearing "by themselves", and months
later nobody remembers that behind them stands a live account holding an
api-scoped token.
- Use a service account, not a person. A personal token disappears when that person leaves the company or rotates their credentials, and every MR the tool opens is attributed to them.
- Give the account Developer on the project — no more. That is enough to read the project and to create, update and merge merge requests.
CI_JOB_TOKENcannot be substituted. The Merge Requests API is read-only for job tokens, so a personal or project access token with theapiscope is required.- Plan for rotation. GitLab no longer issues non-expiring personal access tokens, so expiry is scheduled work. When the token expires, MR creation simply stops — and "no new MRs" is a symptom that is easy to miss for weeks.
- Name the account so its purpose is obvious (for example
svc-gitlab-auto-mr). Whoever finds it during a directory cleanup will decide its fate based on the name alone; an account calledtest1gets deleted.
gitlab-auto-mr --draft # open (or keep) the MR as a draft
gitlab-auto-mr --ready --update-mr # take an existing MR out of draftGitLab has no writable draft field: the draft flag in API responses is
derived from the title, and the Merge Requests API accepts no draft parameter.
Draft state is set by the title prefix — Draft:, [Draft] or (Draft) — which
is what these two flags manage, so the caller does not have to know the
convention.
--draftputsDraft:at the front of the title, without doubling one that is already there. A non-draft--commit-prefixis kept after the marker, so--draft --commit-prefix FeaturegivesDraft: Feature: my-branch.--readyremoves a leadingDraft:/WIP:marker in any of GitLab's accepted spellings, and overrides theDraftdefault of--commit-prefixso the marker is not simply added back.- On an existing MR,
--readystrips the marker from the MR's own title rather than rebuilding the title from the branch name — marking a draft ready should not also rename a title someone wrote by hand. Pass--titleto rename deliberately.
--draft and --ready are refused together, and --draft is refused with
--auto-merge for the same reason a Draft commit prefix already was: GitLab
does not auto-merge drafts. --ready is what makes the default prefix compatible
with --auto-merge.
--commit-prefix keeps working as before and is not deprecated; these flags are
for when you want the state rather than a particular title.
If your instance presents a certificate from an internal CA, point the tool at that CA rather than turning verification off:
gitlab-auto-mr --ca-cert /etc/ssl/certs/internal-ca.pemThe certificate is added to the system trust store rather than replacing it, so
other hosts keep working. --insecure/-k remains available for throwaway
cases, but the two are mutually exclusive: disabling verification would make the
CA pointless, and this tool carries an api-scoped token.
Self-hosted GitLab returns 502/503 during restarts and upgrades, and CI runners
hit transient network errors. By default the tool retries such a failure twice,
waiting 1s and then 2s, so a job does not fail for a reason that a re-run would
have fixed anyway. --retries 0 turns it off.
What gets retried depends on what a repeat could do:
| Request | Retried on 5xx / 429 | Retried on a network error |
|---|---|---|
GET, PUT |
yes | yes |
POST |
no | only if the connection was never established |
POST /merge_requests is the reason for the asymmetry: retrying it after a
timeout could open a second merge request. A failure to dial is safe to repeat,
because the request demonstrably never reached GitLab.
A request that outran --timeout counts as a network error here, so a hung GET
is retried. Ctrl-C is not: once the caller has given up, nothing is retried.
A Retry-After header — which GitLab sends when rate limiting — takes
precedence over the backoff, capped at 30s. 4xx answers other than 429 are never
retried: they are real answers, and repeating them only delays the error.
- Go (see
go.modfor minimum version) - Task (optional, for build automation)
# Using Task (recommended)
task build # Build binary
task test # Run tests
task docker-build # Build Docker image
task clean # Clean artifacts
task --list # Show all tasks
# Or using Go directly
go build -o gitlab_auto_mr .
go test ./...This project uses pre-commit hooks for code quality and consistency:
# Install pre-commit (if not already installed)
pip install pre-commit
# Install hooks
pre-commit install
# Run hooks on all files
pre-commit run --all-files
# Run hooks on specific files
pre-commit run --files main.goIncluded hooks:
- Go dependency verification
- Go code formatting
- Go linting
- Go tests
- Taskfile validation
- Dockerfile validation
- YAML validation
- Markdown formatting
- Large file detection
- Merge conflict detection
task docker-build
# or
docker build -t gitlab-auto-mr .# Creates new MR or informs if MR already exists
./gitlab_auto_mr \
--target-branch main \
--commit-prefix "Ready"./gitlab_auto_mr \
--target-branch main \
--reviewer-id "12345,67890"gitlab-auto-mr --label "bug,priority::high" --milestone 5Both combine with --use-issue-name rather than replacing it: labels are the
union of the two sources, and an explicit --milestone wins over the issue's.
./gitlab_auto_mr \
--mr-exists \
--target-branch main# Update existing MR (requires --update-mr flag, fail if MR doesn't exist)
./gitlab_auto_mr \
--update-mr \
--target-branch main \
--title "Updated Title" \
--description new_description.md# Only create new MR (fail if MR already exists)
./gitlab_auto_mr \
--create-only \
--target-branch main \
--reviewer-id "12345,67890"Authentication Error
Error: unable to get project 12345: unauthorized access, check your access token is valid and has the api scope
- Check your
GITLAB_PRIVATE_TOKENis valid and hasapiscope - An expired or revoked token gives the same message — see Operating for who the token should belong to and why rotation is scheduled work
MR Already Exists
Merge request already exists: Feature XYZ (IID: 42). Use --update-mr flag to update it.
MR URL: https://gitlab.example.com/group/project/-/merge_requests/42
- This is the default behavior when MR already exists
- Add
--update-mrflag to update the existing MR instead
Update Mode Error
Error: merge request does not exist for this branch feature/test to main, cannot update non-existent MR
- This happens when using
--update-mrflag but no MR exists - Remove
--update-mrflag to create a new MR instead
Force Create Mode Error
Error: merge request already exists for this branch feature/test to main, cannot create new MR in create-only mode
- This happens when using
--create-onlyflag but MR already exists - Remove
--create-onlyflag to allow the tool to inform about existing MR - Use
--update-mrflag instead if you want to update the existing MR
Same Source/Target Branch
Error: source branch and target branches must be different
- Ensure you're not running on the target branch
MIT License - see LICENSE file for details.