Skip to content

Commit 58752b8

Browse files
authored
Merge pull request #239 from ap0llo/gitlab-ci-log-sections
In GitLab CI, render Cake tasks as collapsible sections
2 parents 5205aa5 + 367377e commit 58752b8

File tree

3 files changed

+128
-0
lines changed

3 files changed

+128
-0
lines changed

src/Cake.GitLabCI.Module/AnsiEscapeCodes.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ internal static class AnsiEscapeCodes
77
public static readonly string ForegroundYellow = string.Format(FORMAT, 33);
88
public static readonly string ForegroundLightGray = string.Format(FORMAT, 37);
99
public static readonly string ForegroundDarkGray = string.Format(FORMAT, 90);
10+
public static readonly string ForegroundBlue = string.Format(FORMAT, 34);
1011
public static readonly string BackgroundMagenta = string.Format(FORMAT, 45);
1112
public static readonly string BackgroundRed = string.Format(FORMAT, 41);
13+
public static readonly string SectionMarker = "\u001B[0K";
1214

1315
private const string FORMAT = "\u001B[{0}m";
1416
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text.RegularExpressions;
4+
5+
using Cake.Core;
6+
using Cake.Core.Diagnostics;
7+
using Cake.Module.Shared;
8+
9+
using JetBrains.Annotations;
10+
11+
namespace Cake.GitLabCI.Module
12+
{
13+
/// <summary>
14+
/// <see cref="ICakeEngine"/> implementation for GitLab CI.
15+
/// </summary>
16+
/// <remarks>
17+
/// This engine emits additional console output to make GitLab CI render the output of the indiviudal Cake tasks as collapsible sections
18+
/// (see <see href="https://docs.gitlab.com/ee/ci/yaml/script.html#custom-collapsible-sections">Custom collapsible sections (GitLab Docs)</see>).
19+
/// </remarks>
20+
[UsedImplicitly]
21+
public sealed class GitLabCIEngine : CakeEngineBase
22+
{
23+
private readonly IConsole _console;
24+
private readonly object _sectionNameLock = new object();
25+
private readonly Dictionary<string, string> _taskSectionNames = new Dictionary<string, string>();
26+
private readonly HashSet<string> _sectionNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
27+
28+
/// <summary>
29+
/// Initializes a new instance of the <see cref="GitLabCIEngine"/> class.
30+
/// </summary>
31+
/// <param name="dataService">Implementation of <see cref="ICakeDataService"/>.</param>
32+
/// <param name="log">Implementation of <see cref="ICakeLog"/>.</param>
33+
/// <param name="console">Implementation of <see cref="IConsole"/>.</param>
34+
public GitLabCIEngine(ICakeDataService dataService, ICakeLog log, IConsole console)
35+
: base(new CakeEngine(dataService, log))
36+
{
37+
_console = console;
38+
_engine.BeforeSetup += OnBeforeSetup;
39+
_engine.AfterSetup += OnAfterSetup;
40+
_engine.BeforeTaskSetup += OnBeforeTaskSetup;
41+
_engine.AfterTaskTeardown += OnAfterTaskTeardown;
42+
_engine.BeforeTeardown += OnBeforeTeardown;
43+
_engine.AfterTeardown += OnAfterTeardown;
44+
}
45+
46+
private void OnBeforeSetup(object sender, BeforeSetupEventArgs e)
47+
{
48+
WriteSectionStart("setup", "Executing Setup");
49+
}
50+
51+
private void OnAfterSetup(object sender, AfterSetupEventArgs e)
52+
{
53+
WriteSectionEnd("setup");
54+
}
55+
56+
private void OnBeforeTaskSetup(object sender, BeforeTaskSetupEventArgs e)
57+
{
58+
WriteSectionStart(GetSectionNameForTask(e.TaskSetupContext.Task.Name), $"Executing task \"{e.TaskSetupContext.Task.Name}\"");
59+
}
60+
61+
private void OnAfterTaskTeardown(object sender, AfterTaskTeardownEventArgs e)
62+
{
63+
WriteSectionEnd(GetSectionNameForTask(e.TaskTeardownContext.Task.Name));
64+
}
65+
66+
private void OnBeforeTeardown(object sender, BeforeTeardownEventArgs e)
67+
{
68+
WriteSectionStart("teardown", "Executing Teardown");
69+
}
70+
71+
private void OnAfterTeardown(object sender, AfterTeardownEventArgs e)
72+
{
73+
WriteSectionEnd("teardown");
74+
}
75+
76+
private void WriteSectionStart(string sectionName, string sectionHeader)
77+
{
78+
_console.WriteLine("{0}", $"{AnsiEscapeCodes.SectionMarker}section_start:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}:{sectionName}\r{AnsiEscapeCodes.SectionMarker}{AnsiEscapeCodes.ForegroundBlue}{sectionHeader}{AnsiEscapeCodes.Reset}");
79+
}
80+
81+
private void WriteSectionEnd(string sectionName)
82+
{
83+
_console.WriteLine("{0}", $"{AnsiEscapeCodes.SectionMarker}section_end:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}:{sectionName}\r{AnsiEscapeCodes.SectionMarker}");
84+
}
85+
86+
/// <summary>
87+
/// Computes a unique GitLab CI section name for a task name.
88+
/// </summary>
89+
/// <remarks>
90+
/// GitLab CI requires a section name in both the "start" and "end" markers of a section.
91+
/// The name can only be composed of letters, numbers, and the _, ., or - characters.
92+
/// In Cake, each task corresponds to one section.
93+
/// Since the task name may contain characters not allowed in the section name, unsupprted characters are removed from the task name.
94+
/// Additionally, this method ensures that the section name is unique and the same task name will be mapped to the same section name for each call.
95+
/// </remarks>
96+
private string GetSectionNameForTask(string taskName)
97+
{
98+
lock (_sectionNameLock)
99+
{
100+
// If there is already a section name for the task, reuse the same name
101+
if (_taskSectionNames.TryGetValue(taskName, out var sectionName))
102+
{
103+
return sectionName;
104+
}
105+
106+
// Remove unsuported characters from the task name (everything except letters, numbers or the _, ., and - characters
107+
var normalizedTaskName = Regex.Replace(taskName, "[^A-Z|a-z|0-9|_|\\-|\\.]*", string.Empty).ToLowerInvariant();
108+
109+
// Normalizing the task name can cause multiple tasks to be mapped to the same section name
110+
// To avoid name conflicts, append a number to the end to make the section name unique.
111+
sectionName = normalizedTaskName;
112+
var sectionCounter = 0;
113+
while (!_sectionNames.Add(sectionName))
114+
{
115+
sectionName = string.Concat(sectionName, "_", sectionCounter++);
116+
}
117+
118+
// Save task name -> section name mapping for subsequent calls of GetSectionNameForTask()
119+
_taskSectionNames.Add(taskName, sectionName);
120+
return sectionName;
121+
}
122+
}
123+
}
124+
}

src/Cake.GitLabCI.Module/GitLabCIModule.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22

3+
using Cake.Core;
34
using Cake.Core.Annotations;
45
using Cake.Core.Composition;
56
using Cake.Core.Diagnostics;
@@ -19,6 +20,7 @@ public void Register(ICakeContainerRegistrar registrar)
1920
if (StringComparer.OrdinalIgnoreCase.Equals(Environment.GetEnvironmentVariable("CI_SERVER"), "yes"))
2021
{
2122
registrar.RegisterType<GitLabCILog>().As<ICakeLog>().Singleton();
23+
registrar.RegisterType<GitLabCIEngine>().As<ICakeEngine>().Singleton();
2224
}
2325
}
2426
}

0 commit comments

Comments
 (0)