// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Horde.Build.Acls; using Horde.Build.Api; using Horde.Build.Collections; using Horde.Build.Config; using Horde.Build.Models; using Horde.Build.Server; using Horde.Build.Services; using Horde.Build.Utilities; using HordeCommon; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Bson; using Microsoft.Extensions.Logging; namespace Horde.Build.Controllers { using JobId = ObjectId; using LogId = ObjectId; using StreamId = StringId; using UserId = ObjectId; using WorkflowId = StringId; /// /// Controller for the /api/v1/issues endpoint /// [Authorize] [ApiController] [Route("[controller]")] public class IssuesController : HordeControllerBase { private readonly IIssueCollection _issueCollection; private readonly IIssueService _issueService; private readonly JobService _jobService; private readonly StreamService _streamService; private readonly IUserCollection _userCollection; private readonly ILogFileService _logFileService; private readonly ILogger _logger; /// /// Constructor /// public IssuesController(ILogger logger, IIssueCollection issueCollection, IIssueService issueService, JobService jobService, StreamService streamService, IUserCollection userCollection, ILogFileService logFileService) { _issueCollection = issueCollection; _issueService = issueService; _jobService = jobService; _streamService = streamService; _userCollection = userCollection; _logFileService = logFileService; _logger = logger; } /// /// Retrieve information about a specific issue /// /// Set of issue ids to find /// The stream to query for /// The minimum changelist range to query, inclusive /// The minimum changelist range to query, inclusive /// Whether to include resolved issues /// Starting offset of the window of results to return /// Number of results to return /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v2/issues")] [ProducesResponseType(typeof(List), 200)] public async Task> FindIssuesV2Async([FromQuery(Name = "Id")] int[]? ids = null, [FromQuery] StreamId? streamId = null, [FromQuery] int? minChange = null, [FromQuery] int? maxChange = null, [FromQuery] bool? resolved = null, [FromQuery] int index = 0, [FromQuery] int count = 10, [FromQuery] PropertyFilter? filter = null) { if (ids != null && ids.Length == 0) { ids = null; } List responses = new List(); if (streamId != null) { if (!await _streamService.AuthorizeAsync(streamId.Value, AclAction.ViewStream, User, new StreamPermissionsCache())) { return Forbid(); } IStream? stream = await _streamService.GetStreamAsync(streamId.Value); if (stream == null) { return NotFound(streamId.Value); } List spans = await _issueCollection.FindSpansAsync(null, ids, streamId.Value, minChange, maxChange, resolved); if(spans.Count > 0) { // Group all the spans by their issue id Dictionary> issueIdToSpans = new Dictionary>(); foreach (IIssueSpan span in spans) { List? spansForIssue; if (!issueIdToSpans.TryGetValue(span.IssueId, out spansForIssue)) { spansForIssue = new List(); issueIdToSpans.Add(span.IssueId, spansForIssue); } spansForIssue.Add(span); } // Find the matching issues List issues = await _issueCollection.FindIssuesAsync(issueIdToSpans.Keys, index: index, count: count); // Create the corresponding responses foreach (IIssue issue in issues.OrderByDescending(x => x.Id)) { IssueSeverity streamSeverity = IssueSeverity.Unspecified; List spanResponses = new List(); if (issueIdToSpans.TryGetValue(issue.Id, out List? spansForIssue)) { // Filter issues on resolved state if (resolved != null && (resolved.Value != spansForIssue.All(x => x.NextSuccess != null))) { continue; } // Find the current severity in the stream DateTime lastStepTime = DateTime.MinValue; foreach (IIssueSpan span in spansForIssue) { if (span.LastFailure != null && span.LastFailure.StepTime > lastStepTime) { lastStepTime = span.LastFailure.StepTime; streamSeverity = span.LastFailure.Severity; } } // Convert each issue to a response foreach (IIssueSpan span in spansForIssue) { spanResponses.Add(new FindIssueSpanResponse(span, span.LastFailure.Annotations.WorkflowId)); } } IUser? owner = null; IUser? nominatedBy = null; IUser? resolvedBy = null; if (issue.OwnerId != null) { owner = await _userCollection.GetCachedUserAsync(issue.OwnerId.Value); } if (issue.NominatedById != null) { nominatedBy = await _userCollection.GetCachedUserAsync(issue.NominatedById.Value); } if (issue.ResolvedById != null) { resolvedBy = await _userCollection.GetCachedUserAsync(issue.ResolvedById.Value); } FindIssueResponse response = new FindIssueResponse(issue, owner, nominatedBy, resolvedBy, streamSeverity, spanResponses); responses.Add(PropertyFilter.Apply(response, filter)); } } } else { return BadRequest("Missing StreamId on request"); } return responses; } /// /// Retrieve information about a specific issue /// /// Set of issue ids to find /// The stream to query for /// The changelist to query /// The minimum changelist range to query, inclusive /// The minimum changelist range to query, inclusive /// Job id to filter by /// The batch to filter by /// The step to filter by /// The label within the job to filter by /// User to filter issues for /// Whether to include resolved issues /// Whether to include promoted issues /// Starting offset of the window of results to return /// Number of results to return /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v1/issues")] [ProducesResponseType(typeof(List), 200)] public async Task> FindIssuesAsync([FromQuery(Name = "Id")] int[]? ids = null, [FromQuery] string? streamId = null, [FromQuery] int? change = null, [FromQuery] int? minChange = null, [FromQuery] int? maxChange = null, [FromQuery] JobId? jobId = null, [FromQuery] string? batchId = null, [FromQuery] string? stepId = null, [FromQuery(Name = "label")] int? labelIdx = null, [FromQuery] string? userId = null, [FromQuery] bool? resolved = null, [FromQuery] bool? promoted = null, [FromQuery] int index = 0, [FromQuery] int count = 10, [FromQuery] PropertyFilter? filter = null) { if(ids != null && ids.Length == 0) { ids = null; } UserId? userIdValue = null; if (userId != null) { userIdValue = new UserId(userId); } List issues; if (jobId == null) { StreamId? streamIdValue = null; if (streamId != null) { streamIdValue = new StreamId(streamId); } issues = await _issueService.FindIssuesAsync(ids, userIdValue, streamIdValue, minChange ?? change, maxChange ?? change, resolved, promoted, index, count); } else { IJob? job = await _jobService.GetJobAsync(jobId.Value); if (job == null) { return NotFound(jobId.Value); } if(!await _jobService.AuthorizeAsync(job, AclAction.ViewJob, User, null)) { return Forbid(AclAction.ViewJob, jobId.Value); } IGraph graph = await _jobService.GetGraphAsync(job); issues = await _issueService.FindIssuesForJobAsync(ids, job, graph, stepId?.ToSubResourceId(), batchId?.ToSubResourceId(), labelIdx, userIdValue, resolved, promoted, index, count); } StreamPermissionsCache permissionsCache = new StreamPermissionsCache(); List responses = new List(); foreach (IIssue issue in issues) { IIssueDetails details = await _issueService.GetIssueDetailsAsync(issue); if (await AuthorizeIssue(details, permissionsCache)) { bool bShowDesktopAlerts = _issueService.ShowDesktopAlertsForIssue(issue, details.Spans); GetIssueResponse response = await CreateIssueResponseAsync(details, bShowDesktopAlerts); responses.Add(PropertyFilter.Apply(response, filter)); } } return responses; } /// /// Retrieve information about a specific issue /// /// Id of the issue to get information about /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v1/issues/{issueId}")] [ProducesResponseType(typeof(GetIssueResponse), 200)] public async Task> GetIssueAsync(int issueId, [FromQuery] PropertyFilter? filter = null) { IIssueDetails? details = await _issueService.GetIssueDetailsAsync(issueId); if (details == null) { return NotFound(); } if (!await AuthorizeIssue(details, null)) { return Forbid(); } bool bShowDesktopAlerts = _issueService.ShowDesktopAlertsForIssue(details.Issue, details.Spans); return PropertyFilter.Apply(await CreateIssueResponseAsync(details, bShowDesktopAlerts), filter); } /// /// Retrieve historical information about a specific issue /// /// Id of the agent to get information about /// Minimum time for records to return /// Maximum time for records to return /// Offset of the first result /// Number of records to return /// Information about the requested agent [HttpGet] [Route("/api/v1/issues/{issueId}/history")] public async Task GetAgentHistoryAsync(int issueId, [FromQuery] DateTime? minTime = null, [FromQuery] DateTime? maxTime = null, [FromQuery] int index = 0, [FromQuery] int count = 50) { Response.ContentType = "application/json"; Response.StatusCode = 200; await Response.StartAsync(); await _issueCollection.GetLogger(issueId).FindAsync(Response.BodyWriter, minTime, maxTime, index, count); } /// /// Create an issue response object /// /// /// /// async Task CreateIssueResponseAsync(IIssueDetails details, bool showDesktopAlerts) { List affectedStreams = new List(); foreach (IGrouping streamSpans in details.Spans.GroupBy(x => x.StreamId)) { try { IStream? stream = await _streamService.GetCachedStream(streamSpans.Key); affectedStreams.Add(new GetIssueAffectedStreamResponse(details, stream, streamSpans)); } catch { _logger.LogError("Unable to get {StreamId} for span key", streamSpans.Key); } } return new GetIssueResponse(details, affectedStreams, showDesktopAlerts); } /// /// Retrieve events for a specific issue /// /// Id of the issue to get information about /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v1/issues/{issueId}/streams")] [ProducesResponseType(typeof(List), 200)] public async Task>> GetIssueStreamsAsync(int issueId, [FromQuery] PropertyFilter? filter = null) { IIssueDetails? issue = await _issueService.GetIssueDetailsAsync(issueId); if (issue == null) { return NotFound(); } StreamPermissionsCache cache = new StreamPermissionsCache(); if (!await AuthorizeIssue(issue, cache)) { return Forbid(); } List responses = new List(); foreach (IGrouping spanGroup in issue.Spans.GroupBy(x => x.StreamId)) { if (await _streamService.AuthorizeAsync(spanGroup.Key, AclAction.ViewStream, User, cache)) { IStream? stream = await _streamService.GetCachedStream(spanGroup.Key); if (stream != null) { HashSet spanIds = new HashSet(spanGroup.Select(x => x.Id)); List steps = issue.Steps.Where(x => spanIds.Contains(x.SpanId)).ToList(); responses.Add(PropertyFilter.Apply(new GetIssueStreamResponse(stream, spanGroup.ToList(), steps), filter)); } } } return responses; } /// /// Retrieve events for a specific issue /// /// Id of the issue to get information about /// The stream id /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v1/issues/{issueId}/streams/{streamId}")] [ProducesResponseType(typeof(List), 200)] public async Task> GetIssueStreamAsync(int issueId, string streamId, [FromQuery] PropertyFilter? filter = null) { IIssueDetails? details = await _issueService.GetIssueDetailsAsync(issueId); if (details == null) { return NotFound(); } StreamId streamIdValue = new StreamId(streamId); if (!await _streamService.AuthorizeAsync(streamIdValue, AclAction.ViewStream, User, null)) { return Forbid(); } IStream? stream = await _streamService.GetCachedStream(streamIdValue); if (stream == null) { return NotFound(); } List spans = details.Spans.Where(x => x.StreamId == streamIdValue).ToList(); if(spans.Count == 0) { return NotFound(); } HashSet spanIds = new HashSet(spans.Select(x => x.Id)); List steps = details.Steps.Where(x => spanIds.Contains(x.SpanId)).ToList(); return PropertyFilter.Apply(new GetIssueStreamResponse(stream, spans, steps), filter); } /// /// Retrieve events for a specific issue /// /// Id of the issue to get information about /// The job id to filter for /// The batch to filter by /// The step to filter by /// The label within the job to filter by /// List of log ids to return issues for /// Index of the first event /// Number of events to return /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v1/issues/{issueId}/events")] [ProducesResponseType(typeof(List), 200)] public async Task>> GetIssueEventsAsync(int issueId, [FromQuery] JobId? jobId = null, [FromQuery] string? batchId = null, [FromQuery] string? stepId = null, [FromQuery(Name = "label")] int? labelIdx = null, [FromQuery] string[]? logIds = null, [FromQuery] int index = 0, [FromQuery] int count = 10, [FromQuery] PropertyFilter? filter = null) { HashSet logIdValues = new HashSet(); if(jobId != null) { IJob? job = await _jobService.GetJobAsync(jobId.Value); if(job == null) { return NotFound(); } if (stepId != null) { IJobStep? step; if (job.TryGetStep(stepId.ToSubResourceId(), out step) && step.Outcome != JobStepOutcome.Success && step.LogId != null) { logIdValues.Add(step.LogId.Value); } } else if (batchId != null) { IJobStepBatch? batch; if (job.TryGetBatch(batchId.ToSubResourceId(), out batch)) { logIdValues.UnionWith(batch.Steps.Where(x => x.Outcome != JobStepOutcome.Success && x.LogId != null).Select(x => x.LogId!.Value)); } } else if (labelIdx != null) { IGraph graph = await _jobService.GetGraphAsync(job); HashSet includedNodes = new HashSet(graph.Labels[labelIdx.Value].IncludedNodes); foreach (IJobStepBatch batch in job.Batches) { foreach (IJobStep step in batch.Steps) { NodeRef nodeRef = new NodeRef(batch.GroupIdx, step.NodeIdx); if (step.Outcome != JobStepOutcome.Success && step.LogId != null && includedNodes.Contains(nodeRef)) { logIdValues.Add(step.LogId.Value); } } } } else { logIdValues.UnionWith(job.Batches.SelectMany(x => x.Steps).Where(x => x.Outcome != JobStepOutcome.Success && x.LogId != null).Select(x => x.LogId!.Value)); } } if(logIds != null) { logIdValues.UnionWith(logIds.Select(x => new LogId(x))); } List events = await _issueService.FindEventsForIssueAsync(issueId, logIdValues.ToArray(), index, count); JobPermissionsCache permissionsCache = new JobPermissionsCache(); Dictionary logFiles = new Dictionary(); List responses = new List(); foreach (ILogEvent logEvent in events) { ILogFile? logFile; if (!logFiles.TryGetValue(logEvent.LogId, out logFile)) { logFile = await _logFileService.GetLogFileAsync(logEvent.LogId); logFiles[logEvent.LogId] = logFile; } if (logFile != null && await _jobService.AuthorizeAsync(logFile.JobId, AclAction.ViewLog, User, permissionsCache)) { ILogEventData data = await _logFileService.GetEventDataAsync(logFile, logEvent.LineIndex, logEvent.LineCount); GetLogEventResponse response = new GetLogEventResponse(logEvent, data, issueId); responses.Add(PropertyFilter.Apply(response, filter)); } } return responses; } /// /// Authorize the current user to see an issue /// /// The issue to authorize /// Cache of permissions /// True if the user is authorized to see the issue private async Task AuthorizeIssue(IIssueDetails issue, StreamPermissionsCache? permissionsCache) { foreach (StreamId streamId in issue.Spans.Select(x => x.StreamId).Distinct()) { if (await _streamService.AuthorizeAsync(streamId, AclAction.ViewStream, User, permissionsCache)) { return true; } } return false; } /// /// Update an issue /// /// Id of the issue to get information about /// The update information /// List of matching agents [HttpPut] [Route("/api/v1/issues/{issueId}")] public async Task UpdateIssueAsync(int issueId, [FromBody] UpdateIssueRequest request) { UserId? newOwnerId = null; if (request.OwnerId != null) { newOwnerId = request.OwnerId.Length == 0 ? UserId.Empty : new UserId(request.OwnerId); } UserId? newNominatedById = null; if (request.NominatedById != null) { newNominatedById = new UserId(request.NominatedById); } else if (request.OwnerId != null) { newNominatedById = User.GetUserId(); } UserId? newDeclinedById = null; if (request.Declined ?? false) { newDeclinedById = User.GetUserId(); } UserId? newResolvedById = null; if (request.Resolved.HasValue) { newResolvedById = request.Resolved.Value ? User.GetUserId() : UserId.Empty; } List? addSpans = null; if (request.AddSpans != null && request.AddSpans.Count > 0) { addSpans = request.AddSpans.ConvertAll(x => ObjectId.Parse(x)); } List? removeSpans = null; if (request.RemoveSpans != null && request.RemoveSpans.Count > 0) { removeSpans = request.RemoveSpans.ConvertAll(x => ObjectId.Parse(x)); } if (!await _issueService.UpdateIssueAsync(issueId, request.Summary, request.Description, request.Promoted, newOwnerId, newNominatedById, request.Acknowledged, newDeclinedById, request.FixChange, newResolvedById, addSpans, removeSpans)) { return NotFound(); } return Ok(); } } }