diff --git a/package.json b/package.json index 8b4e7dd9..7cb8a85f 100644 --- a/package.json +++ b/package.json @@ -188,6 +188,16 @@ "title": "Cancel workflow run", "when": "viewItem =~ /run/ && viewItem =~ /cancelable/" }, + { + "command": "github-actions.workflow.run.delete", + "category": "GitHub Actions", + "title": "Delete workflow run", + "when": "viewItem =~ /run/ && viewItem =~ /rerunnable/", + "icon": { + "dark": "resources/icons/dark/remove.svg", + "light": "resources/icons/light/remove.svg" + } + }, { "command": "github-actions.settings.secrets.manage", "category": "GitHub Actions", @@ -405,6 +415,15 @@ "command": "github-actions.workflow.run.rerun", "when": "viewItem =~ /run/ && viewItem =~ /rerunnable/" }, + { + "command": "github-actions.workflow.run.delete", + "when": "viewItem =~ /run/ && viewItem =~ /rerunnable/", + "group": "inline@2" + }, + { + "command": "github-actions.workflow.run.delete", + "when": "viewItem =~ /run/ && viewItem =~ /rerunnable/" + }, { "command": "github-actions.settings.secret.add", "group": "inline", @@ -484,6 +503,10 @@ "command": "github-actions.workflow.run.cancel", "when": "false" }, + { + "command": "github-actions.workflow.run.delete", + "when": "false" + }, { "command": "github-actions.settings.secrets.manage", "when": "false" diff --git a/src/commands/deleteWorkflowRun.test.ts b/src/commands/deleteWorkflowRun.test.ts new file mode 100644 index 00000000..a70f227e --- /dev/null +++ b/src/commands/deleteWorkflowRun.test.ts @@ -0,0 +1,131 @@ +import {beforeEach, describe, expect, it, jest} from "@jest/globals"; +import manifest from "../../package.json"; +import { + DELETE_WORKFLOW_RUN_ACCEPT_TEXT, + DeleteWorkflowRunTarget, + DeleteWorkflowRunUI, + executeDeleteWorkflowRun +} from "./deleteWorkflowRunHandler"; + +describe("github-actions.workflow.run.delete (#293)", () => { + const confirmDeleteMock = jest.fn(); + const executeCommandMock = jest.fn(); + const showErrorMessageMock = jest.fn(); + + const ui: DeleteWorkflowRunUI = { + confirmDelete: confirmDeleteMock, + executeCommand: executeCommandMock, + showErrorMessage: showErrorMessageMock + }; + + beforeEach(() => { + confirmDeleteMock.mockReset(); + executeCommandMock.mockReset().mockResolvedValue(undefined); + showErrorMessageMock.mockReset().mockResolvedValue(undefined); + }); + + function createTarget( + deleteWorkflowRun: DeleteWorkflowRunTarget["gitHubRepoContext"]["client"]["actions"]["deleteWorkflowRun"] + ): DeleteWorkflowRunTarget { + return { + gitHubRepoContext: { + owner: "octo-org", + name: "octo-repo", + client: { + actions: { + deleteWorkflowRun + } + } + }, + run: { + run: { + id: 987654, + run_number: 42 + } + } + }; + } + + it("contributes command, inline remove button, and context menu item for completed runs", () => { + const commandEntry = manifest.contributes.commands.find(c => c.command === "github-actions.workflow.run.delete"); + expect(commandEntry).toEqual({ + command: "github-actions.workflow.run.delete", + category: "GitHub Actions", + title: "Delete workflow run", + when: "viewItem =~ /run/ && viewItem =~ /rerunnable/", + icon: { + dark: "resources/icons/dark/remove.svg", + light: "resources/icons/light/remove.svg" + } + }); + + const menuItems = manifest.contributes.menus["view/item/context"].filter( + item => item.command === "github-actions.workflow.run.delete" + ); + expect(menuItems).toEqual([ + { + command: "github-actions.workflow.run.delete", + when: "viewItem =~ /run/ && viewItem =~ /rerunnable/", + group: "inline@2" + }, + { + command: "github-actions.workflow.run.delete", + when: "viewItem =~ /run/ && viewItem =~ /rerunnable/" + } + ]); + }); + + it("confirms deletion via modal dialog, deletes the workflow run, and refreshes tree views", async () => { + const deleteApiMock = + jest.fn(); + deleteApiMock.mockResolvedValue({}); + confirmDeleteMock.mockResolvedValue(DELETE_WORKFLOW_RUN_ACCEPT_TEXT); + + await executeDeleteWorkflowRun(createTarget(deleteApiMock), ui); + + expect(confirmDeleteMock).toHaveBeenCalledWith( + "Are you sure you want to delete workflow run #42?", + { + modal: true, + detail: "Deleting this workflow run cannot be undone and will permanently remove its logs and artifacts." + }, + "Yes, delete this workflow run" + ); + expect(deleteApiMock).toHaveBeenCalledWith({ + owner: "octo-org", + repo: "octo-repo", + run_id: 987654 + }); + expect(executeCommandMock.mock.calls.map(call => call[0])).toEqual([ + "github-actions.explorer.refresh", + "github-actions.explorer.current-branch.refresh" + ]); + }); + + it("does not delete or refresh when the user cancels the confirmation modal", async () => { + const deleteApiMock = + jest.fn(); + deleteApiMock.mockResolvedValue({}); + confirmDeleteMock.mockResolvedValue(undefined); + + await executeDeleteWorkflowRun(createTarget(deleteApiMock), ui); + + expect(deleteApiMock).not.toHaveBeenCalled(); + expect(executeCommandMock).not.toHaveBeenCalled(); + expect(showErrorMessageMock).not.toHaveBeenCalled(); + }); + + it("surfaces API errors via showErrorMessage without refreshing tree views", async () => { + const deleteApiMock = + jest.fn(); + deleteApiMock.mockRejectedValue(new Error("Must have admin rights to Repository.")); + confirmDeleteMock.mockResolvedValue(DELETE_WORKFLOW_RUN_ACCEPT_TEXT); + + await executeDeleteWorkflowRun(createTarget(deleteApiMock), ui); + + expect(showErrorMessageMock).toHaveBeenCalledWith( + "Could not delete workflow run: 'Must have admin rights to Repository.'" + ); + expect(executeCommandMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/deleteWorkflowRun.ts b/src/commands/deleteWorkflowRun.ts new file mode 100644 index 00000000..6b3f281f --- /dev/null +++ b/src/commands/deleteWorkflowRun.ts @@ -0,0 +1,16 @@ +import * as vscode from "vscode"; +import {WorkflowRunCommandArgs} from "../treeViews/shared/workflowRunNode"; +import {executeDeleteWorkflowRun} from "./deleteWorkflowRunHandler"; + +export function registerDeleteWorkflowRun(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.commands.registerCommand("github-actions.workflow.run.delete", async (args: WorkflowRunCommandArgs) => { + await executeDeleteWorkflowRun(args, { + confirmDelete: (message, options, acceptText) => + vscode.window.showInformationMessage(message, options, acceptText), + executeCommand: command => vscode.commands.executeCommand(command), + showErrorMessage: message => vscode.window.showErrorMessage(message) + }); + }) + ); +} diff --git a/src/commands/deleteWorkflowRunHandler.ts b/src/commands/deleteWorkflowRunHandler.ts new file mode 100644 index 00000000..ada809e6 --- /dev/null +++ b/src/commands/deleteWorkflowRunHandler.ts @@ -0,0 +1,59 @@ +export const DELETE_WORKFLOW_RUN_ACCEPT_TEXT = "Yes, delete this workflow run"; + +export interface DeleteWorkflowRunTarget { + gitHubRepoContext: { + owner: string; + name: string; + client: { + actions: { + deleteWorkflowRun(params: {owner: string; repo: string; run_id: number}): Promise; + }; + }; + }; + run: { + run: { + id: number; + run_number: number; + }; + }; +} + +export interface DeleteWorkflowRunUI { + confirmDelete( + message: string, + options: {modal: boolean; detail: string}, + acceptText: string + ): PromiseLike; + executeCommand(command: string): PromiseLike; + showErrorMessage(message: string): PromiseLike; +} + +export async function executeDeleteWorkflowRun( + target: DeleteWorkflowRunTarget, + ui: DeleteWorkflowRunUI +): Promise { + const {gitHubRepoContext, run} = target; + + try { + const answer = await ui.confirmDelete( + `Are you sure you want to delete workflow run #${run.run.run_number}?`, + { + modal: true, + detail: "Deleting this workflow run cannot be undone and will permanently remove its logs and artifacts." + }, + DELETE_WORKFLOW_RUN_ACCEPT_TEXT + ); + + if (answer === DELETE_WORKFLOW_RUN_ACCEPT_TEXT) { + await gitHubRepoContext.client.actions.deleteWorkflowRun({ + owner: gitHubRepoContext.owner, + repo: gitHubRepoContext.name, + run_id: run.run.id + }); + await ui.executeCommand("github-actions.explorer.refresh"); + await ui.executeCommand("github-actions.explorer.current-branch.refresh"); + } + } catch (e) { + await ui.showErrorMessage(`Could not delete workflow run: '${(e as Error).message}'`); + } +} diff --git a/src/extension.ts b/src/extension.ts index be9a157f..9bc607f5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import {canReachGitHubAPI} from "./api/canReachGitHubAPI"; import {getSession} from "./auth/auth"; import {registerCancelWorkflowRun} from "./commands/cancelWorkflowRun"; +import {registerDeleteWorkflowRun} from "./commands/deleteWorkflowRun"; import {registerOpenWorkflowFile} from "./commands/openWorkflowFile"; import {registerOpenWorkflowJobLogs} from "./commands/openWorkflowJobLogs"; import {registerOpenWorkflowStepLogs} from "./commands/openWorkflowStepLogs"; @@ -78,6 +79,7 @@ export async function activate(context: vscode.ExtensionContext) { registerTriggerWorkflowRun(context); registerReRunWorkflowRun(context); registerCancelWorkflowRun(context); + registerDeleteWorkflowRun(context); registerAddSecret(context); registerDeleteSecret(context);