diff --git a/src/Runner.Common/RunServer.cs b/src/Runner.Common/RunServer.cs index 3cc53f3d8..47cd62b63 100644 --- a/src/Runner.Common/RunServer.cs +++ b/src/Runner.Common/RunServer.cs @@ -19,7 +19,14 @@ namespace GitHub.Runner.Common Task GetJobMessageAsync(string id, CancellationToken token); - Task CompleteJobAsync(Guid planId, Guid jobId, TaskResult result, Dictionary outputs, IList stepResults, CancellationToken token); + Task CompleteJobAsync( + Guid planId, + Guid jobId, + TaskResult result, + Dictionary outputs, + IList stepResults, + IList jobAnnotations, + CancellationToken token); Task RenewJobAsync(Guid planId, Guid jobId, CancellationToken token); } @@ -56,11 +63,18 @@ namespace GitHub.Runner.Common shouldRetry: ex => ex is not TaskOrchestrationJobAlreadyAcquiredException); } - public Task CompleteJobAsync(Guid planId, Guid jobId, TaskResult result, Dictionary outputs, IList stepResults, CancellationToken cancellationToken) + public Task CompleteJobAsync( + Guid planId, + Guid jobId, + TaskResult result, + Dictionary outputs, + IList stepResults, + IList jobAnnotations, + CancellationToken cancellationToken) { CheckConnection(); return RetryRequest( - async () => await _runServiceHttpClient.CompleteJobAsync(requestUri, planId, jobId, result, outputs, stepResults, cancellationToken), cancellationToken); + async () => await _runServiceHttpClient.CompleteJobAsync(requestUri, planId, jobId, result, outputs, stepResults, jobAnnotations, cancellationToken), cancellationToken); } public Task RenewJobAsync(Guid planId, Guid jobId, CancellationToken cancellationToken) diff --git a/src/Runner.Listener/JobDispatcher.cs b/src/Runner.Listener/JobDispatcher.cs index 53380b9b2..9402cd2d7 100644 --- a/src/Runner.Listener/JobDispatcher.cs +++ b/src/Runner.Listener/JobDispatcher.cs @@ -15,6 +15,7 @@ using GitHub.Runner.Sdk; using GitHub.Services.Common; using GitHub.Services.WebApi; using GitHub.Services.WebApi.Jwt; +using Sdk.RSWebApi.Contracts; using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Listener @@ -372,6 +373,8 @@ namespace GitHub.Runner.Listener TaskCompletionSource firstJobRequestRenewed = new(); var notification = HostContext.GetService(); + var systemConnection = message.Resources.Endpoints.SingleOrDefault(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase)); + // lock renew cancellation token. using (var lockRenewalTokenSource = new CancellationTokenSource()) using (var workerProcessCancelTokenSource = new CancellationTokenSource()) @@ -379,8 +382,6 @@ namespace GitHub.Runner.Listener long requestId = message.RequestId; Guid lockToken = Guid.Empty; // lockToken has never been used, keep this here of compat - var systemConnection = message.Resources.Endpoints.SingleOrDefault(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase)); - // start renew job request Trace.Info($"Start renew job request {requestId} for job {message.JobId}."); Task renewJobRequest = RenewJobRequestAsync(message, systemConnection, _poolId, requestId, lockToken, orchestrationId, firstJobRequestRenewed, lockRenewalTokenSource.Token); @@ -405,7 +406,7 @@ namespace GitHub.Runner.Listener await renewJobRequest; // complete job request with result Cancelled - await CompleteJobRequestAsync(_poolId, message, lockToken, TaskResult.Canceled); + await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, TaskResult.Canceled); return; } @@ -544,7 +545,6 @@ namespace GitHub.Runner.Listener detailInfo = string.Join(Environment.NewLine, workerOutput); Trace.Info($"Return code {returnCode} indicate worker encounter an unhandled exception or app crash, attach worker stdout/stderr to JobRequest result."); - var jobServer = await InitializeJobServerAsync(systemConnection); await LogWorkerProcessUnhandledException(jobServer, message, detailInfo); @@ -552,7 +552,7 @@ namespace GitHub.Runner.Listener if (detailInfo.Contains(typeof(System.IO.IOException).ToString(), StringComparison.OrdinalIgnoreCase)) { Trace.Info($"Finish job with result 'Failed' due to IOException."); - await ForceFailJob(jobServer, message); + await ForceFailJob(jobServer, message, detailInfo); } } @@ -567,7 +567,7 @@ namespace GitHub.Runner.Listener await renewJobRequest; // complete job request - await CompleteJobRequestAsync(_poolId, message, lockToken, result, detailInfo); + await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, result, detailInfo); // print out unhandled exception happened in worker after we complete job request. // when we run out of disk space, report back to server has higher priority. @@ -664,7 +664,7 @@ namespace GitHub.Runner.Listener await renewJobRequest; // complete job request - await CompleteJobRequestAsync(_poolId, message, lockToken, resultOnAbandonOrCancel); + await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, resultOnAbandonOrCancel); } finally { @@ -1065,7 +1065,7 @@ namespace GitHub.Runner.Listener } } - private async Task CompleteJobRequestAsync(int poolId, Pipelines.AgentJobRequestMessage message, Guid lockToken, TaskResult result, string detailInfo = null) + private async Task CompleteJobRequestAsync(int poolId, Pipelines.AgentJobRequestMessage message, ServiceEndpoint systemConnection, Guid lockToken, TaskResult result, string detailInfo = null) { Trace.Entering(); @@ -1077,7 +1077,23 @@ namespace GitHub.Runner.Listener if (this._isRunServiceJob) { - Trace.Verbose($"Skip FinishAgentRequest call from Listener because MessageType is {message.MessageType}"); + var runServer = await GetRunServerAsync(systemConnection); + var unhandledExceptionIssue = new Issue() { Type = IssueType.Error, Message = detailInfo }; + var unhandledAnnotation = unhandledExceptionIssue.ToAnnotation(); + var jobAnnotations = new List(); + if (unhandledAnnotation.HasValue) + { + jobAnnotations.Add(unhandledAnnotation.Value); + } + try + { + await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, result, outputs: null, stepResults: null, jobAnnotations: jobAnnotations, CancellationToken.None); + } + catch (Exception ex) + { + Trace.Error("Fail to raise job completion back to service."); + Trace.Error(ex); + } return; } @@ -1117,7 +1133,7 @@ namespace GitHub.Runner.Listener } // log an error issue to job level timeline record - private async Task LogWorkerProcessUnhandledException(IRunnerService server, Pipelines.AgentJobRequestMessage message, string errorMessage) + private async Task LogWorkerProcessUnhandledException(IRunnerService server, Pipelines.AgentJobRequestMessage message, string detailInfo) { if (server is IJobServer jobServer) { @@ -1129,34 +1145,11 @@ namespace GitHub.Runner.Listener TimelineRecord jobRecord = timeline.Records.FirstOrDefault(x => x.Id == message.JobId && x.RecordType == "Job"); ArgUtil.NotNull(jobRecord, nameof(jobRecord)); - try - { - if (!string.IsNullOrEmpty(errorMessage) && - message.Variables.TryGetValue("DistributedTask.EnableRunnerIPCDebug", out var enableRunnerIPCDebug) && - StringUtil.ConvertToBoolean(enableRunnerIPCDebug.Value)) - { - // the trace should be best effort and not affect any job result - var match = _invalidJsonRegex.Match(errorMessage); - if (match.Success && - match.Groups.Count == 2) - { - var jsonPosition = int.Parse(match.Groups[1].Value); - var serializedJobMessage = JsonUtility.ToString(message); - var originalJson = serializedJobMessage.Substring(jsonPosition - 10, 20); - errorMessage = $"Runner sent Json at position '{jsonPosition}': {originalJson} ({Convert.ToBase64String(Encoding.UTF8.GetBytes(originalJson))})\n{errorMessage}"; - } - } - } - catch (Exception ex) - { - Trace.Error(ex); - errorMessage = $"Fail to check json IPC error: {ex.Message}\n{errorMessage}"; - } - - var unhandledExceptionIssue = new Issue() { Type = IssueType.Error, Message = errorMessage }; + var unhandledExceptionIssue = new Issue() { Type = IssueType.Error, Message = detailInfo }; unhandledExceptionIssue.Data[Constants.Runner.InternalTelemetryIssueDataKey] = Constants.Runner.WorkerCrash; jobRecord.ErrorCount++; jobRecord.Issues.Add(unhandledExceptionIssue); + await jobServer.UpdateTimelineRecordsAsync(message.Plan.ScopeIdentifier, message.Plan.PlanType, message.Plan.PlanId, message.Timeline.Id, new TimelineRecord[] { jobRecord }, CancellationToken.None); } catch (Exception ex) @@ -1167,13 +1160,13 @@ namespace GitHub.Runner.Listener } else { - Trace.Info("Job server does not support handling unhandled exception yet, error message: {0}", errorMessage); + Trace.Info("Job server does not support handling unhandled exception yet, error message: {0}", detailInfo); return; } } // raise job completed event to fail the job. - private async Task ForceFailJob(IRunnerService server, Pipelines.AgentJobRequestMessage message) + private async Task ForceFailJob(IRunnerService server, Pipelines.AgentJobRequestMessage message, string detailInfo) { if (server is IJobServer jobServer) { @@ -1192,7 +1185,15 @@ namespace GitHub.Runner.Listener { try { - await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, TaskResult.Failed, outputs: null, stepResults: null, CancellationToken.None); + var unhandledExceptionIssue = new Issue() { Type = IssueType.Error, Message = detailInfo }; + var unhandledAnnotation = unhandledExceptionIssue.ToAnnotation(); + var jobAnnotations = new List(); + if (unhandledAnnotation.HasValue) + { + jobAnnotations.Add(unhandledAnnotation.Value); + } + + await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, TaskResult.Failed, outputs: null, stepResults: null, jobAnnotations: jobAnnotations, CancellationToken.None); } catch (Exception ex) { diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 8d981c549..660883d73 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -18,6 +18,7 @@ using GitHub.Runner.Sdk; using GitHub.Runner.Worker.Container; using GitHub.Runner.Worker.Handlers; using Newtonsoft.Json; +using Sdk.RSWebApi.Contracts; using ObjectTemplating = GitHub.DistributedTask.ObjectTemplating; using Pipelines = GitHub.DistributedTask.Pipelines; @@ -438,14 +439,26 @@ namespace GitHub.Runner.Worker PublishStepTelemetry(); - var stepResult = new StepResult(); - stepResult.ExternalID = _record.Id; - stepResult.Conclusion = _record.Result ?? TaskResult.Succeeded; - stepResult.Status = _record.State; - stepResult.Number = _record.Order; - stepResult.Name = _record.Name; - stepResult.StartedAt = _record.StartTime; - stepResult.CompletedAt = _record.FinishTime; + var stepResult = new StepResult + { + ExternalID = _record.Id, + Conclusion = _record.Result ?? TaskResult.Succeeded, + Status = _record.State, + Number = _record.Order, + Name = _record.Name, + StartedAt = _record.StartTime, + CompletedAt = _record.FinishTime, + Annotations = new List() + }; + + _record.Issues?.ForEach(issue => + { + var annotation = issue.ToAnnotation(); + if (annotation != null) + { + stepResult.Annotations.Add(annotation.Value); + } + }); Global.StepsResult.Add(stepResult); @@ -725,6 +738,9 @@ namespace GitHub.Runner.Worker // Steps results for entire job Global.StepsResult = new List(); + // Job level annotations + Global.JobAnnotations = new List(); + // Job Outputs JobOutputs = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index f9120e380..d8ff1ecde 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Collections.Generic; using GitHub.Actions.RunService.WebApi; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Util; using GitHub.Runner.Worker.Container; using Newtonsoft.Json.Linq; +using Sdk.RSWebApi.Contracts; namespace GitHub.Runner.Worker { @@ -18,6 +19,7 @@ namespace GitHub.Runner.Worker public IDictionary> JobDefaults { get; set; } public List StepsTelemetry { get; set; } public List StepsResult { get; set; } + public List JobAnnotations { get; set; } public List JobTelemetry { get; set; } public TaskOrchestrationPlanReference Plan { get; set; } public List PrependPath { get; set; } diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 77a93bf9d..f2edb873d 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -272,7 +272,7 @@ namespace GitHub.Runner.Worker { try { - await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, result, jobContext.JobOutputs, jobContext.Global.StepsResult, default); + await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, result, jobContext.JobOutputs, jobContext.Global.StepsResult, jobContext.Global.JobAnnotations, default); return result; } catch (Exception ex) diff --git a/src/Sdk/RSWebApi/Contracts/Annotation.cs b/src/Sdk/RSWebApi/Contracts/Annotation.cs new file mode 100644 index 000000000..522dccad9 --- /dev/null +++ b/src/Sdk/RSWebApi/Contracts/Annotation.cs @@ -0,0 +1,35 @@ +using System.Runtime.Serialization; + +namespace Sdk.RSWebApi.Contracts +{ + [DataContract] + public struct Annotation + { + [DataMember(Name = "level", EmitDefaultValue = false)] + public AnnotationLevel Level; + + [DataMember(Name = "message", EmitDefaultValue = false)] + public string Message; + + [DataMember(Name = "rawDetails", EmitDefaultValue = false)] + public string RawDetails; + + [DataMember(Name = "path", EmitDefaultValue = false)] + public string Path; + + [DataMember(Name = "isInfrastructureIssue", EmitDefaultValue = false)] + public bool IsInfrastructureIssue; + + [DataMember(Name = "startLine", EmitDefaultValue = false)] + public long StartLine; + + [DataMember(Name = "endLine", EmitDefaultValue = false)] + public long EndLine; + + [DataMember(Name = "startColumn", EmitDefaultValue = false)] + public long StartColumn; + + [DataMember(Name = "endColumn", EmitDefaultValue = false)] + public long EndColumn; + } +} diff --git a/src/Sdk/RSWebApi/Contracts/AnnotationLevel.cs b/src/Sdk/RSWebApi/Contracts/AnnotationLevel.cs new file mode 100644 index 000000000..826ac40d7 --- /dev/null +++ b/src/Sdk/RSWebApi/Contracts/AnnotationLevel.cs @@ -0,0 +1,20 @@ +using System.Runtime.Serialization; + +namespace Sdk.RSWebApi.Contracts +{ + [DataContract] + public enum AnnotationLevel + { + [EnumMember] + UNKNOWN = 0, + + [EnumMember] + NOTICE = 1, + + [EnumMember] + WARNING = 2, + + [EnumMember] + FAILURE = 3 + } +} diff --git a/src/Sdk/RSWebApi/Contracts/CompleteJobRequest.cs b/src/Sdk/RSWebApi/Contracts/CompleteJobRequest.cs index 27aa3f963..5c8813f98 100644 --- a/src/Sdk/RSWebApi/Contracts/CompleteJobRequest.cs +++ b/src/Sdk/RSWebApi/Contracts/CompleteJobRequest.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Runtime.Serialization; using GitHub.DistributedTask.WebApi; +using Sdk.RSWebApi.Contracts; namespace GitHub.Actions.RunService.WebApi { @@ -10,17 +11,20 @@ namespace GitHub.Actions.RunService.WebApi { [DataMember(Name = "planId", EmitDefaultValue = false)] public Guid PlanID { get; set; } - + [DataMember(Name = "jobId", EmitDefaultValue = false)] public Guid JobID { get; set; } - + [DataMember(Name = "conclusion")] public TaskResult Conclusion { get; set; } - + [DataMember(Name = "outputs", EmitDefaultValue = false)] - public Dictionary Outputs { get; set; } - + public Dictionary Outputs { get; set; } + [DataMember(Name = "stepResults", EmitDefaultValue = false)] public IList StepResults { get; set; } + + [DataMember(Name = "annotations", EmitDefaultValue = false)] + public IList Annotations { get; set; } } -} \ No newline at end of file +} diff --git a/src/Sdk/RSWebApi/Contracts/IssueExtensions.cs b/src/Sdk/RSWebApi/Contracts/IssueExtensions.cs new file mode 100644 index 000000000..88272cca1 --- /dev/null +++ b/src/Sdk/RSWebApi/Contracts/IssueExtensions.cs @@ -0,0 +1,91 @@ +using GitHub.DistributedTask.WebApi; + +namespace Sdk.RSWebApi.Contracts +{ + public static class IssueExtensions + { + public static Annotation? ToAnnotation(this Issue issue) + { + var issueMessage = issue.Message; + if (string.IsNullOrWhiteSpace(issueMessage)) + { + if (!issue.Data.TryGetValue(RunIssueKeys.Message, out issueMessage) || string.IsNullOrWhiteSpace(issueMessage)) + { + return null; + } + } + + var annotationLevel = GetAnnotationLevel(issue.Type); + var path = GetFilePath(issue); + var lineNumber = GetAnnotationNumber(issue, RunIssueKeys.Line) ?? 0; + var endLineNumber = GetAnnotationNumber(issue, RunIssueKeys.EndLine) ?? lineNumber; + var columnNumber = GetAnnotationNumber(issue, RunIssueKeys.Col) ?? 0; + var endColumnNumber = GetAnnotationNumber(issue, RunIssueKeys.EndColumn) ?? columnNumber; + var logLineNumber = GetAnnotationNumber(issue, RunIssueKeys.LogLineNumber) ?? 0; + + if (path == null && lineNumber == 0 && logLineNumber != 0) + { + lineNumber = logLineNumber; + endLineNumber = logLineNumber; + } + + return new Annotation + { + Level = annotationLevel, + Message = issueMessage, + Path = path, + StartLine = lineNumber, + EndLine = endLineNumber, + StartColumn = columnNumber, + EndColumn = endColumnNumber, + }; + } + + private static AnnotationLevel GetAnnotationLevel(IssueType issueType) + { + switch (issueType) + { + case IssueType.Error: + return AnnotationLevel.FAILURE; + case IssueType.Warning: + return AnnotationLevel.WARNING; + case IssueType.Notice: + return AnnotationLevel.NOTICE; + default: + return AnnotationLevel.UNKNOWN; + } + } + + private static int? GetAnnotationNumber(Issue issue, string key) + { + if (issue.Data.TryGetValue(key, out var numberString) && + int.TryParse(numberString, out var number)) + { + return number; + } + + return null; + } + + private static string GetAnnotationField(Issue issue, string key) + { + if (issue.Data.TryGetValue(key, out var value)) + { + return value; + } + + return null; + } + + private static string GetFilePath(Issue issue) + { + if (issue.Data.TryGetValue(RunIssueKeys.File, out var path) && + !string.IsNullOrWhiteSpace(path)) + { + return path; + } + + return null; + } + } +} diff --git a/src/Sdk/RSWebApi/Contracts/IssueKeys.cs b/src/Sdk/RSWebApi/Contracts/IssueKeys.cs new file mode 100644 index 000000000..d7a14a847 --- /dev/null +++ b/src/Sdk/RSWebApi/Contracts/IssueKeys.cs @@ -0,0 +1,13 @@ +namespace Sdk.RSWebApi.Contracts +{ + public static class RunIssueKeys + { + public const string Message = "message"; + public const string File = "file"; + public const string Line = "line"; + public const string Col = "col"; + public const string EndLine = "endLine"; + public const string EndColumn = "endColumn"; + public const string LogLineNumber = "logFileLineNumber"; + } +} diff --git a/src/Sdk/RSWebApi/Contracts/StepResult.cs b/src/Sdk/RSWebApi/Contracts/StepResult.cs index e7df8d6d4..e24489817 100644 --- a/src/Sdk/RSWebApi/Contracts/StepResult.cs +++ b/src/Sdk/RSWebApi/Contracts/StepResult.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Runtime.Serialization; using System.Threading.Tasks; using GitHub.DistributedTask.WebApi; +using Sdk.RSWebApi.Contracts; namespace GitHub.Actions.RunService.WebApi { @@ -34,5 +36,8 @@ namespace GitHub.Actions.RunService.WebApi [DataMember(Name = "completed_log_lines", EmitDefaultValue = false)] public long? CompletedLogLines { get; set; } + + [DataMember(Name = "annotations", EmitDefaultValue = false)] + public List Annotations { get; set; } } -} \ No newline at end of file +} diff --git a/src/Sdk/RSWebApi/RunServiceHttpClient.cs b/src/Sdk/RSWebApi/RunServiceHttpClient.cs index 8ddd1672b..3b910403d 100644 --- a/src/Sdk/RSWebApi/RunServiceHttpClient.cs +++ b/src/Sdk/RSWebApi/RunServiceHttpClient.cs @@ -100,6 +100,7 @@ namespace GitHub.Actions.RunService.WebApi TaskResult result, Dictionary outputs, IList stepResults, + IList jobAnnotations, CancellationToken cancellationToken = default) { HttpMethod httpMethod = new HttpMethod("POST"); @@ -109,7 +110,8 @@ namespace GitHub.Actions.RunService.WebApi JobID = jobId, Conclusion = result, Outputs = outputs, - StepResults = stepResults + StepResults = stepResults, + Annotations = jobAnnotations }; requestUri = new Uri(requestUri, "completejob"); diff --git a/src/Test/L0/Sdk/RSWebApi/AnnotationsL0.cs b/src/Test/L0/Sdk/RSWebApi/AnnotationsL0.cs new file mode 100644 index 000000000..0970e67c2 --- /dev/null +++ b/src/Test/L0/Sdk/RSWebApi/AnnotationsL0.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using GitHub.DistributedTask.WebApi; +using Sdk.RSWebApi.Contracts; +using Xunit; + +namespace GitHub.Actions.RunService.WebApi.Tests; + +public sealed class AnnotationsL0 +{ + [Fact] + public void ToAnnotation_ValidIssueWithMessage_ReturnsAnnotation() + { + var issue = new Issue + { + Type = IssueType.Error, + Message = "An error occurred", + IsInfrastructureIssue = true + }; + + issue.Data.Add(RunIssueKeys.File, "test.txt"); + issue.Data.Add(RunIssueKeys.Line, "5"); + issue.Data.Add(RunIssueKeys.Col, "10"); + issue.Data.Add(RunIssueKeys.EndLine, "8"); + issue.Data.Add(RunIssueKeys.EndColumn, "20"); + issue.Data.Add(RunIssueKeys.LogLineNumber, "2"); + + var annotation = issue.ToAnnotation(); + + Assert.NotNull(annotation); + Assert.Equal(AnnotationLevel.FAILURE, annotation.Value.Level); + Assert.Equal("An error occurred", annotation.Value.Message); + Assert.Equal("test.txt", annotation.Value.Path); + Assert.Equal(5, annotation.Value.StartLine); + Assert.Equal(8, annotation.Value.EndLine); + Assert.Equal(10, annotation.Value.StartColumn); + Assert.Equal(20, annotation.Value.EndColumn); + } + + [Fact] + public void ToAnnotation_ValidIssueWithEmptyMessage_ReturnsNull() + { + var issue = new Issue + { + Type = IssueType.Warning, + Message = string.Empty + }; + + var annotation = issue.ToAnnotation(); + + Assert.Null(annotation); + } + + [Fact] + public void ToAnnotation_ValidIssueWithMessageInData_ReturnsAnnotation() + { + var issue = new Issue + { + Type = IssueType.Warning, + Message = string.Empty, + }; + + issue.Data.Add(RunIssueKeys.Message, "A warning occurred"); + + var annotation = issue.ToAnnotation(); + + Assert.NotNull(annotation); + Assert.Equal(AnnotationLevel.WARNING, annotation.Value.Level); + Assert.Equal("A warning occurred", annotation.Value.Message); + } +}