From 1e9309e2b51e56d20ce95138f7ed63b29e1d437a Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Thu, 9 Jul 2026 17:56:00 -0500 Subject: [PATCH] M5: Workflow runs list plus re-run/cancel/trigger Add a repository-scoped Workflow Runs page (DynamicListPage keyed on an owner/repo query) that lists recent GitHub Actions runs, with per-run re-run (non-destructive), cancel (destructive, confirmed), open-in-browser, and copy URL actions. Add a top-level Trigger Workflow form (workflow_dispatch) built on the existing FormContent infrastructure. - IWorkflowRun / WorkflowRun data type; IWorkflowRunsDataManager / WorkflowRunsDataManager (Octokit Actions API: list, rerun, cancel, dispatch), reusing GitHubCreateManager.ParseOwnerRepo for owner/repo parsing. - WorkflowRunsMediator + WorkflowRunActionCommand mirror the notifications/ mutation event-driven refresh pattern; cancel is wrapped in ConfirmedCommand and marked critical. - Top-level Workflow Runs and Trigger Workflow commands; TriggerWorkflowForm + template; LinkCommand URL overload; Workflows icon; ResW strings. - Tests: WorkflowRunsTests (form JSON, prompt/none/list rendering, cancel visibility by run status). 192/192 tests pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Controls/WorkflowRunsTests.cs | 140 ++++++++++++++ .../Helpers/TestSetupHelpers.cs | 5 +- .../Controls/Commands/LinkCommand.cs | 7 + .../Commands/WorkflowRunActionCommand.cs | 51 ++++++ .../Controls/Forms/TriggerWorkflowForm.cs | 77 ++++++++ GitHubExtension/Controls/IWorkflowRun.cs | 28 +++ .../Controls/Pages/WorkflowRunsPage.cs | 172 ++++++++++++++++++ .../Templates/TriggerWorkflowTemplate.json | 51 ++++++ GitHubExtension/Controls/WorkflowRun.cs | 28 +++ .../Controls/WorkflowRunsMediator.cs | 19 ++ .../Data/WorkflowRunsDataManager.cs | 80 ++++++++ .../DataManager/IWorkflowRunsDataManager.cs | 24 +++ .../GitHubExtensionCommandsProvider.cs | 36 +++- GitHubExtension/Helpers/GitHubIcon.cs | 1 + GitHubExtension/Helpers/TemplateHelper.cs | 1 + GitHubExtension/Program.cs | 6 +- GitHubExtension/Strings/en-US/Resources.resw | 108 +++++++++++ 17 files changed, 831 insertions(+), 3 deletions(-) create mode 100644 GitHubExtension.Test/Controls/WorkflowRunsTests.cs create mode 100644 GitHubExtension/Controls/Commands/WorkflowRunActionCommand.cs create mode 100644 GitHubExtension/Controls/Forms/TriggerWorkflowForm.cs create mode 100644 GitHubExtension/Controls/IWorkflowRun.cs create mode 100644 GitHubExtension/Controls/Pages/WorkflowRunsPage.cs create mode 100644 GitHubExtension/Controls/Templates/TriggerWorkflowTemplate.json create mode 100644 GitHubExtension/Controls/WorkflowRun.cs create mode 100644 GitHubExtension/Controls/WorkflowRunsMediator.cs create mode 100644 GitHubExtension/DataManager/Data/WorkflowRunsDataManager.cs create mode 100644 GitHubExtension/DataManager/IWorkflowRunsDataManager.cs diff --git a/GitHubExtension.Test/Controls/WorkflowRunsTests.cs b/GitHubExtension.Test/Controls/WorkflowRunsTests.cs new file mode 100644 index 0000000..3689c50 --- /dev/null +++ b/GitHubExtension.Test/Controls/WorkflowRunsTests.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using GitHubExtension.Controls; +using GitHubExtension.Controls.Forms; +using GitHubExtension.Controls.Pages; +using GitHubExtension.DataManager; +using GitHubExtension.Helpers; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Moq; + +namespace GitHubExtension.Test.Controls; + +[TestClass] +public class WorkflowRunsTests +{ + private static Mock CreateResources() + { + var resources = new Mock(); + resources.Setup(x => x.GetResource(It.IsAny(), null)).Returns((key, _) => key); + return resources; + } + + private static Mock CreateRun(long id, string status, string conclusion) + { + var run = new Mock(); + run.Setup(x => x.Id).Returns(id); + run.Setup(x => x.Name).Returns("CI"); + run.Setup(x => x.RunNumber).Returns(id); + run.Setup(x => x.Status).Returns(status); + run.Setup(x => x.Conclusion).Returns(conclusion); + run.Setup(x => x.HeadBranch).Returns("main"); + run.Setup(x => x.HtmlUrl).Returns($"https://github.com/owner/repo/actions/runs/{id}"); + return run; + } + + [TestMethod] + [TestCategory("Unit")] + public void TriggerWorkflowForm_TemplateJson_IsValidJson() + { + var dataManager = new Mock(); + var form = new TriggerWorkflowForm(dataManager.Object, CreateResources().Object, "owner/repo"); + + var json = form.TemplateJson; + + var parsed = JsonDocument.Parse(json); + Assert.AreEqual("AdaptiveCard", parsed.RootElement.GetProperty("type").GetString()); + } + + [TestMethod] + [TestCategory("Unit")] + public void WorkflowRunsPage_EmptyQuery_ShowsPrompt() + { + var dataManager = new Mock(); + var page = new WorkflowRunsPage(dataManager.Object, new WorkflowRunsMediator(), CreateResources().Object); + + var items = page.GetItems(); + + Assert.AreEqual(1, items.Length); + Assert.AreEqual("Pages_WorkflowRuns_Prompt", items[0].Title); + dataManager.Verify(x => x.GetWorkflowRunsAsync(It.IsAny()), Times.Never); + } + + [TestMethod] + [TestCategory("Unit")] + public void WorkflowRunsPage_WithRuns_ReturnsItems() + { + var dataManager = new Mock(); + dataManager.Setup(x => x.GetWorkflowRunsAsync("owner/repo")) + .ReturnsAsync(new[] { CreateRun(1, "completed", "success").Object, CreateRun(2, "completed", "failure").Object }); + + var page = new WorkflowRunsPage(dataManager.Object, new WorkflowRunsMediator(), CreateResources().Object) + { + SearchText = "owner/repo", + }; + + var items = page.GetItems(); + + Assert.AreEqual(2, items.Length); + } + + [TestMethod] + [TestCategory("Unit")] + public void WorkflowRunsPage_InProgressRun_HasCancelCommand() + { + var dataManager = new Mock(); + dataManager.Setup(x => x.GetWorkflowRunsAsync("owner/repo")) + .ReturnsAsync(new[] { CreateRun(1, "in_progress", string.Empty).Object }); + + var page = new WorkflowRunsPage(dataManager.Object, new WorkflowRunsMediator(), CreateResources().Object) + { + SearchText = "owner/repo", + }; + + var item = (ListItem)page.GetItems()[0]; + + // Re-run + Cancel + Copy URL + Assert.AreEqual(3, item.MoreCommands.Length); + } + + [TestMethod] + [TestCategory("Unit")] + public void WorkflowRunsPage_CompletedRun_HasNoCancelCommand() + { + var dataManager = new Mock(); + dataManager.Setup(x => x.GetWorkflowRunsAsync("owner/repo")) + .ReturnsAsync(new[] { CreateRun(1, "completed", "success").Object }); + + var page = new WorkflowRunsPage(dataManager.Object, new WorkflowRunsMediator(), CreateResources().Object) + { + SearchText = "owner/repo", + }; + + var item = (ListItem)page.GetItems()[0]; + + // Re-run + Copy URL (no cancel for a completed run) + Assert.AreEqual(2, item.MoreCommands.Length); + } + + [TestMethod] + [TestCategory("Unit")] + public void WorkflowRunsPage_NoRuns_ShowsNoneMessage() + { + var dataManager = new Mock(); + dataManager.Setup(x => x.GetWorkflowRunsAsync("owner/repo")) + .ReturnsAsync(Array.Empty()); + + var page = new WorkflowRunsPage(dataManager.Object, new WorkflowRunsMediator(), CreateResources().Object) + { + SearchText = "owner/repo", + }; + + var items = page.GetItems(); + + Assert.AreEqual(1, items.Length); + Assert.AreEqual("Pages_WorkflowRuns_None", items[0].Title); + } +} diff --git a/GitHubExtension.Test/Helpers/TestSetupHelpers.cs b/GitHubExtension.Test/Helpers/TestSetupHelpers.cs index 9a4083d..1ed4b69 100644 --- a/GitHubExtension.Test/Helpers/TestSetupHelpers.cs +++ b/GitHubExtension.Test/Helpers/TestSetupHelpers.cs @@ -176,6 +176,9 @@ public static GitHubExtensionCommandsProvider CreateGitHubExtensionCommandsProvi mockNotificationsDataManager.Setup(x => x.GetUnreadCountAsync()).ReturnsAsync(0); var notificationsPage = new NotificationsPage(mockNotificationsDataManager.Object, notificationsMediator, mockResources); var mockCreateManager = new Mock().Object; - return new GitHubExtensionCommandsProvider(savedSearchesPage, signOutPage, signInPage, notificationsPage, mockDeveloperIdProvider, persistentDataManager, mockResources, searchPageFactory, savedSearchesMediator, mockAuthenticationMediator, notificationsMediator, mockCreateManager); + var workflowRunsMediator = new WorkflowRunsMediator(); + var mockWorkflowRunsDataManager = new Mock(); + var workflowRunsPage = new WorkflowRunsPage(mockWorkflowRunsDataManager.Object, workflowRunsMediator, mockResources); + return new GitHubExtensionCommandsProvider(savedSearchesPage, signOutPage, signInPage, notificationsPage, mockDeveloperIdProvider, persistentDataManager, mockResources, searchPageFactory, savedSearchesMediator, mockAuthenticationMediator, notificationsMediator, mockCreateManager, workflowRunsPage, mockWorkflowRunsDataManager.Object); } } diff --git a/GitHubExtension/Controls/Commands/LinkCommand.cs b/GitHubExtension/Controls/Commands/LinkCommand.cs index 5daf0cf..4b50b68 100644 --- a/GitHubExtension/Controls/Commands/LinkCommand.cs +++ b/GitHubExtension/Controls/Commands/LinkCommand.cs @@ -40,6 +40,13 @@ internal LinkCommand(INotification notification, IResources resources) Icon = new IconInfo("\uE8A7"); } + internal LinkCommand(string htmlUrl, IResources resources) + { + _htmlUrl = htmlUrl; + Name = resources.GetResource("Commands_Open_Link"); + Icon = new IconInfo("\uE8A7"); + } + public override CommandResult Invoke() { Process.Start(new ProcessStartInfo(_htmlUrl) { UseShellExecute = true }); diff --git a/GitHubExtension/Controls/Commands/WorkflowRunActionCommand.cs b/GitHubExtension/Controls/Commands/WorkflowRunActionCommand.cs new file mode 100644 index 0000000..54aa9c0 --- /dev/null +++ b/GitHubExtension/Controls/Commands/WorkflowRunActionCommand.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using GitHubExtension.DataManager; +using GitHubExtension.Helpers; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Serilog; + +namespace GitHubExtension.Controls.Commands; + +// Runs a workflow-run action (re-run or cancel) against GitHub, showing a +// success or error toast and notifying the workflow-runs mediator so the open +// page can refresh. Non-destructive actions use this command directly; +// destructive actions wrap it in a ConfirmedCommand. +internal sealed partial class WorkflowRunActionCommand : InvokableCommand +{ + private static readonly ILogger _log = Log.ForContext("SourceContext", nameof(WorkflowRunActionCommand)); + + private readonly Func _action; + private readonly string _successMessage; + private readonly string _errorMessage; + private readonly WorkflowRunsMediator _mediator; + + internal WorkflowRunActionCommand(string name, IconInfo icon, Func action, string successMessage, string errorMessage, WorkflowRunsMediator mediator) + { + Name = name; + Icon = icon; + _action = action; + _successMessage = successMessage; + _errorMessage = errorMessage; + _mediator = mediator; + } + + public override CommandResult Invoke() + { + try + { + _action().GetAwaiter().GetResult(); + ToastHelper.ShowSuccessToast(_successMessage); + _mediator.NotifyWorkflowRunsChanged(); + } + catch (Exception ex) + { + _log.Error(ex, "Workflow run action failed."); + ToastHelper.ShowErrorToast(_errorMessage); + } + + return CommandResult.KeepOpen(); + } +} diff --git a/GitHubExtension/Controls/Forms/TriggerWorkflowForm.cs b/GitHubExtension/Controls/Forms/TriggerWorkflowForm.cs new file mode 100644 index 0000000..67786c4 --- /dev/null +++ b/GitHubExtension/Controls/Forms/TriggerWorkflowForm.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Nodes; +using GitHubExtension.DataManager; +using GitHubExtension.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace GitHubExtension.Controls.Forms; + +public sealed partial class TriggerWorkflowForm : FormContent, IGitHubForm +{ + private readonly IWorkflowRunsDataManager _dataManager; + private readonly IResources _resources; + private readonly string _initialRepository; + + public event EventHandler? LoadingStateChanged; + + public event EventHandler? FormSubmitted; + + public TriggerWorkflowForm(IWorkflowRunsDataManager dataManager, IResources resources, string initialRepository = "") + { + _dataManager = dataManager; + _resources = resources; + _initialRepository = initialRepository; + } + + public Dictionary TemplateSubstitutions => new() + { + { "{{TriggerWorkflowFormTitle}}", JsonSerializer.Serialize(_resources.GetResource("Forms_TriggerWorkflow_Title")) }, + { "{{RepositoryLabel}}", JsonSerializer.Serialize(_resources.GetResource("Forms_Create_RepositoryLabel")) }, + { "{{RepositoryPlaceholder}}", JsonSerializer.Serialize(_resources.GetResource("Forms_Create_RepositoryPlaceholder")) }, + { "{{RepositoryErrorMessage}}", JsonSerializer.Serialize(_resources.GetResource("Forms_Create_RepositoryError")) }, + { "{{RepositoryValue}}", JsonSerializer.Serialize(_initialRepository) }, + { "{{WorkflowFileLabel}}", JsonSerializer.Serialize(_resources.GetResource("Forms_TriggerWorkflow_WorkflowFileLabel")) }, + { "{{WorkflowFilePlaceholder}}", JsonSerializer.Serialize(_resources.GetResource("Forms_TriggerWorkflow_WorkflowFilePlaceholder")) }, + { "{{WorkflowFileErrorMessage}}", JsonSerializer.Serialize(_resources.GetResource("Forms_TriggerWorkflow_WorkflowFileError")) }, + { "{{RefLabel}}", JsonSerializer.Serialize(_resources.GetResource("Forms_TriggerWorkflow_RefLabel")) }, + { "{{RefPlaceholder}}", JsonSerializer.Serialize(_resources.GetResource("Forms_TriggerWorkflow_RefPlaceholder")) }, + { "{{RefErrorMessage}}", JsonSerializer.Serialize(_resources.GetResource("Forms_TriggerWorkflow_RefError")) }, + { "{{RefValue}}", JsonSerializer.Serialize("main") }, + { "{{TriggerWorkflowActionTitle}}", JsonSerializer.Serialize(_resources.GetResource("Forms_TriggerWorkflow_Action")) }, + }; + + public override string TemplateJson => TemplateHelper.LoadTemplateJsonFromTemplateName("TriggerWorkflow", TemplateSubstitutions); + + public override ICommandResult SubmitForm(string? inputs, string data) + { + LoadingStateChanged?.Invoke(this, true); + _ = SubmitInternalAsync(inputs); + return CommandResult.KeepOpen(); + } + + private async Task SubmitInternalAsync(string? inputs) + { + try + { + var payload = JsonNode.Parse(inputs ?? string.Empty) ?? throw new InvalidOperationException("No input provided."); + var repository = payload["Repository"]?.ToString() ?? string.Empty; + var workflowFile = payload["WorkflowFile"]?.ToString() ?? string.Empty; + var gitRef = payload["Ref"]?.ToString() ?? string.Empty; + + await _dataManager.TriggerWorkflowAsync(repository, workflowFile, gitRef); + + LoadingStateChanged?.Invoke(this, false); + FormSubmitted?.Invoke(this, new FormSubmitEventArgs(true, null)); + } + catch (Exception ex) + { + LoadingStateChanged?.Invoke(this, false); + FormSubmitted?.Invoke(this, new FormSubmitEventArgs(false, ex)); + } + } +} diff --git a/GitHubExtension/Controls/IWorkflowRun.cs b/GitHubExtension/Controls/IWorkflowRun.cs new file mode 100644 index 0000000..4fb000e --- /dev/null +++ b/GitHubExtension/Controls/IWorkflowRun.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace GitHubExtension.Controls; + +public interface IWorkflowRun +{ + long Id { get; } + + string Name { get; } + + string Status { get; } + + string Conclusion { get; } + + string HeadBranch { get; } + + string TriggerEvent { get; } + + long RunNumber { get; } + + string HtmlUrl { get; } + + string RepositoryFullName { get; } + + DateTimeOffset CreatedAt { get; } +} diff --git a/GitHubExtension/Controls/Pages/WorkflowRunsPage.cs b/GitHubExtension/Controls/Pages/WorkflowRunsPage.cs new file mode 100644 index 0000000..500b356 --- /dev/null +++ b/GitHubExtension/Controls/Pages/WorkflowRunsPage.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using GitHubExtension.Controls.Commands; +using GitHubExtension.DataManager; +using GitHubExtension.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Serilog; + +namespace GitHubExtension.Controls.Pages; + +// Repository-scoped workflow runs page. Because workflow runs require a +// repository, the user types owner/repo into the search box and the page loads +// the most recent runs for that repository. Per-run actions include re-run +// (non-destructive) and cancel (destructive, confirmed), plus open in browser +// and copy URL. +public sealed partial class WorkflowRunsPage : DynamicListPage +{ + private readonly IWorkflowRunsDataManager _dataManager; + private readonly WorkflowRunsMediator _mediator; + private readonly IResources _resources; + private readonly ILogger _log; + + public WorkflowRunsPage(IWorkflowRunsDataManager dataManager, WorkflowRunsMediator mediator, IResources resources) + { + _dataManager = dataManager; + _mediator = mediator; + _resources = resources; + _log = Log.ForContext("SourceContext", $"Pages/{nameof(WorkflowRunsPage)}"); + + Name = resources.GetResource("Pages_WorkflowRuns"); + Icon = GitHubIcon.IconDictionary["Workflows"]; + PlaceholderText = resources.GetResource("Pages_WorkflowRuns_Placeholder"); + + _mediator.WorkflowRunsChanged += OnWorkflowRunsChanged; + } + + private void OnWorkflowRunsChanged(object? sender, object? args) + { + RaiseItemsChanged(0); + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + RaiseItemsChanged(0); + } + + public override IListItem[] GetItems() => DoGetItems(SearchText).GetAwaiter().GetResult(); + + private async Task DoGetItems(string query) + { + var repository = (query ?? string.Empty).Trim(); + + if (string.IsNullOrEmpty(repository)) + { + return new IListItem[] + { + new ListItem(new NoOpCommand()) + { + Title = _resources.GetResource("Pages_WorkflowRuns_Prompt"), + Icon = GitHubIcon.IconDictionary["Workflows"], + }, + }; + } + + try + { + var runs = (await _dataManager.GetWorkflowRunsAsync(repository)).ToList(); + + if (runs.Count == 0) + { + return new IListItem[] + { + new ListItem(new NoOpCommand()) + { + Title = _resources.GetResource("Pages_WorkflowRuns_None"), + Icon = GitHubIcon.IconDictionary["Workflows"], + }, + }; + } + + return runs.Select(run => GetListItem(repository, run)).ToArray(); + } + catch (Exception ex) + { + _log.Error(ex, "Failed to load workflow runs."); + return new IListItem[] + { + new ListItem(new NoOpCommand()) + { + Title = _resources.GetResource("Pages_Error_Title"), + Details = new Details() + { + Title = ex.Message, + Body = string.IsNullOrEmpty(ex.StackTrace) ? "There is no stack trace for the error." : ex.StackTrace, + }, + }, + }; + } + } + + private ListItem GetListItem(string repository, IWorkflowRun run) + { + var rerunCommand = new WorkflowRunActionCommand( + _resources.GetResource("Commands_RerunWorkflow"), + new IconInfo("\uE72C"), + () => _dataManager.RerunAsync(repository, run.Id), + _resources.GetResource("Message_RerunWorkflow_Success"), + _resources.GetResource("Message_RerunWorkflow_Error"), + _mediator); + + var cancelInner = new WorkflowRunActionCommand( + _resources.GetResource("Commands_CancelWorkflow"), + new IconInfo("\uE711"), + () => _dataManager.CancelAsync(repository, run.Id), + _resources.GetResource("Message_CancelWorkflow_Success"), + _resources.GetResource("Message_CancelWorkflow_Error"), + _mediator); + + var cancelCommand = new ConfirmedCommand( + cancelInner, + _resources.GetResource("Commands_CancelWorkflow"), + new IconInfo("\uE711"), + _resources.GetResource("Confirm_CancelWorkflow_Title"), + _resources.GetResource("Confirm_CancelWorkflow_Description")); + + var moreCommands = new List + { + new(rerunCommand), + }; + + if (IsCancellable(run.Status)) + { + moreCommands.Add(new CommandContextItem(cancelCommand) { IsCritical = true }); + } + + moreCommands.Add(new CommandContextItem(new CopyCommand(run.HtmlUrl, _resources.GetResource("Commands_CopyURL"), _resources))); + + return new ListItem(new LinkCommand(run.HtmlUrl, _resources)) + { + Title = GetTitle(run), + Subtitle = GetSubtitle(run), + Icon = GitHubIcon.IconDictionary["Workflows"], + MoreCommands = moreCommands.ToArray(), + }; + } + + private string GetTitle(IWorkflowRun run) + { + var name = string.IsNullOrEmpty(run.Name) ? _resources.GetResource("Pages_WorkflowRuns_UnnamedRun") : run.Name; + return $"{name} #{run.RunNumber}"; + } + + private string GetSubtitle(IWorkflowRun run) + { + var state = string.IsNullOrEmpty(run.Conclusion) ? run.Status : run.Conclusion; + return string.IsNullOrEmpty(run.HeadBranch) ? state : $"{run.HeadBranch} - {state}"; + } + + private static bool IsCancellable(string status) + { + return status.Equals("queued", StringComparison.OrdinalIgnoreCase) + || status.Equals("in_progress", StringComparison.OrdinalIgnoreCase) + || status.Equals("InProgress", StringComparison.OrdinalIgnoreCase) + || status.Equals("Queued", StringComparison.OrdinalIgnoreCase) + || status.Equals("requested", StringComparison.OrdinalIgnoreCase) + || status.Equals("waiting", StringComparison.OrdinalIgnoreCase) + || status.Equals("pending", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GitHubExtension/Controls/Templates/TriggerWorkflowTemplate.json b/GitHubExtension/Controls/Templates/TriggerWorkflowTemplate.json new file mode 100644 index 0000000..443e058 --- /dev/null +++ b/GitHubExtension/Controls/Templates/TriggerWorkflowTemplate.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "text": {{TriggerWorkflowFormTitle}}, + "horizontalAlignment": "Center", + "wrap": true, + "style": "heading" + }, + { + "type": "Input.Text", + "id": "Repository", + "label": {{RepositoryLabel}}, + "placeholder": {{RepositoryPlaceholder}}, + "isRequired": true, + "errorMessage": {{RepositoryErrorMessage}}, + "value": {{RepositoryValue}} + }, + { + "type": "Input.Text", + "id": "WorkflowFile", + "label": {{WorkflowFileLabel}}, + "placeholder": {{WorkflowFilePlaceholder}}, + "isRequired": true, + "errorMessage": {{WorkflowFileErrorMessage}} + }, + { + "type": "Input.Text", + "id": "Ref", + "label": {{RefLabel}}, + "placeholder": {{RefPlaceholder}}, + "isRequired": true, + "errorMessage": {{RefErrorMessage}}, + "value": {{RefValue}} + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": {{TriggerWorkflowActionTitle}}, + "data": { + "id": "TriggerWorkflowAction" + } + } + ] +} diff --git a/GitHubExtension/Controls/WorkflowRun.cs b/GitHubExtension/Controls/WorkflowRun.cs new file mode 100644 index 0000000..0c587fc --- /dev/null +++ b/GitHubExtension/Controls/WorkflowRun.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace GitHubExtension.Controls; + +public sealed class WorkflowRun : IWorkflowRun +{ + public long Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public string Conclusion { get; set; } = string.Empty; + + public string HeadBranch { get; set; } = string.Empty; + + public string TriggerEvent { get; set; } = string.Empty; + + public long RunNumber { get; set; } + + public string HtmlUrl { get; set; } = string.Empty; + + public string RepositoryFullName { get; set; } = string.Empty; + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/GitHubExtension/Controls/WorkflowRunsMediator.cs b/GitHubExtension/Controls/WorkflowRunsMediator.cs new file mode 100644 index 0000000..b7895d1 --- /dev/null +++ b/GitHubExtension/Controls/WorkflowRunsMediator.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace GitHubExtension.Controls; + +public class WorkflowRunsMediator +{ + public event EventHandler? WorkflowRunsChanged; + + public WorkflowRunsMediator() + { + } + + public void NotifyWorkflowRunsChanged(object? args = null) + { + WorkflowRunsChanged?.Invoke(this, args); + } +} diff --git a/GitHubExtension/DataManager/Data/WorkflowRunsDataManager.cs b/GitHubExtension/DataManager/Data/WorkflowRunsDataManager.cs new file mode 100644 index 0000000..a9bca18 --- /dev/null +++ b/GitHubExtension/DataManager/Data/WorkflowRunsDataManager.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using GitHubExtension.Client; +using GitHubExtension.Controls; +using Octokit; +using Serilog; + +namespace GitHubExtension.DataManager.Data; + +public sealed class WorkflowRunsDataManager : IWorkflowRunsDataManager +{ + private const int DefaultPageSize = 30; + + private static readonly Lazy _logger = new(() => Serilog.Log.ForContext("SourceContext", nameof(WorkflowRunsDataManager))); + + private static readonly ILogger _log = _logger.Value; + + private readonly GitHubClientProvider _gitHubClientProvider; + + public WorkflowRunsDataManager(GitHubClientProvider gitHubClientProvider) + { + _gitHubClientProvider = gitHubClientProvider; + } + + public async Task> GetWorkflowRunsAsync(string ownerRepo) + { + var (owner, repo) = GitHubCreateManager.ParseOwnerRepo(ownerRepo); + var client = await _gitHubClientProvider.GetClientForLoggedInDeveloper(false); + + var options = new ApiOptions { PageCount = 1, PageSize = DefaultPageSize, StartPage = 1 }; + var response = await client.Actions.Workflows.Runs.List(owner, repo, new WorkflowRunsRequest(), options); + + _log.Debug($"Retrieved {response.WorkflowRuns.Count} workflow runs for {owner}/{repo}."); + + return response.WorkflowRuns.Select(run => ToWorkflowRun(run, $"{owner}/{repo}")).ToList(); + } + + public async Task RerunAsync(string ownerRepo, long runId) + { + var (owner, repo) = GitHubCreateManager.ParseOwnerRepo(ownerRepo); + var client = await _gitHubClientProvider.GetClientForLoggedInDeveloper(false); + await client.Actions.Workflows.Runs.Rerun(owner, repo, runId); + _log.Information($"Re-ran workflow run {runId} in {owner}/{repo}."); + } + + public async Task CancelAsync(string ownerRepo, long runId) + { + var (owner, repo) = GitHubCreateManager.ParseOwnerRepo(ownerRepo); + var client = await _gitHubClientProvider.GetClientForLoggedInDeveloper(false); + await client.Actions.Workflows.Runs.Cancel(owner, repo, runId); + _log.Information($"Cancelled workflow run {runId} in {owner}/{repo}."); + } + + public async Task TriggerWorkflowAsync(string ownerRepo, string workflowFileName, string gitRef) + { + var (owner, repo) = GitHubCreateManager.ParseOwnerRepo(ownerRepo); + var client = await _gitHubClientProvider.GetClientForLoggedInDeveloper(false); + await client.Actions.Workflows.CreateDispatch(owner, repo, workflowFileName, new CreateWorkflowDispatch(gitRef)); + _log.Information($"Triggered workflow {workflowFileName} on {gitRef} in {owner}/{repo}."); + } + + private static Controls.WorkflowRun ToWorkflowRun(Octokit.WorkflowRun run, string repositoryFullName) + { + return new Controls.WorkflowRun + { + Id = run.Id, + Name = run.Name ?? string.Empty, + Status = run.Status.StringValue ?? string.Empty, + Conclusion = run.Conclusion.HasValue ? (run.Conclusion.Value.StringValue ?? string.Empty) : string.Empty, + HeadBranch = run.HeadBranch ?? string.Empty, + TriggerEvent = run.Event ?? string.Empty, + RunNumber = run.RunNumber, + HtmlUrl = run.HtmlUrl ?? string.Empty, + RepositoryFullName = repositoryFullName, + CreatedAt = run.CreatedAt, + }; + } +} diff --git a/GitHubExtension/DataManager/IWorkflowRunsDataManager.cs b/GitHubExtension/DataManager/IWorkflowRunsDataManager.cs new file mode 100644 index 0000000..f78ba7b --- /dev/null +++ b/GitHubExtension/DataManager/IWorkflowRunsDataManager.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using GitHubExtension.Controls; + +namespace GitHubExtension.DataManager; + +public interface IWorkflowRunsDataManager +{ + // Fetches the most recent workflow runs for the given repository. The + // repository may be provided as the short owner/repo form or a full URL. + Task> GetWorkflowRunsAsync(string ownerRepo); + + // Re-runs all jobs for a workflow run. This is a non-destructive action. + Task RerunAsync(string ownerRepo, long runId); + + // Cancels an in-progress workflow run. This is a destructive action. + Task CancelAsync(string ownerRepo, long runId); + + // Triggers a workflow_dispatch event for the given workflow file on the + // provided git reference (branch or tag). + Task TriggerWorkflowAsync(string ownerRepo, string workflowFileName, string gitRef); +} diff --git a/GitHubExtension/GitHubExtensionCommandsProvider.cs b/GitHubExtension/GitHubExtensionCommandsProvider.cs index 0d64c7e..b37d008 100644 --- a/GitHubExtension/GitHubExtensionCommandsProvider.cs +++ b/GitHubExtension/GitHubExtensionCommandsProvider.cs @@ -28,6 +28,8 @@ public partial class GitHubExtensionCommandsProvider : CommandProvider, IDisposa private readonly AuthenticationMediator _authenticationMediator; private readonly NotificationsMediator _notificationsMediator; private readonly IGitHubCreateManager _createManager; + private readonly WorkflowRunsPage _workflowRunsPage; + private readonly IWorkflowRunsDataManager _workflowRunsDataManager; public GitHubExtensionCommandsProvider( SavedSearchesPage savedSearchesPage, @@ -41,7 +43,9 @@ public GitHubExtensionCommandsProvider( SavedSearchesMediator savedSearchesMediator, AuthenticationMediator authenticationMediator, NotificationsMediator notificationsMediator, - IGitHubCreateManager createManager) + IGitHubCreateManager createManager, + WorkflowRunsPage workflowRunsPage, + IWorkflowRunsDataManager workflowRunsDataManager) { _savedSearchesPage = savedSearchesPage; _signOutPage = signOutPage; @@ -55,6 +59,8 @@ public GitHubExtensionCommandsProvider( _authenticationMediator = authenticationMediator; _notificationsMediator = notificationsMediator; _createManager = createManager; + _workflowRunsPage = workflowRunsPage; + _workflowRunsDataManager = workflowRunsDataManager; DisplayName = _resources.GetResource("ExtensionTitle"); @@ -114,6 +120,8 @@ public override ICommandItem[] TopLevelCommands() BuildCreateIssueCommandItem(), BuildCreatePullRequestCommandItem(), BuildCreateBranchCommandItem(), + BuildWorkflowRunsCommandItem(), + BuildTriggerWorkflowCommandItem(), new(_savedSearchesPage), new(_signOutPage), }; @@ -173,6 +181,32 @@ private CommandItem BuildCreateBranchCommandItem() }; } + private CommandItem BuildWorkflowRunsCommandItem() + { + return new CommandItem(_workflowRunsPage) + { + Title = _resources.GetResource("CommandsProvider_WorkflowRunsCommandName"), + Subtitle = _resources.GetResource("CommandsProvider_WorkflowRunsSubtitle"), + }; + } + + private CommandItem BuildTriggerWorkflowCommandItem() + { + var page = new GitHubFormPage( + new TriggerWorkflowForm(_workflowRunsDataManager, _resources), + _resources, + "Forms_TriggerWorkflow_Title", + "\uE724", + "Message_TriggerWorkflow_Success", + "Message_TriggerWorkflow_Error"); + + return new CommandItem(page) + { + Title = _resources.GetResource("CommandsProvider_TriggerWorkflowCommandName"), + Subtitle = _resources.GetResource("CommandsProvider_TriggerWorkflowSubtitle"), + }; + } + private CommandItem BuildNotificationsCommandItem() { var subtitle = _notificationsPage.UnreadCount > 0 diff --git a/GitHubExtension/Helpers/GitHubIcon.cs b/GitHubExtension/Helpers/GitHubIcon.cs index 124ea55..b445ad0 100644 --- a/GitHubExtension/Helpers/GitHubIcon.cs +++ b/GitHubExtension/Helpers/GitHubIcon.cs @@ -26,6 +26,7 @@ static GitHubIcon() { "Repositories", new IconInfo("\uE8F1") }, { "notification", new IconInfo("\uEA8F") }, { "Notifications", new IconInfo("\uEA8F") }, + { "Workflows", new IconInfo("\uE9F5") }, }; } diff --git a/GitHubExtension/Helpers/TemplateHelper.cs b/GitHubExtension/Helpers/TemplateHelper.cs index 59011e3..c1f8d10 100644 --- a/GitHubExtension/Helpers/TemplateHelper.cs +++ b/GitHubExtension/Helpers/TemplateHelper.cs @@ -18,6 +18,7 @@ public static string GetTemplatePath(string page) "CreatePullRequest" => "Controls\\Templates\\CreatePullRequestTemplate.json", "CreateBranch" => "Controls\\Templates\\CreateBranchTemplate.json", "AddComment" => "Controls\\Templates\\AddCommentTemplate.json", + "TriggerWorkflow" => "Controls\\Templates\\TriggerWorkflowTemplate.json", _ => throw new NotImplementedException($"Template for page '{page}' is not implemented."), }; } diff --git a/GitHubExtension/Program.cs b/GitHubExtension/Program.cs index 1da3c10..f55b8af 100644 --- a/GitHubExtension/Program.cs +++ b/GitHubExtension/Program.cs @@ -133,10 +133,14 @@ private static async Task HandleCOMServerActivationAsync() var savedSearchesMediator = new SavedSearchesMediator(); var notificationsMediator = new NotificationsMediator(); var mutationMediator = new MutationMediator(); + var workflowRunsMediator = new WorkflowRunsMediator(); var notificationsDataManager = new NotificationsDataManager(gitHubClientProvider); var notificationsPage = new NotificationsPage(notificationsDataManager, notificationsMediator, resources); + var workflowRunsDataManager = new WorkflowRunsDataManager(gitHubClientProvider); + var workflowRunsPage = new WorkflowRunsPage(workflowRunsDataManager, workflowRunsMediator, resources); + var mutationManager = new GitHubMutationManager(gitHubClientProvider); var createManager = new GitHubCreateManager(gitHubClientProvider); var mutationCommandsFactory = new MutationCommandsFactory(mutationManager, createManager, mutationMediator, resources); @@ -155,7 +159,7 @@ private static async Task HandleCOMServerActivationAsync() using var signInForm = new SignInForm(authenticationMediator, resources, developerIdProvider, signInCommand); using var signInPage = new SignInPage(signInForm, resources, signInCommand, authenticationMediator); - using var commandProvider = new GitHubExtensionCommandsProvider(savedSearchesPage, signOutPage, signInPage, notificationsPage, developerIdProvider, searchRepository, resources, searchPageFactory, savedSearchesMediator, authenticationMediator, notificationsMediator, createManager); + using var commandProvider = new GitHubExtensionCommandsProvider(savedSearchesPage, signOutPage, signInPage, notificationsPage, developerIdProvider, searchRepository, resources, searchPageFactory, savedSearchesMediator, authenticationMediator, notificationsMediator, createManager, workflowRunsPage, workflowRunsDataManager); var extensionInstance = new GitHubExtension(extensionDisposedEvent, commandProvider); // We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called. diff --git a/GitHubExtension/Strings/en-US/Resources.resw b/GitHubExtension/Strings/en-US/Resources.resw index dd505f9..d1dab49 100644 --- a/GitHubExtension/Strings/en-US/Resources.resw +++ b/GitHubExtension/Strings/en-US/Resources.resw @@ -705,4 +705,112 @@ Sign in to the GitHub Extension (Preview) The title for the SignInPage + + Workflow runs + Name of the top-level Workflow Runs command + + + View GitHub Actions workflow runs for a repository + Subtitle of the top-level Workflow Runs command + + + Trigger workflow + Name of the top-level Trigger Workflow command + + + Run a workflow_dispatch event for a repository + Subtitle of the top-level Trigger Workflow command + + + Workflow runs + Title of the Workflow Runs page + + + Type a repository as owner/repo + Placeholder text shown in the Workflow Runs search box + + + Type a repository as owner/repo to view its workflow runs + Prompt shown on the Workflow Runs page before a repository is entered + + + No workflow runs found + Message shown when a repository has no workflow runs + + + Workflow run + Fallback title for a workflow run with no name + + + Re-run workflow + Command name for re-running a workflow run + + + Cancel workflow + Command name for cancelling a workflow run + + + Cancel workflow run? + Confirmation dialog title for cancelling a workflow run + + + This will stop the in-progress workflow run. + Confirmation dialog description for cancelling a workflow run + + + Workflow run restarted + Success toast shown after re-running a workflow run + + + Failed to re-run the workflow + Error toast shown when re-running a workflow run fails + + + Workflow run cancelled + Success toast shown after cancelling a workflow run + + + Failed to cancel the workflow + Error toast shown when cancelling a workflow run fails + + + Workflow triggered + Success toast shown after triggering a workflow + + + Failed to trigger the workflow + Error toast shown when triggering a workflow fails + + + Trigger workflow + Title of the Trigger Workflow form + + + Workflow file + Label for the workflow file input + + + e.g. build.yml + Placeholder for the workflow file input + + + Workflow file is required + Validation error for the workflow file input + + + Branch or tag + Label for the git ref input + + + e.g. main + Placeholder for the git ref input + + + A branch or tag is required + Validation error for the git ref input + + + Trigger workflow + Submit button title for the Trigger Workflow form + \ No newline at end of file