Repository: sharpliner/sharpliner
Pull request #446: Elseif condition
Author: login: jr-krandino
Created: 2025-05-02T16:04:51Z | Merged: 2025-05-08T16:15:31Z

While working with Sharpliner I noticed there was no way to invoke an ${{ elseif }} call.  This update adds .ElseIf as a Conditioned object, which will allow for elseifs like so:

```
If.Equal("A", "B")
  .Pool(new HostedPool("pool-A")
  {
      Demands = { "SomeProperty -equals SomeValue" }
  })
  .ElseIf.Equal("C", "D")
  .Pool(new HostedPool("pool-B"))
```

outputs:
```
${{ if eq('A', 'B') }}:
  name: pool-A
  demands:
  - SomeProperty -equals SomeValue
${{ elseif eq('C', 'D') }}:
  name: pool-B
```

I've updated a test that was meant to do ElseIf statements to utilize this.


Diff:
diff --git a/src/Sharpliner/AzureDevOps/AzureDevOpsDefinition.cs b/src/Sharpliner/AzureDevOps/AzureDevOpsDefinition.cs
index 88f07697..c9c447d2 100644
--- a/src/Sharpliner/AzureDevOps/AzureDevOpsDefinition.cs
+++ b/src/Sharpliner/AzureDevOps/AzureDevOpsDefinition.cs
@@ -537,7 +537,7 @@ protected static StageListParameter StageListParameter(string name, string? disp
     ///   - group: prod
     /// </code>
     /// </summary>
-    protected static IfConditionBuilder If => new();
+    protected static IfConditionBuilder If => new(null, false);
 
     /// <summary>
     /// Start an <c>${{ else () }}</c> section.
diff --git a/src/Sharpliner/AzureDevOps/ConditionedExpressions/Condition.cs b/src/Sharpliner/AzureDevOps/ConditionedExpressions/Condition.cs
index d3d5032b..028f5c3e 100644
--- a/src/Sharpliner/AzureDevOps/ConditionedExpressions/Condition.cs
+++ b/src/Sharpliner/AzureDevOps/ConditionedExpressions/Condition.cs
@@ -25,6 +25,7 @@ public abstract class Condition : IYamlConvertible
     internal const string ExpressionEnd = " }}";
 
     internal const string IfTagStart = $"{ExpressionStart}if ";
+    internal const string ElseIfTagStart = $"{ExpressionStart}elseif ";
     internal const string ElseTagStart = $"{ExpressionStart}else";
 
     private const string VariableIndexAccessStart = "variables[";
diff --git a/src/Sharpliner/AzureDevOps/ConditionedExpressions/Conditioned.cs b/src/Sharpliner/AzureDevOps/ConditionedExpressions/Conditioned.cs
index 718b05cd..eadb8340 100644
--- a/src/Sharpliner/AzureDevOps/ConditionedExpressions/Conditioned.cs
+++ b/src/Sharpliner/AzureDevOps/ConditionedExpressions/Conditioned.cs
@@ -250,6 +250,43 @@ protected Conditioned() : base((IfCondition?)null)
     /// </summary>
     public IfConditionBuilder<T> If => new(this);
 
+    /// <summary>
+    /// Starts a new <c>${{ elseif (...) }}</c> section.
+    /// For example:
+    /// <code lang="csharp">
+    /// If.IsBranch("dev")
+    ///     .Group("Development")
+    /// ElseIf.IsBranch("prod")
+    ///     .Group("Production")
+    /// </code>
+    /// will generate:
+    /// <code lang="yaml">
+    /// - ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/dev') }}:
+    ///   - group: Development
+    /// - ${{ elseif eq(variables['Build.SourceBranch'], 'refs/heads/prod') }}:
+    ///   - group: Production
+    /// </code>
+    /// </summary>
+    public IfConditionBuilder ElseIf
+    {
+        get
+        {
+            // If we're top-level, we create a fake new top with empty definition to collect all the definitions
+            if (Parent == null)
+            {
+                Parent = new Conditioned<T>();
+                Parent.Definitions.Add(this);
+            }
+
+            if (Condition == null)
+            {
+                throw new InvalidOperationException("No condition to match ElseIf against");
+            }
+
+            return new IfConditionBuilder(Parent, true);
+        }
+    }
+
     /// <summary>
     /// Ends a <c>${{ if (...) }}</c> section.
     /// For example:
diff --git a/src/Sharpliner/AzureDevOps/ConditionedExpressions/IfConditionBuilder.cs b/src/Sharpliner/AzureDevOps/ConditionedExpressions/IfConditionBuilder.cs
index 14f98542..20c26e1c 100644
--- a/src/Sharpliner/AzureDevOps/ConditionedExpressions/IfConditionBuilder.cs
+++ b/src/Sharpliner/AzureDevOps/ConditionedExpressions/IfConditionBuilder.cs
@@ -9,11 +9,15 @@ namespace Sharpliner.AzureDevOps.ConditionedExpressions;
 /// </summary>
 public class IfConditionBuilder
 {
+    internal bool IsElseIf { get; } = false;
     internal Conditioned? Parent { get; }
 
-    internal IfConditionBuilder(Conditioned? parent = null)
+    internal IfConditionBuilder(Conditioned? parent = null) : this(parent, false) { }
+
+    internal IfConditionBuilder(Conditioned? parent = null, bool isElseIf = false)
     {
         Parent = parent;
+        IsElseIf = isElseIf;
     }
 
     /// <summary>
@@ -423,6 +427,7 @@ public IfCondition IsNotPullRequest
     private IfCondition Link(IfCondition condition)
     {
         condition.Parent = Parent;
+        condition.IsElseIf = IsElseIf;
         return condition;
     }
 }
@@ -433,11 +438,15 @@ private IfCondition Link(IfCondition condition)
 /// </summary>
 public class IfConditionBuilder<T>
 {
+    internal bool IsElseIf { get; } = false;
     internal Conditioned<T>? Parent { get; }
 
-    internal IfConditionBuilder(Conditioned<T>? parent = null)
+    internal IfConditionBuilder(Conditioned<T>? parent = null) : this(parent, false) { }
+
+    internal IfConditionBuilder(Conditioned<T>? parent = null, bool isElseIf = false)
     {
         Parent = parent;
+        IsElseIf = isElseIf;
     }
 
     /// <summary>
@@ -961,6 +970,7 @@ public IfCondition<T> IsNotPullRequest
     private IfCondition<T> Link(IfCondition<T> condition)
     {
         condition.Parent = Parent;
+        condition.IsElseIf = IsElseIf;
         return condition;
     }
 }
diff --git a/src/Sharpliner/AzureDevOps/IfCondition.cs b/src/Sharpliner/AzureDevOps/IfCondition.cs
index 7a6199d3..62ad2155 100644
--- a/src/Sharpliner/AzureDevOps/IfCondition.cs
+++ b/src/Sharpliner/AzureDevOps/IfCondition.cs
@@ -7,11 +7,17 @@ namespace Sharpliner.AzureDevOps;
 /// </summary>
 public abstract class IfCondition : Condition
 {
+    internal bool IsElseIf = false;
+
+    internal override string TagStart => IsElseIf ? ElseIfTagStart : IfTagStart;
+
     private static readonly (string Start, string End)[] s_tagsToRemove =
     [
         (IfTagStart, ExpressionEnd),
+        (ElseIfTagStart, ExpressionEnd),
         (ElseTagStart, ExpressionEnd),
         ('\'' + IfTagStart, ExpressionEnd + '\''),
+        ('\'' + ElseIfTagStart, ExpressionEnd + '\''),
         ('\'' + ElseTagStart, ExpressionEnd + '\''),
         (ExpressionStart, ExpressionEnd),
         ('\'' + ExpressionStart, ExpressionEnd + '\''),
@@ -41,8 +47,8 @@ internal static string WithoutTags(string condition)
         return condition;
     }
 
-    internal static string WrapTag(string condition) =>
-        IfTagStart + condition + ExpressionEnd;
+    internal string WrapTag(string condition) =>
+        TagStart + condition + ExpressionEnd;
 }
 
 /// <summary>
diff --git a/tests/Sharpliner.Tests/AzureDevOps/ConditionedExpressions/ConditionalsTests.cs b/tests/Sharpliner.Tests/AzureDevOps/ConditionedExpressions/ConditionalsTests.cs
index 8db95f75..bd608feb 100644
--- a/tests/Sharpliner.Tests/AzureDevOps/ConditionedExpressions/ConditionalsTests.cs
+++ b/tests/Sharpliner.Tests/AzureDevOps/ConditionedExpressions/ConditionalsTests.cs
@@ -164,8 +164,7 @@ private class ConditionedValueWithElseIf_Pipeline : SimpleTestPipeline
                                 {
                                     Demands = { "SomeProperty -equals SomeValue" }
                                 })
-                            .EndIf
-                            .If.Equal("C", "D")
+                            .ElseIf.Equal("C", "D")
                                 .Pool(new HostedPool("pool-B")),
                 }
             }
diff --git a/tests/Sharpliner.Tests/PublicApiExport.txt.verified.txt b/tests/Sharpliner.Tests/PublicApiExport.txt.verified.txt
index 7af657de..9e27f6da 100644
--- a/tests/Sharpliner.Tests/PublicApiExport.txt.verified.txt
+++ b/tests/Sharpliner.Tests/PublicApiExport.txt.verified.txt
@@ -1309,6 +1309,7 @@ namespace Sharpliner.AzureDevOps.ConditionedExpressions
         protected Conditioned(Sharpliner.AzureDevOps.IfCondition? condition) { }
         public Conditioned(T definition) { }
         public Sharpliner.AzureDevOps.IfCondition<T> Else { get; }
+        public Sharpliner.AzureDevOps.ConditionedExpressions.IfConditionBuilder ElseIf { get; }
         public Sharpliner.AzureDevOps.ConditionedExpressions.Conditioned<T> EndEach { get; }
         public Sharpliner.AzureDevOps.ConditionedExpressions.Conditioned<T> EndIf { get; }
         public Sharpliner.AzureDevOps.ConditionedExpressions.IfConditionBuilder<T> If { get; }
diff --git a/tests/Sharpliner.Tests/Verified/AzureDevOps.ConditionedExpressions/ConditionalsTests.ConditionedValueWithElseIf_Test.verified.txt b/tests/Sharpliner.Tests/Verified/AzureDevOps.ConditionedExpressions/ConditionalsTests.ConditionedValueWithElseIf_Test.verified.txt
index 92e3444d..a8c8ab36 100644
--- a/tests/Sharpliner.Tests/Verified/AzureDevOps.ConditionedExpressions/ConditionalsTests.ConditionedValueWithElseIf_Test.verified.txt
+++ b/tests/Sharpliner.Tests/Verified/AzureDevOps.ConditionedExpressions/ConditionalsTests.ConditionedValueWithElseIf_Test.verified.txt
@@ -5,5 +5,5 @@
       name: pool-A
       demands:
       - SomeProperty -equals SomeValue
-    ${{ if eq('C', 'D') }}:
+    ${{ elseif eq('C', 'D') }}:
       name: pool-B


Reviews:
user: login: premun
body: @jr-krandino I like the change, thank you for the contribution, just a couple of small comments
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T15:41:15Z
user: login: premun
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T19:44:57Z
user: login: premun
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-08T16:15:21Z

Review comments:
diff_hunk: @@ -35,8 +35,15 @@ public record Environment
     /// <summary>
     /// Tag names to filter the resources in the environment
     /// </summary>
+    [YamlIgnore]
     public List<string> Tags { get; init; } = [];
 
+    /// <summary>
+    /// Internal property to serialize the tags as a comma-separated string.
+    /// </summary>
+    [YamlMember(Alias = "tags")]
+    public string? _Tags => Tags.Count > 0 ? string.Join(",", Tags) : null;
path: src/Sharpliner/AzureDevOps/Model/Environment.cs
user: login: premun
body: Should this be part of the PR?
created_at: 2025-05-05T15:26:36Z
author_association: COLLABORATOR
diff_hunk: @@ -195,7 +195,14 @@ internal TemplateDefinition(TParameters? typedParameters = null)
     /// <param name="definition">The typed template definition</param>
     public static implicit operator Template<T>(TemplateDefinition<T, TParameters> definition)
     {
-        return new Template<T>(definition.TargetFile, TypedTemplateUtils<TParameters>.ToTemplateParameters(definition._typedParameters));
+        string path = definition.TargetFile;
+
+        if (definition.TargetPathType == TargetPathType.RelativeToGitRoot)
+        {
+            path = "/" + path;
path: src/Sharpliner/AzureDevOps/TemplateDefinition.cs
user: login: premun
body: I think this one also creeped in by mistake?
created_at: 2025-05-05T15:38:03Z
author_association: COLLABORATOR
diff_hunk: @@ -7,11 +7,17 @@ namespace Sharpliner.AzureDevOps;
 /// </summary>
 public abstract class IfCondition : Condition
 {
+    internal bool IsElseIf = false;
path: src/Sharpliner/AzureDevOps/IfCondition.cs
user: login: premun
body: Can you not make this a boolean flag but instead use inheritance and possibly override some of the fields such as `TagStart`?
Can you do the same for the builder?
created_at: 2025-05-05T15:41:07Z
author_association: COLLABORATOR
diff_hunk: @@ -7,11 +7,17 @@ namespace Sharpliner.AzureDevOps;
 /// </summary>
 public abstract class IfCondition : Condition
 {
+    internal bool IsElseIf = false;
path: src/Sharpliner/AzureDevOps/IfCondition.cs
user: login: premun
body: @jr-krandino how about this one?
created_at: 2025-05-06T19:44:57Z
author_association: COLLABORATOR

PR comments:
user: login: jr-krandino
created_at: 2025-05-06T15:58:04Z
body: The excess code from another PR should all be removed now.
author_association: CONTRIBUTOR
user: login: jr-krandino
created_at: 2025-05-08T16:00:54Z
body: Hey @premun.  I think I'm having some sort of strange issue where my responses to your previous question about inheritance wasn't going out.  They're weirdly marked as "Pending".

![441224090-68103d5e-7938-443c-af80-ff1e4b425c8d](https://github.com/user-attachments/assets/7dc872f0-cfd1-4185-823e-f0ecb13221c4)

Here's how it looks on my end, as well as the explanation on why I went the route I did.

author_association: CONTRIBUTOR
user: login: premun
created_at: 2025-05-08T16:14:17Z
body: @jr-krandino you probably made them in the "Files" tab and you "Started a review" so it's buffering all your comments until you publish the review.
author_association: COLLABORATOR
user: login: premun
created_at: 2025-05-08T16:15:52Z
body: > Hey @premun. I think I'm having some sort of strange issue where my responses to your previous question about inheritance wasn't going out. They're weirdly marked as "Pending".
> 
> ![441224090-68103d5e-7938-443c-af80-ff1e4b425c8d](https://private-user-images.githubusercontent.com/185941133/441789444-7dc872f0-cfd1-4185-823e-f0ecb13221c4.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDY3MjEyMjIsIm5iZiI6MTc0NjcyMDkyMiwicGF0aCI6Ii8xODU5NDExMzMvNDQxNzg5NDQ0LTdkYzg3MmYwLWNmZDEtNDE4NS04MjNlLWYwZWNiMTMyMjFjNC5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwNTA4JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDUwOFQxNjE1MjJaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1iNTQ4MmFkODcwZjNjNmVhNWJjNTQ4NGRkOTRjN2FlOWI2NTljMTQ0ZmVmNjE2YTA3MDg1NTYxM2E3ODQ0YjIzJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.yeSiLWpVXFGvbhcr8pbbtGw04KD9ekNQw6TgNWUnQtQ)
> 
> Here's how it looks on my end, as well as the explanation on why I went the route I did.

I see, let's merge then. Thanks for this one!
author_association: COLLABORATOR