Add history view, rework to use history status, remove old classes
Signed-off-by: Sanjula Ganepola <sanjulagane@gmail.com>
This commit is contained in:
71
package.json
71
package.json
@@ -52,6 +52,11 @@
|
|||||||
"name": "Workflows",
|
"name": "Workflows",
|
||||||
"icon": "$(layers)"
|
"icon": "$(layers)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "history",
|
||||||
|
"name": "History",
|
||||||
|
"icon": "$(book)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "settings",
|
"id": "settings",
|
||||||
"name": "Settings",
|
"name": "Settings",
|
||||||
@@ -59,6 +64,42 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"viewsWelcome": [
|
||||||
|
{
|
||||||
|
"view": "components",
|
||||||
|
"contents": "Loading components..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"view": "workflows",
|
||||||
|
"contents": "Loading components...",
|
||||||
|
"when": "!githubLocalActions:noWorkflows"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"view": "workflows",
|
||||||
|
"contents": "No workflows found.",
|
||||||
|
"when": "githubLocalActions:noWorkflows"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"view": "history",
|
||||||
|
"contents": "Loading history...",
|
||||||
|
"when": "!githubLocalActions:noHistory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"view": "history",
|
||||||
|
"contents": "No history found.",
|
||||||
|
"when": "githubLocalActions:noHistory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"view": "settings",
|
||||||
|
"contents": "Loading settings...",
|
||||||
|
"when": "!githubLocalActions:noSettings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"view": "settings",
|
||||||
|
"contents": "No workflows found.",
|
||||||
|
"when": "githubLocalActions:noSettings"
|
||||||
|
}
|
||||||
|
],
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"category": "GitHub Local Actions",
|
"category": "GitHub Local Actions",
|
||||||
@@ -114,6 +155,18 @@
|
|||||||
"title": "Run Workflow",
|
"title": "Run Workflow",
|
||||||
"icon": "$(debug-start)"
|
"icon": "$(debug-start)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"category": "GitHub Local Actions",
|
||||||
|
"command": "githubLocalActions.runJob",
|
||||||
|
"title": "Run Job",
|
||||||
|
"icon": "$(debug-start)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "GitHub Local Actions",
|
||||||
|
"command": "githubLocalActions.refreshHistory",
|
||||||
|
"title": "Refresh",
|
||||||
|
"icon": "$(refresh)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"category": "GitHub Local Actions",
|
"category": "GitHub Local Actions",
|
||||||
"command": "githubLocalActions.refreshSettings",
|
"command": "githubLocalActions.refreshSettings",
|
||||||
@@ -159,6 +212,14 @@
|
|||||||
"command": "githubLocalActions.runWorkflow",
|
"command": "githubLocalActions.runWorkflow",
|
||||||
"when": "never"
|
"when": "never"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "githubLocalActions.runJob",
|
||||||
|
"when": "never"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "githubLocalActions.refreshHistory",
|
||||||
|
"when": "never"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "githubLocalActions.refreshSettings",
|
"command": "githubLocalActions.refreshSettings",
|
||||||
"when": "never"
|
"when": "never"
|
||||||
@@ -185,6 +246,11 @@
|
|||||||
"when": "view == workflows",
|
"when": "view == workflows",
|
||||||
"group": "navigation@2"
|
"group": "navigation@2"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "githubLocalActions.refreshHistory",
|
||||||
|
"when": "view == history",
|
||||||
|
"group": "navigation@0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "githubLocalActions.refreshSettings",
|
"command": "githubLocalActions.refreshSettings",
|
||||||
"when": "view == settings",
|
"when": "view == settings",
|
||||||
@@ -216,6 +282,11 @@
|
|||||||
"command": "githubLocalActions.runWorkflow",
|
"command": "githubLocalActions.runWorkflow",
|
||||||
"when": "view == workflows && viewItem =~ /^githubLocalActions.workflow.*/",
|
"when": "view == workflows && viewItem =~ /^githubLocalActions.workflow.*/",
|
||||||
"group": "inline@1"
|
"group": "inline@1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "githubLocalActions.runJob",
|
||||||
|
"when": "view == workflows && viewItem =~ /^githubLocalActions.job.*/",
|
||||||
|
"group": "inline@0"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
234
src/act.ts
234
src/act.ts
@@ -1,12 +1,12 @@
|
|||||||
import * as child_process from 'child_process';
|
import * as child_process from 'child_process';
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { commands, CustomExecution, env, EventEmitter, Pseudoterminal, ShellExecution, TaskDefinition, TaskGroup, TaskPanelKind, TaskRevealKind, tasks, TaskScope, TerminalDimensions, window, workspace } from "vscode";
|
import { commands, CustomExecution, env, EventEmitter, Pseudoterminal, ShellExecution, TaskDefinition, TaskGroup, TaskPanelKind, TaskRevealKind, tasks, TaskScope, TerminalDimensions, window, workspace, WorkspaceFolder } from "vscode";
|
||||||
import { ComponentsManager } from "./componentsManager";
|
import { ComponentsManager } from "./componentsManager";
|
||||||
import { workflowsTreeDataProvider } from './extension';
|
import { historyTreeDataProvider } from './extension';
|
||||||
import { SettingsManager } from './settingsManager';
|
import { SettingsManager } from './settingsManager';
|
||||||
import { Workflow, WorkflowsManager } from "./workflowsManager";
|
import { Workflow, WorkflowsManager } from "./workflowsManager";
|
||||||
|
|
||||||
export enum EventTrigger {
|
export enum Event {
|
||||||
BranchProtectionRule = 'branch_protection_rule',
|
BranchProtectionRule = 'branch_protection_rule',
|
||||||
CheckRun = 'check_run',
|
CheckRun = 'check_run',
|
||||||
CheckSuite = 'check_suite',
|
CheckSuite = 'check_suite',
|
||||||
@@ -44,27 +44,11 @@ export enum EventTrigger {
|
|||||||
|
|
||||||
export enum Option {
|
export enum Option {
|
||||||
Workflows = '--workflows',
|
Workflows = '--workflows',
|
||||||
|
Job = '--job',
|
||||||
Variable = '--var',
|
Variable = '--var',
|
||||||
Json = "--json"
|
Json = "--json"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowLog {
|
|
||||||
name: string,
|
|
||||||
status: WorkflowStatus
|
|
||||||
jobLogs: JobLog[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobLog {
|
|
||||||
name: string,
|
|
||||||
status: JobStatus,
|
|
||||||
stepLogs: StepLog[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StepLog {
|
|
||||||
name: string,
|
|
||||||
status: StepStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RawLog {
|
export interface RawLog {
|
||||||
dryrun: boolean,
|
dryrun: boolean,
|
||||||
job: string,
|
job: string,
|
||||||
@@ -74,42 +58,34 @@ export interface RawLog {
|
|||||||
msg: string,
|
msg: string,
|
||||||
time: string,
|
time: string,
|
||||||
|
|
||||||
stage: string,
|
raw_output?: boolean,
|
||||||
step: string,
|
|
||||||
stepID: string[],
|
|
||||||
|
|
||||||
jobResult: string, //TODO: Could be an enum?
|
stage?: string,
|
||||||
|
step?: string,
|
||||||
|
stepID?: string[],
|
||||||
|
stepResult?: string, //TODO: Could be an enum?
|
||||||
|
|
||||||
|
jobResult?: string, //TODO: Could be an enum?
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WorkflowStatus {
|
export interface History {
|
||||||
Queued = 'queued',
|
name: string,
|
||||||
InProgress = 'inProgress',
|
status: HistoryStatus,
|
||||||
Success = 'success',
|
start?: string,
|
||||||
Failed = 'failed',
|
end?: string,
|
||||||
Cancelled = 'cancelled'
|
output?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum JobStatus {
|
export enum HistoryStatus {
|
||||||
Queued = 'queued',
|
Running = 'Running',
|
||||||
InProgress = 'inProgress',
|
Success = 'Success',
|
||||||
Skipped = 'skipped',
|
Failed = 'Failed',
|
||||||
Success = 'success',
|
Cancelled = 'Cancelled'
|
||||||
Failed = 'failed',
|
|
||||||
Cancelled = 'cancelled'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum StepStatus {
|
|
||||||
Queued = 'queued',
|
|
||||||
InProgress = 'inProgress',
|
|
||||||
Skipped = 'skipped',
|
|
||||||
Success = 'success',
|
|
||||||
Failed = 'failed',
|
|
||||||
Cancelled = 'cancelled'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Act {
|
export class Act {
|
||||||
private static base: string = 'act';
|
private static base: string = 'act';
|
||||||
workflowLogs: { [path: string]: WorkflowLog[] };
|
workspaceHistory: { [path: string]: History[] };
|
||||||
componentsManager: ComponentsManager;
|
componentsManager: ComponentsManager;
|
||||||
workflowsManager: WorkflowsManager;
|
workflowsManager: WorkflowsManager;
|
||||||
settingsManager: SettingsManager;
|
settingsManager: SettingsManager;
|
||||||
@@ -117,7 +93,7 @@ export class Act {
|
|||||||
prebuiltExecutables: { [architecture: string]: string };
|
prebuiltExecutables: { [architecture: string]: string };
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.workflowLogs = {};
|
this.workspaceHistory = {};
|
||||||
this.componentsManager = new ComponentsManager();
|
this.componentsManager = new ComponentsManager();
|
||||||
this.workflowsManager = new WorkflowsManager();
|
this.workflowsManager = new WorkflowsManager();
|
||||||
this.settingsManager = new SettingsManager();
|
this.settingsManager = new SettingsManager();
|
||||||
@@ -178,15 +154,28 @@ export class Act {
|
|||||||
// TODO: Implement
|
// TODO: Implement
|
||||||
}
|
}
|
||||||
|
|
||||||
async runEvent(eventTrigger: EventTrigger) {
|
|
||||||
// return await this.runCommand(`${Act.base} ${eventTrigger}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async runWorkflow(workflow: Workflow) {
|
async runWorkflow(workflow: Workflow) {
|
||||||
return await this.runCommand(workflow, `${Act.base} ${Option.Json} ${Option.Workflows} ".github/workflows/${path.parse(workflow.uri.fsPath).base}"`);
|
const workspaceFolder = workspace.getWorkspaceFolder(workflow.uri);
|
||||||
|
if (workspaceFolder) {
|
||||||
|
return await this.runCommand(workspaceFolder, `${Option.Workflows} ".github/workflows/${path.parse(workflow.uri.fsPath).base}"`, workflow.name, [`Workflow: ${workflow.name}`]);
|
||||||
|
} else {
|
||||||
|
window.showErrorMessage(`Failed to locate workspace folder for ${workflow.uri.fsPath}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async runCommand(workflow: Workflow, command: string) {
|
// TODO: Implement
|
||||||
|
// async runJob(workspaceFolder: WorkspaceFolder, workflow: Workflow, job: Job) {
|
||||||
|
// return await this.runCommand(workspaceFolder, `${Option.Workflows} ".github/workflows/${path.parse(workflow.uri.fsPath).base}" ${Option.Job} "${job.id}"`, `${workflow.name}/${job.name}`, [`Workflow: ${workflow.name}`, `Job: ${job.name}`]);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// TODO: Implement
|
||||||
|
// async runEvent(workspaceFolder: WorkspaceFolder, event: Event) {
|
||||||
|
// return await this.runCommand(workspaceFolder, `${Option.Workflows} ${event}`, event, [`Event: ${event}`]);
|
||||||
|
// }
|
||||||
|
|
||||||
|
async runCommand(workspaceFolder: WorkspaceFolder, options: string, name: string, typeText: string[]) {
|
||||||
|
const command = `${Act.base} ${Option.Json} ${options}`;
|
||||||
|
|
||||||
const unreadyComponents = await this.componentsManager.getUnreadyComponents();
|
const unreadyComponents = await this.componentsManager.getUnreadyComponents();
|
||||||
if (unreadyComponents.length > 0) {
|
if (unreadyComponents.length > 0) {
|
||||||
window.showErrorMessage(`The following required components are not ready: ${unreadyComponents.map(component => component.name).join(', ')}`, 'Fix...').then(async value => {
|
window.showErrorMessage(`The following required components are not ready: ${unreadyComponents.map(component => component.name).join(', ')}`, 'Fix...').then(async value => {
|
||||||
@@ -197,37 +186,20 @@ export class Act {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceFolder = workspace.getWorkspaceFolder(workflow.uri);
|
if (!this.workspaceHistory[workspaceFolder.uri.fsPath]) {
|
||||||
if (!workspaceFolder) {
|
this.workspaceHistory[workspaceFolder.uri.fsPath] = [];
|
||||||
window.showErrorMessage('Failed to detect workspace folder');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.workflowLogs[workflow.uri.fsPath]) {
|
const historyIndex = this.workspaceHistory[workspaceFolder.uri.fsPath].length;
|
||||||
this.workflowLogs[workflow.uri.fsPath] = [];
|
this.workspaceHistory[workspaceFolder.uri.fsPath].push({
|
||||||
}
|
name: `${name} #${this.workspaceHistory[workspaceFolder.uri.fsPath].length + 1}`,
|
||||||
|
status: HistoryStatus.Running,
|
||||||
this.workflowLogs[workflow.uri.fsPath].push({
|
start: new Date().toISOString()
|
||||||
name: `${workflow.name} #${this.workflowLogs[workflow.uri.fsPath].length + 1}`,
|
|
||||||
status: WorkflowStatus.Queued,
|
|
||||||
jobLogs: Object.entries<any>(workflow.yaml.jobs).map(([key, value]) => {
|
|
||||||
return {
|
|
||||||
name: value.name ? value.name : key,
|
|
||||||
status: JobStatus.Queued,
|
|
||||||
stepLogs: Object.entries<any>(workflow.yaml.jobs[key].steps).map(([key, value]) => {
|
|
||||||
return {
|
|
||||||
name: value.name ? value.name : key,
|
|
||||||
status: StepStatus.Queued,
|
|
||||||
stepLogs: []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
workflowsTreeDataProvider.refresh();
|
historyTreeDataProvider.refresh();
|
||||||
|
|
||||||
await tasks.executeTask({
|
await tasks.executeTask({
|
||||||
name: workflow.name,
|
name: name,
|
||||||
detail: 'Run workflow',
|
detail: 'Run workflow',
|
||||||
definition: { type: 'GitHub Local Actions' },
|
definition: { type: 'GitHub Local Actions' },
|
||||||
source: 'GitHub Local Actions',
|
source: 'GitHub Local Actions',
|
||||||
@@ -249,53 +221,77 @@ export class Act {
|
|||||||
const writeEmitter = new EventEmitter<string>();
|
const writeEmitter = new EventEmitter<string>();
|
||||||
const closeEmitter = new EventEmitter<number>();
|
const closeEmitter = new EventEmitter<number>();
|
||||||
|
|
||||||
|
const exec = child_process.spawn(command, { cwd: workspaceFolder.uri.fsPath, shell: env.shell });
|
||||||
|
const handleIO = (data: any) => {
|
||||||
|
const lines: string[] = data.toString().split('\n').filter((line: string) => line != '');
|
||||||
|
for (const line of lines) {
|
||||||
|
const jsonLine = JSON.parse(line);
|
||||||
|
|
||||||
|
if (!this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].start) {
|
||||||
|
this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].start = jsonLine.time;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonLine.jobResult) {
|
||||||
|
this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].end = jsonLine.time;
|
||||||
|
|
||||||
|
switch (jsonLine.jobResult) {
|
||||||
|
case 'success':
|
||||||
|
this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].status = HistoryStatus.Success;
|
||||||
|
break;
|
||||||
|
case 'failure':
|
||||||
|
this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].status = HistoryStatus.Failed;
|
||||||
|
break;
|
||||||
|
// TODO: Handle cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
historyTreeDataProvider.refresh();
|
||||||
|
writeEmitter.fire(`${jsonLine.msg.trimEnd()}\r\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exec.stdout.on('data', handleIO);
|
||||||
|
exec.stderr.on('data', handleIO);
|
||||||
|
exec.on('close', (code) => {
|
||||||
|
if (!this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].end) {
|
||||||
|
this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].end = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].status === HistoryStatus.Running) {
|
||||||
|
this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].status = HistoryStatus.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyTreeDataProvider.refresh();
|
||||||
|
closeEmitter.fire(code || 0);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onDidWrite: writeEmitter.event,
|
onDidWrite: writeEmitter.event,
|
||||||
onDidClose: closeEmitter.event,
|
onDidClose: closeEmitter.event,
|
||||||
open: async (initialDimensions: TerminalDimensions | undefined): Promise<void> => {
|
open: async (initialDimensions: TerminalDimensions | undefined): Promise<void> => {
|
||||||
writeEmitter.fire(`Workflow: ${workflow.name}\r\n`);
|
writeEmitter.fire(`Name: ${name}\r\n`);
|
||||||
writeEmitter.fire(`Path: ${workflow.uri.fsPath}\r\n`);
|
writeEmitter.fire(`Path: ${workspaceFolder.uri.fsPath}\r\n`);
|
||||||
writeEmitter.fire(`Command: ${command}\r\n`);
|
for (const text of typeText) {
|
||||||
|
writeEmitter.fire(`${text}\r\n`);
|
||||||
|
}
|
||||||
writeEmitter.fire(`Environments: OSSBUILD\r\n`);
|
writeEmitter.fire(`Environments: OSSBUILD\r\n`);
|
||||||
writeEmitter.fire(`Variables: VARIABLE1=ABC, VARIABLE2=DEF\r\n`);
|
writeEmitter.fire(`Variables: VARIABLE1=ABC, VARIABLE2=DEF\r\n`);
|
||||||
writeEmitter.fire(`Secrets: SECRET1=ABC, SECRET2=DEF\r\n`);
|
writeEmitter.fire(`Secrets: SECRET1=ABC, SECRET2=DEF\r\n`);
|
||||||
writeEmitter.fire(`Timestamp: ${new Date().toLocaleTimeString()}\r\n`);
|
writeEmitter.fire(`Timestamp: ${new Date().toLocaleTimeString()}\r\n`);
|
||||||
|
writeEmitter.fire(`Command: ${command}\r\n`);
|
||||||
writeEmitter.fire(`\r\n`);
|
writeEmitter.fire(`\r\n`);
|
||||||
|
|
||||||
const exec = child_process.spawn(command, { cwd: workspaceFolder.uri.fsPath, shell: env.shell });
|
|
||||||
exec.stdout.on('data', (data) => {
|
|
||||||
const lines = data.toString().split('\n');
|
|
||||||
for (const line of lines) {
|
|
||||||
const rawLog: RawLog = JSON.parse(line);
|
|
||||||
|
|
||||||
if (rawLog.stepID) {
|
|
||||||
|
|
||||||
} else if (rawLog.jobID) {
|
|
||||||
// this.workflowLogs[workflow.uri.fsPath][Object.values(this.workflowLogs).length - 1].jobLogs.push({
|
|
||||||
// name: '',
|
|
||||||
// status: '',
|
|
||||||
// stepLogs: []
|
|
||||||
// });
|
|
||||||
} else if (rawLog.jobResult) {
|
|
||||||
|
|
||||||
} else {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeEmitter.fire(lines);
|
|
||||||
});
|
|
||||||
|
|
||||||
exec.stderr.on('data', (data) => {
|
|
||||||
const error = data.toString();
|
|
||||||
writeEmitter.fire(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
exec.on('close', (code) => {
|
|
||||||
closeEmitter.fire(code || 0);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
close: () => { }
|
close: () => {
|
||||||
|
if (this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].status === HistoryStatus.Running) {
|
||||||
|
this.workspaceHistory[workspaceFolder.uri.fsPath][historyIndex].status = HistoryStatus.Cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyTreeDataProvider.refresh();
|
||||||
|
exec.stdout.destroy();
|
||||||
|
exec.stdin.destroy();
|
||||||
|
exec.stderr.destroy();
|
||||||
|
exec.kill();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { window, workspace } from 'vscode';
|
|||||||
import { Act } from './act';
|
import { Act } from './act';
|
||||||
import ComponentsTreeDataProvider from './views/components/componentsTreeDataProvider';
|
import ComponentsTreeDataProvider from './views/components/componentsTreeDataProvider';
|
||||||
import { DecorationProvider } from './views/decorationProvider';
|
import { DecorationProvider } from './views/decorationProvider';
|
||||||
|
import HistoryTreeDataProvider from './views/history/historyTreeDataProvider';
|
||||||
import SettingsTreeDataProvider from './views/settings/settingsTreeDataProvider';
|
import SettingsTreeDataProvider from './views/settings/settingsTreeDataProvider';
|
||||||
import WorkflowsTreeDataProvider from './views/workflows/workflowsTreeDataProvider';
|
import WorkflowsTreeDataProvider from './views/workflows/workflowsTreeDataProvider';
|
||||||
|
|
||||||
export let act: Act;
|
export let act: Act;
|
||||||
export let componentsTreeDataProvider: ComponentsTreeDataProvider;
|
export let componentsTreeDataProvider: ComponentsTreeDataProvider;
|
||||||
export let workflowsTreeDataProvider: WorkflowsTreeDataProvider;
|
export let workflowsTreeDataProvider: WorkflowsTreeDataProvider;
|
||||||
|
export let historyTreeDataProvider: HistoryTreeDataProvider;
|
||||||
export let settingsTreeDataProvider: SettingsTreeDataProvider;
|
export let settingsTreeDataProvider: SettingsTreeDataProvider;
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
@@ -22,6 +24,8 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
const componentsTreeView = window.createTreeView(ComponentsTreeDataProvider.VIEW_ID, { treeDataProvider: componentsTreeDataProvider });
|
const componentsTreeView = window.createTreeView(ComponentsTreeDataProvider.VIEW_ID, { treeDataProvider: componentsTreeDataProvider });
|
||||||
workflowsTreeDataProvider = new WorkflowsTreeDataProvider(context);
|
workflowsTreeDataProvider = new WorkflowsTreeDataProvider(context);
|
||||||
const workflowsTreeView = window.createTreeView(WorkflowsTreeDataProvider.VIEW_ID, { treeDataProvider: workflowsTreeDataProvider });
|
const workflowsTreeView = window.createTreeView(WorkflowsTreeDataProvider.VIEW_ID, { treeDataProvider: workflowsTreeDataProvider });
|
||||||
|
historyTreeDataProvider = new HistoryTreeDataProvider(context);
|
||||||
|
const historyTreeView = window.createTreeView(HistoryTreeDataProvider.VIEW_ID, { treeDataProvider: historyTreeDataProvider });
|
||||||
settingsTreeDataProvider = new SettingsTreeDataProvider(context);
|
settingsTreeDataProvider = new SettingsTreeDataProvider(context);
|
||||||
const settingsTreeView = window.createTreeView(SettingsTreeDataProvider.VIEW_ID, { treeDataProvider: settingsTreeDataProvider });
|
const settingsTreeView = window.createTreeView(SettingsTreeDataProvider.VIEW_ID, { treeDataProvider: settingsTreeDataProvider });
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
componentsTreeView,
|
componentsTreeView,
|
||||||
workflowsTreeView,
|
workflowsTreeView,
|
||||||
|
historyTreeView,
|
||||||
settingsTreeView,
|
settingsTreeView,
|
||||||
window.registerFileDecorationProvider(decorationProvider),
|
window.registerFileDecorationProvider(decorationProvider),
|
||||||
workflowsFileWatcher
|
workflowsFileWatcher
|
||||||
|
|||||||
46
src/views/history/history.ts
Normal file
46
src/views/history/history.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode";
|
||||||
|
import { History, HistoryStatus } from "../../act";
|
||||||
|
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
||||||
|
|
||||||
|
export default class HistoryTreeItem extends TreeItem implements GithubLocalActionsTreeItem {
|
||||||
|
static contextValue = 'githubLocalActions.history';
|
||||||
|
history: History;
|
||||||
|
|
||||||
|
constructor(history: History) {
|
||||||
|
super(history.name, TreeItemCollapsibleState.None);
|
||||||
|
this.history = history;
|
||||||
|
|
||||||
|
let totalDuration: string | undefined;
|
||||||
|
if (history.start) {
|
||||||
|
const start = new Date(history.start).getTime();
|
||||||
|
const end = history.end ? new Date(history.end).getTime() : new Date().getTime();
|
||||||
|
totalDuration = `${((end - start) / 1000).toFixed(0).toString()}s`;
|
||||||
|
this.description = totalDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.contextValue = `${HistoryTreeItem.contextValue}_${history.status}`;
|
||||||
|
switch (history.status) {
|
||||||
|
case HistoryStatus.Running:
|
||||||
|
this.iconPath = new ThemeIcon('loading~spin');
|
||||||
|
break;
|
||||||
|
case HistoryStatus.Success:
|
||||||
|
this.iconPath = new ThemeIcon('pass', new ThemeColor('GitHubLocalActions.green'));
|
||||||
|
break;
|
||||||
|
case HistoryStatus.Failed:
|
||||||
|
this.iconPath = new ThemeIcon('error', new ThemeColor('GitHubLocalActions.red'));
|
||||||
|
break;
|
||||||
|
case HistoryStatus.Cancelled:
|
||||||
|
this.iconPath = new ThemeIcon('circle-slash', new ThemeColor('GitHubLocalActions.yellow'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.tooltip = `Name: ${history.name}\n` +
|
||||||
|
`Status: ${history.status}\n` +
|
||||||
|
`Started: ${history.start ? history.start : 'N/A'}\n` +
|
||||||
|
`Ended: ${history.end ? history.end : 'N/A'}\n` +
|
||||||
|
(totalDuration ? `Total Duration: ${totalDuration}\n` : ``);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChildren(): Promise<GithubLocalActionsTreeItem[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/views/history/historyTreeDataProvider.ts
Normal file
59
src/views/history/historyTreeDataProvider.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { CancellationToken, commands, EventEmitter, ExtensionContext, extensions, TreeDataProvider, TreeItem, workspace } from "vscode";
|
||||||
|
import { act } from "../../extension";
|
||||||
|
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
||||||
|
import HistoryTreeItem from "./history";
|
||||||
|
|
||||||
|
export default class HistoryTreeDataProvider implements TreeDataProvider<GithubLocalActionsTreeItem> {
|
||||||
|
private _onDidChangeTreeData = new EventEmitter<GithubLocalActionsTreeItem | undefined | null | void>();
|
||||||
|
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||||
|
static VIEW_ID = 'history';
|
||||||
|
|
||||||
|
constructor(context: ExtensionContext) {
|
||||||
|
extensions.onDidChange(e => {
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
commands.registerCommand('githubLocalActions.refreshHistory', async () => {
|
||||||
|
this.refresh();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(element?: GithubLocalActionsTreeItem) {
|
||||||
|
this._onDidChangeTreeData.fire(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTreeItem(element: GithubLocalActionsTreeItem): GithubLocalActionsTreeItem | Thenable<GithubLocalActionsTreeItem> {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveTreeItem(item: TreeItem, element: GithubLocalActionsTreeItem, token: CancellationToken): Promise<GithubLocalActionsTreeItem> {
|
||||||
|
if (element.getToolTip) {
|
||||||
|
element.tooltip = await element.getToolTip();
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChildren(element?: GithubLocalActionsTreeItem): Promise<GithubLocalActionsTreeItem[]> {
|
||||||
|
if (element) {
|
||||||
|
return element.getChildren();
|
||||||
|
} else {
|
||||||
|
const items: GithubLocalActionsTreeItem[] = [];
|
||||||
|
|
||||||
|
const workspaceFolders = workspace.workspaceFolders;
|
||||||
|
if (workspaceFolders && workspaceFolders.length > 0) {
|
||||||
|
const workspaceHistory = act.workspaceHistory[workspaceFolders[0].uri.fsPath]; //TODO: Fix for multi workspace support
|
||||||
|
if (workspaceHistory) {
|
||||||
|
for (const history of workspaceHistory) {
|
||||||
|
items.push(new HistoryTreeItem(history));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await commands.executeCommand('setContext', 'githubLocalActions:noHistory', items.length == 0);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CancellationToken, commands, EventEmitter, ExtensionContext, TreeDataProvider, TreeItem } from "vscode";
|
import { CancellationToken, commands, EventEmitter, ExtensionContext, TreeDataProvider, TreeItem } from "vscode";
|
||||||
|
import { act } from "../../extension";
|
||||||
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
||||||
import ContainerEnginesTreeItem from "./containerEngines";
|
import ContainerEnginesTreeItem from "./containerEngines";
|
||||||
import EnvironmentsTreeItem from "./environments";
|
import EnvironmentsTreeItem from "./environments";
|
||||||
@@ -40,14 +41,22 @@ export default class SettingsTreeDataProvider implements TreeDataProvider<Github
|
|||||||
if (element) {
|
if (element) {
|
||||||
return element.getChildren();
|
return element.getChildren();
|
||||||
} else {
|
} else {
|
||||||
return [
|
const items: GithubLocalActionsTreeItem[] = [];
|
||||||
new EnvironmentsTreeItem(),
|
|
||||||
new SecretsTreeItem(),
|
const workflows = await act.workflowsManager.getWorkflows();
|
||||||
new VariablesTreeItem(),
|
if (workflows.length > 0) {
|
||||||
new InputsTreeItem(),
|
items.push(...[
|
||||||
new RunnersTreeItem(),
|
new EnvironmentsTreeItem(),
|
||||||
new ContainerEnginesTreeItem()
|
new SecretsTreeItem(),
|
||||||
];
|
new VariablesTreeItem(),
|
||||||
|
new InputsTreeItem(),
|
||||||
|
new RunnersTreeItem(),
|
||||||
|
new ContainerEnginesTreeItem()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await commands.executeCommand('setContext', 'githubLocalActions:noSettings', items.length == 0);
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
23
src/views/workflows/job.ts
Normal file
23
src/views/workflows/job.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode";
|
||||||
|
import { Job, Workflow } from "../../workflowsManager";
|
||||||
|
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
||||||
|
|
||||||
|
export default class JobTreeItem extends TreeItem implements GithubLocalActionsTreeItem {
|
||||||
|
static contextValue = 'githubLocalActions.job';
|
||||||
|
job: Job;
|
||||||
|
workflow: Workflow;
|
||||||
|
|
||||||
|
constructor(workflow: Workflow, job: Job) {
|
||||||
|
super(job.name, TreeItemCollapsibleState.None);
|
||||||
|
this.workflow = workflow;
|
||||||
|
this.job = job;
|
||||||
|
this.contextValue = JobTreeItem.contextValue;
|
||||||
|
this.iconPath = new ThemeIcon('rocket');
|
||||||
|
this.tooltip = `Name: ${job.name}` +
|
||||||
|
`ID: ${job.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChildren(): Promise<GithubLocalActionsTreeItem[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode";
|
|
||||||
import { JobLog } from "../../act";
|
|
||||||
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
|
||||||
import StepTreeItem from "./stepLog";
|
|
||||||
|
|
||||||
export default class JobLogTreeItem extends TreeItem implements GithubLocalActionsTreeItem {
|
|
||||||
static contextValue = 'githubLocalActions.jobLog';
|
|
||||||
jobLog: JobLog;
|
|
||||||
|
|
||||||
constructor(jobLog: JobLog) {
|
|
||||||
super(jobLog.name, TreeItemCollapsibleState.Collapsed);
|
|
||||||
this.jobLog = jobLog;
|
|
||||||
this.contextValue = JobLogTreeItem.contextValue;
|
|
||||||
this.iconPath = new ThemeIcon('pass-filled');
|
|
||||||
// this.tooltip = `Name: ${workflow.name}\n` +
|
|
||||||
// `Path: ${workflow.uri.fsPath}\n` +
|
|
||||||
// (workflow.error ? `Error: ${workflow.error}` : ``);
|
|
||||||
|
|
||||||
// TODO: Add tooltip and resourceUri
|
|
||||||
}
|
|
||||||
|
|
||||||
async getChildren(): Promise<GithubLocalActionsTreeItem[]> {
|
|
||||||
const stepLogs = this.jobLog.stepLogs;
|
|
||||||
if (stepLogs) {
|
|
||||||
return this.jobLog.stepLogs.map(step => new StepTreeItem(step));
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode";
|
|
||||||
import { StepLog } from "../../act";
|
|
||||||
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
|
||||||
|
|
||||||
export default class StepLogTreeItem extends TreeItem implements GithubLocalActionsTreeItem {
|
|
||||||
static contextValue = 'githubLocalActions.stepLog';
|
|
||||||
stepLog: StepLog;
|
|
||||||
|
|
||||||
constructor(stepLog: StepLog) {
|
|
||||||
super(stepLog.name, TreeItemCollapsibleState.None);
|
|
||||||
this.stepLog = stepLog;
|
|
||||||
this.contextValue = StepLogTreeItem.contextValue;
|
|
||||||
this.iconPath = new ThemeIcon('pass-filled');
|
|
||||||
// this.tooltip = `Name: ${workflow.name}\n` +
|
|
||||||
// `Path: ${workflow.uri.fsPath}\n` +
|
|
||||||
// (workflow.error ? `Error: ${workflow.error}` : ``);
|
|
||||||
|
|
||||||
// TODO: Add tooltip and resourceUri
|
|
||||||
}
|
|
||||||
|
|
||||||
async getChildren(): Promise<GithubLocalActionsTreeItem[]> {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from "vscode";
|
import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from "vscode";
|
||||||
import { act } from "../../extension";
|
|
||||||
import { Workflow } from "../../workflowsManager";
|
import { Workflow } from "../../workflowsManager";
|
||||||
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
||||||
import WorkflowLogTreeItem from "./workflowLog";
|
import JobTreeItem from "./job";
|
||||||
|
|
||||||
export default class WorkflowTreeItem extends TreeItem implements GithubLocalActionsTreeItem {
|
export default class WorkflowTreeItem extends TreeItem implements GithubLocalActionsTreeItem {
|
||||||
static contextValue = 'githubLocalActions.workflow';
|
static contextValue = 'githubLocalActions.workflow';
|
||||||
@@ -23,11 +22,15 @@ export default class WorkflowTreeItem extends TreeItem implements GithubLocalAct
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getChildren(): Promise<GithubLocalActionsTreeItem[]> {
|
async getChildren(): Promise<GithubLocalActionsTreeItem[]> {
|
||||||
const workflowLogs = act.workflowLogs[this.workflow.uri.fsPath];
|
const items: GithubLocalActionsTreeItem[] = [];
|
||||||
if (workflowLogs) {
|
|
||||||
return workflowLogs.map(workflowLog => new WorkflowLogTreeItem(workflowLog));
|
const jobs = this.workflow.yaml.jobs;
|
||||||
} else {
|
if (jobs) {
|
||||||
return [];
|
for (const [key, value] of Object.entries<any>(jobs)) {
|
||||||
|
items.push(new JobTreeItem(this.workflow, { name: value.name ? value.name : key, id: key }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode";
|
|
||||||
import { WorkflowLog } from "../../act";
|
|
||||||
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
|
||||||
import JobLogTreeItem from "./jobLog";
|
|
||||||
|
|
||||||
export default class WorkflowLogTreeItem extends TreeItem implements GithubLocalActionsTreeItem {
|
|
||||||
static contextValue = 'githubLocalActions.workflowLog';
|
|
||||||
workflowLog: WorkflowLog;
|
|
||||||
|
|
||||||
constructor(workflowLog: WorkflowLog) {
|
|
||||||
super(workflowLog.name, TreeItemCollapsibleState.Collapsed);
|
|
||||||
this.workflowLog = workflowLog;
|
|
||||||
this.contextValue = WorkflowLogTreeItem.contextValue;
|
|
||||||
this.iconPath = new ThemeIcon('pass-filled');
|
|
||||||
// this.tooltip = `Name: ${workflow.name}\n` +
|
|
||||||
// `Path: ${workflow.uri.fsPath}\n` +
|
|
||||||
// (workflow.error ? `Error: ${workflow.error}` : ``);
|
|
||||||
|
|
||||||
// TODO: Add tooltip and resourceUri
|
|
||||||
}
|
|
||||||
|
|
||||||
async getChildren(): Promise<GithubLocalActionsTreeItem[]> {
|
|
||||||
return this.workflowLog.jobLogs.map(jobLog => new JobLogTreeItem(jobLog));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CancellationToken, commands, EventEmitter, ExtensionContext, TreeDataProvider, TreeItem, window, workspace } from "vscode";
|
import { CancellationToken, commands, EventEmitter, ExtensionContext, TreeDataProvider, TreeItem, window, workspace } from "vscode";
|
||||||
import { EventTrigger } from "../../act";
|
import { Event } from "../../act";
|
||||||
import { act } from "../../extension";
|
import { act } from "../../extension";
|
||||||
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem";
|
||||||
import WorkflowTreeItem from "./workflow";
|
import WorkflowTreeItem from "./workflow";
|
||||||
@@ -15,13 +15,14 @@ export default class WorkflowsTreeDataProvider implements TreeDataProvider<Githu
|
|||||||
await act.runAllWorkflows();
|
await act.runAllWorkflows();
|
||||||
}),
|
}),
|
||||||
commands.registerCommand('githubLocalActions.runEvent', async () => {
|
commands.registerCommand('githubLocalActions.runEvent', async () => {
|
||||||
const event = await window.showQuickPick(Object.values(EventTrigger), {
|
const event = await window.showQuickPick(Object.values(Event), {
|
||||||
title: 'Select the event to run',
|
title: 'Select the event to run',
|
||||||
placeHolder: 'Event'
|
placeHolder: 'Event'
|
||||||
});
|
});
|
||||||
|
|
||||||
if(event) {
|
if (event) {
|
||||||
await act.runEvent(event as EventTrigger);
|
// TODO: Implement running event
|
||||||
|
// await act.runEvent(event as Event);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
commands.registerCommand('githubLocalActions.refreshWorkflows', async () => {
|
commands.registerCommand('githubLocalActions.refreshWorkflows', async () => {
|
||||||
@@ -33,6 +34,10 @@ export default class WorkflowsTreeDataProvider implements TreeDataProvider<Githu
|
|||||||
}),
|
}),
|
||||||
commands.registerCommand('githubLocalActions.runWorkflow', async (workflowTreeItem: WorkflowTreeItem) => {
|
commands.registerCommand('githubLocalActions.runWorkflow', async (workflowTreeItem: WorkflowTreeItem) => {
|
||||||
await act.runWorkflow(workflowTreeItem.workflow);
|
await act.runWorkflow(workflowTreeItem.workflow);
|
||||||
|
}),
|
||||||
|
commands.registerCommand('githubLocalActions.runJob', async (workflowTreeItem: WorkflowTreeItem) => {
|
||||||
|
// TODO: Implement running job
|
||||||
|
// await act.runJob()
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,8 +62,15 @@ export default class WorkflowsTreeDataProvider implements TreeDataProvider<Githu
|
|||||||
if (element) {
|
if (element) {
|
||||||
return element.getChildren();
|
return element.getChildren();
|
||||||
} else {
|
} else {
|
||||||
|
const items: GithubLocalActionsTreeItem[] = [];
|
||||||
|
|
||||||
const workflows = await act.workflowsManager.getWorkflows();
|
const workflows = await act.workflowsManager.getWorkflows();
|
||||||
return workflows.map(workflow => new WorkflowTreeItem(workflow));
|
for (const workflow of workflows) {
|
||||||
|
items.push(new WorkflowTreeItem(workflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
await commands.executeCommand('setContext', 'githubLocalActions:noWorkflows', items.length == 0);
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,11 @@ export interface Workflow {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
name: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
export class WorkflowsManager {
|
export class WorkflowsManager {
|
||||||
async getWorkflows(): Promise<Workflow[]> {
|
async getWorkflows(): Promise<Workflow[]> {
|
||||||
const workflows: Workflow[] = [];
|
const workflows: Workflow[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user