Skip to content

Commit e1fa00d

Browse files
committed
feat: add support for GitLab merge-request refs
- Treat GitLab merge-request refs as pull-requests/<iid> (GitPreparer, ReferenceName, repository) - GitLab CI agent: env var constants aligned with project conventions - Tests (GitLabCi, build agent) and documentation
1 parent 7dd97ac commit e1fa00d

File tree

10 files changed

+139
-24
lines changed

10 files changed

+139
-24
lines changed

docs/input/docs/reference/build-servers/gitlab.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ To use GitVersion with GitLab CI, either use the [MSBuild
99
Task](/docs/usage/msbuild) or put the GitVersion executable in your
1010
runner's `PATH`.
1111

12+
### Merge Request pipelines
13+
14+
GitVersion supports GitLab Merge Request refs natively. In MR pipelines, GitLab sets `CI_MERGE_REQUEST_REF_PATH` (e.g. `refs/merge-requests/15/head` or `refs/merge-requests/15/merge`). GitVersion uses this variable when present and treats the ref as a pull-request branch, exposing it as `pull-requests/<iid>` so that your `pull-request` configuration in GitVersion.yml applies without any CI workarounds (no need to create synthetic refs under `refs/heads/`). The branch name matches the default regex `^(pull-requests|pull|pr)[\/-](?<Number>\d*)`.
15+
1216
A working example of integrating GitVersion with GitLab is maintained in the project [Utterly Automated Versioning][utterly-automated-versioning]
1317

1418
Here is a summary of what it demonstrated (many more details in the [Readme][readme])

docs/input/docs/usage/docker.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ On GitHub Actions, you may need to set the following environment variables:
3434
docker run --rm -v "$(pwd):/repo" --env GITHUB_ACTIONS=true --env GITHUB_REF=$(GITHUB_REF) gittools/gitversion:{tag} /repo
3535
```
3636

37+
On GitLab CI (including Merge Request pipelines), pass the GitLab variables so GitVersion can detect the branch or MR ref:
38+
39+
```sh
40+
docker run --rm -v "$(pwd):/repo" --env GITLAB_CI=true --env CI_MERGE_REQUEST_REF_PATH=$CI_MERGE_REQUEST_REF_PATH --env CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME --env CI_COMMIT_TAG=$CI_COMMIT_TAG gittools/gitversion:{tag} /repo
41+
```
42+
3743
### Tags
3844

3945
Most of the tags we provide have both arm64 and amd64 variants. If you need to pull a architecture specific tag you can do that like:

src/GitVersion.App.Tests/PullRequestInBuildAgentTest.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ private static async Task VerifyPullRequestVersionIsCalculatedProperly(string pu
191191
new object[] { "refs/pull-requests/5/merge", "refs/pull-requests/5/merge", false, true, false },
192192
new object[] { "refs/pull/5/merge", "refs/pull/5/merge", false, true, false },
193193
new object[] { "refs/heads/pull/5/head", "pull/5/head", true, false, false },
194-
new object[] { "refs/remotes/pull/5/merge", "pull/5/merge", false, true, true }
194+
new object[] { "refs/remotes/pull/5/merge", "pull/5/merge", false, true, true },
195+
new object[] { "refs/merge-requests/15/head", "pull-requests/15", false, true, false },
196+
new object[] { "refs/merge-requests/15/merge", "pull-requests/15", false, true, false }
195197
];
196198

197199
[TestCaseSource(nameof(PrMergeRefInputs))]

src/GitVersion.BuildAgents.Tests/Agents/GitLabCiTests.cs

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ public void SetUp()
2626
}
2727

2828
[TearDown]
29-
public void TearDown() => this.environment.SetEnvironmentVariable(GitLabCi.EnvironmentVariableName, null);
29+
public void TearDown()
30+
{
31+
this.environment.SetEnvironmentVariable(GitLabCi.EnvironmentVariableName, null);
32+
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, null);
33+
this.environment.SetEnvironmentVariable(GitLabCi.CommitTagEnvironmentVariableName, null);
34+
this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, null);
35+
}
3036

3137
[Test]
3238
public void ShouldSetBuildNumber()
@@ -50,7 +56,7 @@ public void ShouldSetOutputVariables()
5056
[TestCase("#3-change_projectname", "#3-change_projectname")]
5157
public void GetCurrentBranchShouldHandleBranches(string branchName, string expectedResult)
5258
{
53-
this.environment.SetEnvironmentVariable("CI_COMMIT_REF_NAME", branchName);
59+
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, branchName);
5460

5561
var result = this.buildServer.GetCurrentBranch(false);
5662

@@ -63,8 +69,8 @@ public void GetCurrentBranchShouldHandleBranches(string branchName, string expec
6369
[TestCase("v1.2.1", "v1.2.1", null)]
6470
public void GetCurrentBranchShouldHandleTags(string branchName, string commitTag, string? expectedResult)
6571
{
66-
this.environment.SetEnvironmentVariable("CI_COMMIT_REF_NAME", branchName);
67-
this.environment.SetEnvironmentVariable("CI_COMMIT_TAG", commitTag); // only set in pipelines for tags
72+
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, branchName);
73+
this.environment.SetEnvironmentVariable(GitLabCi.CommitTagEnvironmentVariableName, commitTag); // only set in pipelines for tags
6874

6975
var result = this.buildServer.GetCurrentBranch(false);
7076

@@ -85,13 +91,49 @@ public void GetCurrentBranchShouldHandleTags(string branchName, string commitTag
8591
[TestCase("#3-change_projectname", "#3-change_projectname")]
8692
public void GetCurrentBranchShouldHandlePullRequests(string branchName, string expectedResult)
8793
{
88-
this.environment.SetEnvironmentVariable("CI_COMMIT_REF_NAME", branchName);
94+
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, branchName);
8995

9096
var result = this.buildServer.GetCurrentBranch(false);
9197

9298
result.ShouldBe(expectedResult);
9399
}
94100

101+
[TestCase("refs/merge-requests/15/head")]
102+
[TestCase("refs/merge-requests/15/merge")]
103+
[TestCase("refs/merge-requests/1/head")]
104+
public void GetCurrentBranch_WhenMergeRequestRefPathSet_ReturnsMergeRequestRefPath(string mrRefPath)
105+
{
106+
this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, mrRefPath);
107+
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, "some-branch");
108+
109+
var result = this.buildServer.GetCurrentBranch(false);
110+
111+
result.ShouldBe(mrRefPath);
112+
}
113+
114+
[Test]
115+
public void GetCurrentBranch_WhenMergeRequestRefPathAndCommitRefNameSet_PrefersMergeRequestRefPath()
116+
{
117+
this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, "refs/merge-requests/42/head");
118+
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, "feature/foo");
119+
120+
var result = this.buildServer.GetCurrentBranch(false);
121+
122+
result.ShouldBe("refs/merge-requests/42/head");
123+
}
124+
125+
[Test]
126+
public void GetCurrentBranch_WhenTagSet_ReturnsNull()
127+
{
128+
this.environment.SetEnvironmentVariable(GitLabCi.CommitTagEnvironmentVariableName, "v1.0.0");
129+
this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, "refs/merge-requests/10/head");
130+
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, "main");
131+
132+
var result = this.buildServer.GetCurrentBranch(false);
133+
134+
result.ShouldBeNull();
135+
}
136+
95137
[Test]
96138
public void WriteAllVariablesToTheTextWriter()
97139
{

src/GitVersion.BuildAgents/Agents/GitLabCi.cs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ namespace GitVersion.Agents;
77
internal class GitLabCi : BuildAgentBase
88
{
99
public const string EnvironmentVariableName = "GITLAB_CI";
10+
public const string CommitRefNameEnvironmentVariableName = "CI_COMMIT_REF_NAME";
11+
public const string CommitTagEnvironmentVariableName = "CI_COMMIT_TAG";
12+
public const string MergeRequestRefPathEnvironmentVariableName = "CI_MERGE_REQUEST_REF_PATH";
13+
1014
private string? file;
1115

1216
public GitLabCi(IEnvironment environment, ILog log, IFileSystem fileSystem) : base(environment, log, fileSystem) => WithPropertyFile("gitversion.properties");
@@ -22,14 +26,16 @@ public override string[] SetOutputVariables(string name, string? value) =>
2226
$"GitVersion_{name}={value}"
2327
];
2428

25-
// CI_COMMIT_REF_NAME can contain either the branch or the tag
26-
// See https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
27-
// CI_COMMIT_TAG is only available in tag pipelines,
28-
// so we can exit if CI_COMMIT_REF_NAME would return the tag
29-
public override string? GetCurrentBranch(bool usingDynamicRepos) =>
30-
string.IsNullOrEmpty(this.Environment.GetEnvironmentVariable("CI_COMMIT_TAG"))
31-
? this.Environment.GetEnvironmentVariable("CI_COMMIT_REF_NAME")
32-
: null;
29+
// CI_COMMIT_REF_NAME = branch/tag name. In MR pipelines, CI_MERGE_REQUEST_REF_PATH = refs/merge-requests/<iid>/head.
30+
public override string? GetCurrentBranch(bool usingDynamicRepos)
31+
{
32+
if (!string.IsNullOrEmpty(this.Environment.GetEnvironmentVariable(CommitTagEnvironmentVariableName)))
33+
return null;
34+
var mrRef = this.Environment.GetEnvironmentVariable(MergeRequestRefPathEnvironmentVariableName);
35+
if (!string.IsNullOrEmpty(mrRef))
36+
return mrRef;
37+
return this.Environment.GetEnvironmentVariable(CommitRefNameEnvironmentVariableName);
38+
}
3339

3440
public override bool PreventFetch() => true;
3541

src/GitVersion.Core/Core/GitPreparer.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,12 @@ private void EnsureHeadIsAttachedToBranch(string? currentBranchName, Authenticat
234234
var localBranchesWhereCommitShaIsHead = this.repository.Branches.Where(b => !b.IsRemote && b.Tip?.Sha == headSha).ToList();
235235

236236
var matchingCurrentBranch = !currentBranchName.IsNullOrEmpty()
237-
? localBranchesWhereCommitShaIsHead.SingleOrDefault(b => b.Name.Canonical.Replace("/heads/", "/") == currentBranchName.Replace("/heads/", "/"))
237+
? localBranchesWhereCommitShaIsHead.SingleOrDefault(b =>
238+
{
239+
if (ReferenceName.TryParseMergeRequestsRef(currentBranchName, out var mergeRequestId))
240+
return b.Name.Canonical == ReferenceName.LocalBranchPrefix + ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId);
241+
return b.Name.Canonical.Replace("/heads/", "/") == currentBranchName.Replace("/heads/", "/");
242+
})
238243
: null;
239244
if (matchingCurrentBranch != null)
240245
{
@@ -379,11 +384,15 @@ public void EnsureLocalBranchExistsForCurrentBranch(IRemote remote, string? curr
379384

380385
const string referencePrefix = "refs/";
381386
var isLocalBranch = currentBranch.StartsWith(ReferenceName.LocalBranchPrefix);
382-
var localCanonicalName = !currentBranch.StartsWith(referencePrefix)
383-
? ReferenceName.LocalBranchPrefix + currentBranch
384-
: isLocalBranch
385-
? currentBranch
386-
: ReferenceName.LocalBranchPrefix + currentBranch[referencePrefix.Length..];
387+
string localCanonicalName;
388+
if (ReferenceName.TryParseMergeRequestsRef(currentBranch, out var mergeRequestId))
389+
localCanonicalName = ReferenceName.LocalBranchPrefix + ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId);
390+
else
391+
localCanonicalName = !currentBranch.StartsWith(referencePrefix)
392+
? ReferenceName.LocalBranchPrefix + currentBranch
393+
: isLocalBranch
394+
? currentBranch
395+
: ReferenceName.LocalBranchPrefix + currentBranch[referencePrefix.Length..];
387396

388397
var repoTip = this.repository.Head.Tip;
389398

src/GitVersion.Core/Core/RepositoryStore.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,13 @@ public IBranch GetTargetBranch(string? targetBranchName)
7373
if (targetBranchName.IsNullOrEmpty())
7474
return desiredBranch;
7575

76-
// There are some edge cases where HEAD is not pointing to the desired branch.
77-
// Therefore, it's important to verify if 'currentBranch' is indeed the desired branch.
76+
if (ReferenceName.TryParseMergeRequestsRef(targetBranchName, out var mergeRequestId))
77+
{
78+
var prBranch = FindBranch(ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId));
79+
if (prBranch != null)
80+
return prBranch;
81+
}
82+
7883
var targetBranch = FindBranch(targetBranchName);
7984

8085
// CanonicalName can be "refs/heads/develop", so we need to check for "/{TargetBranch}" as well

src/GitVersion.Core/Git/ReferenceName.cs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,18 @@ public class ReferenceName : IEquatable<ReferenceName?>, IComparable<ReferenceNa
1818
[
1919
"refs/pull/",
2020
"refs/pull-requests/",
21+
"refs/merge-requests/",
2122
"refs/remotes/pull/",
2223
"refs/remotes/pull-requests/"
2324
];
2425

26+
/// <summary>
27+
/// The sole <see cref="PullRequestPrefixes" /> entry for <c>refs/merge-requests/&lt;id&gt;/head|merge</c>.
28+
/// Adding another prefix that also contains <c>/merge-requests/</c> will fail at type initialization.
29+
/// </summary>
30+
private static readonly string mergeRequestsRefPrefix = PullRequestPrefixes.Single(
31+
p => p.Contains("/merge-requests/", StringComparison.Ordinal));
32+
2533
public ReferenceName(string canonical)
2634
{
2735
Canonical = canonical.NotNull();
@@ -84,6 +92,29 @@ public bool EquivalentTo(string? name) =>
8492
|| Friendly.Equals(name, StringComparison.OrdinalIgnoreCase)
8593
|| WithoutOrigin.Equals(name, StringComparison.OrdinalIgnoreCase);
8694

95+
/// <summary>
96+
/// Parses canonical refs under refs/merge-requests/&lt;id&gt;/head or /merge (convention used by some Git hosts) and extracts the merge-request id.
97+
/// </summary>
98+
public static bool TryParseMergeRequestsRef(string? canonicalRef, out int mergeRequestId)
99+
{
100+
mergeRequestId = 0;
101+
if (string.IsNullOrEmpty(canonicalRef) || !canonicalRef.StartsWith(mergeRequestsRefPrefix, StringComparison.Ordinal))
102+
return false;
103+
var after = canonicalRef.Substring(mergeRequestsRefPrefix.Length);
104+
var slash = after.IndexOf('/');
105+
if (slash <= 0 || slash >= after.Length - 1) return false;
106+
var suffix = after[(slash + 1)..];
107+
if (!suffix.Equals("head", StringComparison.OrdinalIgnoreCase) && !suffix.Equals("merge", StringComparison.OrdinalIgnoreCase))
108+
return false;
109+
return int.TryParse(after.Substring(0, slash), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out mergeRequestId)
110+
&& mergeRequestId > 0;
111+
}
112+
113+
/// <summary>
114+
/// Returns the branch-style name pull-requests/&lt;id&gt; for default pull-request configuration matching.
115+
/// </summary>
116+
public static string MergeRequestsRefFriendlyName(int mergeRequestId) => $"pull-requests/{mergeRequestId}";
117+
87118
private string Shorten()
88119
{
89120
if (IsLocalBranch)
@@ -92,7 +123,13 @@ private string Shorten()
92123
if (IsRemoteBranch)
93124
return Canonical[RemoteTrackingBranchPrefix.Length..];
94125

95-
return IsTag ? Canonical[TagPrefix.Length..] : Canonical;
126+
if (IsTag)
127+
return Canonical[TagPrefix.Length..];
128+
129+
if (TryParseMergeRequestsRef(Canonical, out var mergeRequestId))
130+
return MergeRequestsRefFriendlyName(mergeRequestId);
131+
132+
return Canonical;
96133
}
97134

98135
private string RemoveOrigin()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
#nullable enable
2+
static GitVersion.Git.ReferenceName.MergeRequestsRefFriendlyName(int mergeRequestId) -> string!
3+
static GitVersion.Git.ReferenceName.TryParseMergeRequestsRef(string? canonicalRef, out int mergeRequestId) -> bool

src/GitVersion.LibGit2Sharp/Git/GitRepository.mutating.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ public void CreateBranchForPullRequestBranch(AuthenticationInfo auth) => Reposit
7070
}
7171
else if (referenceName.IsPullRequest)
7272
{
73-
var fakeBranchName = canonicalName.Replace("refs/pull/", "refs/heads/pull/").Replace("refs/pull-requests/", "refs/heads/pull-requests/");
73+
var fakeBranchName = ReferenceName.TryParseMergeRequestsRef(canonicalName, out var mergeRequestId)
74+
? $"{ReferenceName.LocalBranchPrefix}{ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId)}"
75+
: canonicalName.Replace("refs/pull/", "refs/heads/pull/").Replace("refs/pull-requests/", "refs/heads/pull-requests/");
7476

7577
this.log.Info($"Creating fake local branch '{fakeBranchName}'.");
7678
References.Add(fakeBranchName, headTipSha);

0 commit comments

Comments
 (0)