diff --git a/.github/ISSUE_TEMPLATE/1-bug_report.yml b/.github/ISSUE_TEMPLATE/1-bug_report.yml index e597721..b8e0b3d 100644 --- a/.github/ISSUE_TEMPLATE/1-bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1-bug_report.yml @@ -3,6 +3,10 @@ description: Report a bug or issue. labels: - 'bug' body: + - type: markdown + attributes: + value: | + ⭐ Most of the content for this bug report can be automatically generated by selecting `Help and Support` -> `Report an Issue` from any GitHub Local Actions view. ⭐ - type: input id: github-local-actions-version attributes: @@ -76,14 +80,6 @@ body: render: sh validations: required: false - - type: textarea - id: bug-description - attributes: - label: Bug Description - description: | - Describe the bug you encountered and share all steps to reproduce it. - placeholder: | - Bug description - type: textarea id: act-bug-report attributes: @@ -103,4 +99,12 @@ body: [...] render: yml validations: - required: false \ No newline at end of file + required: false + - type: textarea + id: bug-description + attributes: + label: Bug Description + description: | + Describe the bug you encountered and share all steps to reproduce it. + placeholder: | + Bug description \ No newline at end of file diff --git a/package.json b/package.json index 7ccaa7f..a4a0973 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "repository": { "url": "https://github.com/SanjulaGanepola/github-local-actions" }, - "homepage": "https://github.com/SanjulaGanepola/github-local-actions/blob/main/README.md", + "homepage": "https://sanjulaganepola.github.io/github-local-actions-docs", "bugs": { "url": "https://github.com/SanjulaGanepola/github-local-actions/issues" }, diff --git a/src/act.ts b/src/act.ts index 2d63564..a0eb37a 100644 --- a/src/act.ts +++ b/src/act.ts @@ -9,7 +9,7 @@ import { ConfigurationManager, Platform, Section } from "./configurationManager" import { componentsTreeDataProvider, historyTreeDataProvider } from './extension'; import { HistoryManager, HistoryStatus } from './historyManager'; import { SecretManager } from "./secretManager"; -import { Mode, SettingsManager } from './settingsManager'; +import { Mode, Settings, SettingsManager } from './settingsManager'; import { StorageKey, StorageManager } from './storageManager'; import { Utils } from "./utils"; import { Job, Workflow, WorkflowsManager } from "./workflowsManager"; @@ -576,6 +576,30 @@ export class Act { return path.join(cacheHomeDir, ...paths); } + async buildActCommand(settings: Settings, options: string[]) { + const userOptions: string[] = [ + ...settings.secrets.map(secret => `${Option.Secret} ${secret.key}`), + (settings.secretFiles.length > 0 ? `${Option.SecretFile} "${settings.secretFiles[0].path}"` : `${Option.SecretFile} ""`), + ...settings.variables.map(variable => `${Option.Var} ${variable.key}="${Utils.escapeSpecialCharacters(variable.value)}"`), + (settings.variableFiles.length > 0 ? `${Option.VarFile} "${settings.variableFiles[0].path}"` : `${Option.VarFile} ""`), + ...settings.inputs.map(input => `${Option.Input} ${input.key}="${Utils.escapeSpecialCharacters(input.value)}"`), + (settings.inputFiles.length > 0 ? `${Option.InputFile} "${settings.inputFiles[0].path}"` : `${Option.InputFile} ""`), + ...settings.runners.map(runner => `${Option.Platform} ${runner.key}="${Utils.escapeSpecialCharacters(runner.value)}"`), + (settings.payloadFiles.length > 0 ? `${Option.EventPath} "${settings.payloadFiles[0].path}"` : `${Option.EventPath} ""`), + ...settings.options.map(option => option.path ? `--${option.name}${option.default && ['true', 'false'].includes(option.default) ? "=" : " "}"${Utils.escapeSpecialCharacters(option.path)}"` : `--${option.name}`) + ]; + + const actCommand = Act.getActCommand(); + const executionCommand = `${actCommand} ${Option.Json} ${Option.Verbose} ${options.join(' ')} ${userOptions.join(' ')}`; + const displayCommand = `${actCommand} ${options.join(' ')} ${userOptions.join(' ')}`; + + return { + userOptions, + executionCommand, + displayCommand + }; + } + async runCommand(commandArgs: CommandArgs) { // Check if required components are ready // const unreadyComponents = await this.componentsManager.getUnreadyComponents(); @@ -624,23 +648,8 @@ export class Act { } catch (error: any) { } // Build command with settings - const actCommand = Act.getActCommand(); const settings = await this.settingsManager.getSettings(workspaceFolder, true); - - const userOptions: string[] = [ - ...settings.secrets.map(secret => `${Option.Secret} ${secret.key}`), - (settings.secretFiles.length > 0 ? `${Option.SecretFile} "${settings.secretFiles[0].path}"` : `${Option.SecretFile} ""`), - ...settings.variables.map(variable => `${Option.Var} ${variable.key}="${Utils.escapeSpecialCharacters(variable.value)}"`), - (settings.variableFiles.length > 0 ? `${Option.VarFile} "${settings.variableFiles[0].path}"` : `${Option.VarFile} ""`), - ...settings.inputs.map(input => `${Option.Input} ${input.key}="${Utils.escapeSpecialCharacters(input.value)}"`), - (settings.inputFiles.length > 0 ? `${Option.InputFile} "${settings.inputFiles[0].path}"` : `${Option.InputFile} ""`), - ...settings.runners.map(runner => `${Option.Platform} ${runner.key}="${Utils.escapeSpecialCharacters(runner.value)}"`), - (settings.payloadFiles.length > 0 ? `${Option.EventPath} "${settings.payloadFiles[0].path}"` : `${Option.EventPath} ""`), - ...settings.options.map(option => option.path ? `--${option.name}${option.default && ['true', 'false'].includes(option.default) ? "=" : " "}"${Utils.escapeSpecialCharacters(option.path)}"` : `--${option.name}`) - ]; - - const command = `${actCommand} ${Option.Json} ${Option.Verbose} ${commandArgs.options.join(' ')} ${userOptions.join(' ')}`; - const displayCommand = `${actCommand} ${commandArgs.options.join(' ')} ${userOptions.join(' ')}`; + const { userOptions, executionCommand, displayCommand } = await this.buildActCommand(settings, commandArgs.options); // Execute task const taskExecution = await tasks.executeTask({ @@ -854,7 +863,7 @@ export class Act { }; const exec = childProcess.spawn( - command, + executionCommand, { cwd: commandArgs.path, shell: shell, diff --git a/src/componentsManager.ts b/src/componentsManager.ts index 1ace6dd..1f44ae3 100644 --- a/src/componentsManager.ts +++ b/src/componentsManager.ts @@ -1,6 +1,6 @@ import * as childProcess from "child_process"; import { commands, env, extensions, QuickPickItemKind, ShellExecution, TaskGroup, TaskPanelKind, TaskRevealKind, tasks, TaskScope, ThemeIcon, Uri, window } from "vscode"; -import { Act } from "./act"; +import { Act, Option } from "./act"; import { ConfigurationManager, Platform, Section } from "./configurationManager"; import { act, componentsTreeDataProvider } from "./extension"; import ComponentsTreeDataProvider from "./views/components/componentsTreeDataProvider"; @@ -39,7 +39,7 @@ export class ComponentsManager { async getComponents(): Promise[]> { const components: Component[] = []; - const actCliInfo = await this.getCliInfo(`${Act.getActCommand()} --version`, ComponentsManager.actVersionRegExp, false, false); + const actCliInfo = await this.getCliInfo(`${Act.getActCommand()} ${Option.Version}`, ComponentsManager.actVersionRegExp, false, false); components.push({ name: 'nektos/act', icon: 'terminal', diff --git a/src/extension.ts b/src/extension.ts index b182798..b49d6d7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { commands, env, TreeCheckboxChangeEvent, Uri, window, workspace } from 'vscode'; import { Act } from './act'; import { ConfigurationManager } from './configurationManager'; +import { IssueHandler } from './issueHandler'; import ComponentsTreeDataProvider from './views/components/componentsTreeDataProvider'; import { DecorationProvider } from './views/decorationProvider'; import { GithubLocalActionsTreeItem } from './views/githubLocalActionsTreeItem'; @@ -68,10 +69,10 @@ export function activate(context: vscode.ExtensionContext) { window.registerFileDecorationProvider(decorationProvider), workflowsFileWatcher, commands.registerCommand('githubLocalActions.viewDocumentation', async () => { - await env.openExternal(Uri.parse('https://nektosact.com')); + await env.openExternal(Uri.parse('https://sanjulaganepola.github.io/github-local-actions-docs')); }), commands.registerCommand('githubLocalActions.reportAnIssue', async () => { - await env.openExternal(Uri.parse('https://github.com/SanjulaGanepola/github-local-actions/issues')); + await IssueHandler.openBugReport(context); }), ); } diff --git a/src/githubManager.ts b/src/githubManager.ts index 22080d8..c086fe4 100644 --- a/src/githubManager.ts +++ b/src/githubManager.ts @@ -10,6 +10,7 @@ export interface Response { } export interface GithubRepository { + remoteOriginUrl: string, owner: string, repo: string } @@ -24,7 +25,7 @@ export interface GithubVariable { } export class GitHubManager { - async getRepository(workspaceFolder: WorkspaceFolder, command: string, args: any[]): Promise { + async getRepository(workspaceFolder: WorkspaceFolder, suppressNotFoundErrors: boolean, tryAgainOptions?: { command: string, args: any[] }): Promise { const gitApi = extensions.getExtension('vscode.git')?.exports.getAPI(1); if (gitApi) { if (gitApi.state === 'initialized') { @@ -38,19 +39,25 @@ export class GitHubManager { const parsedParentPath = path.parse(parsedPath.dir); return { + remoteOriginUrl: remoteOriginUrl, owner: parsedParentPath.name, repo: parsedPath.name }; } else { - window.showErrorMessage('Remote GitHub URL not found.'); + if (!suppressNotFoundErrors) { + window.showErrorMessage('Remote GitHub URL not found.'); + } } } else { - window.showErrorMessage(`${workspaceFolder.name} does not have a Git repository`); + if (!suppressNotFoundErrors) { + window.showErrorMessage(`${workspaceFolder.name} does not have a Git repository`); + } } } else { - window.showErrorMessage('Git extension is still being initialized. Please try again later.', 'Try Again').then(async value => { - if (value && value === 'Try Again') { - await commands.executeCommand(command, ...args); + const items = tryAgainOptions ? ['Try Again'] : []; + window.showErrorMessage('Git extension is still being initialized. Please try again later.', ...items).then(async value => { + if (value && value === 'Try Again' && tryAgainOptions) { + await commands.executeCommand(tryAgainOptions.command, ...tryAgainOptions.args); } }); } diff --git a/src/issueHandler.ts b/src/issueHandler.ts new file mode 100644 index 0000000..9f3a84d --- /dev/null +++ b/src/issueHandler.ts @@ -0,0 +1,195 @@ +import * as childProcess from "child_process"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { env, ExtensionContext, ProgressLocation, QuickPickItem, ThemeIcon, Uri, window, workspace } from "vscode"; +import { Act, Option } from "./act"; +import { act } from "./extension"; + +interface BugReport { + githubLocalActionsVersion?: string + actVersion?: string, + githubRepositoryLink?: string, + workflowContent?: string, + actCommandUsed?: string, + actCommandOutput?: string, + actBugReport?: string + bugDescription?: string, +} + +// Used to map bug report keys to ids and titles in the issue template +const bugReportToTemplateMap: Record = { + githubLocalActionsVersion: { id: 'github-local-actions-version', title: 'Github Local Actions Version' }, + actVersion: { id: 'act-version', title: 'Act Version' }, + githubRepositoryLink: { id: 'github-repository-link', title: 'GitHub Repository Link' }, + workflowContent: { id: 'workflow-content', title: 'Workflow Content' }, + actCommandUsed: { id: 'act-command-used', title: 'Act Command Used' }, + actCommandOutput: { id: 'act-command-output', title: 'Act Command Output' }, + actBugReport: { id: 'act-bug-report', title: 'Act Bug Report' }, + bugDescription: { id: 'bug-description', title: 'Bug Description' } +}; + +export namespace IssueHandler { + export async function openBugReport(context: ExtensionContext) { + try { + const bugReport = await generateBugReport(context); + if (bugReport) { + const params = Object.entries(bugReport) + .filter(([key, value]) => value !== undefined && value !== '') + .map(([key, value]) => `${encodeURIComponent(bugReportToTemplateMap[key as keyof BugReport].id)}=${encodeURIComponent(value)}`) + .join('&'); + + const bugReportUrl: string = 'https://github.com/SanjulaGanepola/github-local-actions/issues/new?assignees=&labels=bug&projects=&template=1-bug_report.yml'; + const urlWithParams = params ? `${bugReportUrl}&${params}` : bugReportUrl; + await env.openExternal(Uri.parse(urlWithParams)); + } + } catch (error) { + await env.openExternal(Uri.parse('https://github.com/SanjulaGanepola/github-local-actions/issues')); + } + } + + async function generateBugReport(context: ExtensionContext): Promise { + return await window.withProgress({ location: ProgressLocation.Notification, title: 'Generating bug report...' }, async () => { + const fullBugReport: BugReport = {}; + const infoItems: (QuickPickItem & { key: keyof BugReport })[] = []; + + // Get extension version + const githubLocalActionsVersion = context.extension.packageJSON.version; + if (githubLocalActionsVersion) { + fullBugReport.githubLocalActionsVersion = `v${githubLocalActionsVersion}`; + infoItems.push({ + label: bugReportToTemplateMap['githubLocalActionsVersion'].title, + description: fullBugReport.githubLocalActionsVersion, + iconPath: new ThemeIcon('robot'), + picked: true, + key: 'githubLocalActionsVersion' + }); + } + + // Get act version + const actVersion = (await act.componentsManager.getComponents()).find(component => component.name === 'nektos/act')?.version; + if (actVersion) { + fullBugReport.actVersion = `v${actVersion}`; + infoItems.push({ + label: bugReportToTemplateMap['actVersion'].title, + description: fullBugReport.actVersion, + iconPath: new ThemeIcon('terminal'), + picked: true, + key: 'actVersion' + }); + } + + + let isWorkflowFound: boolean = false; + const activeEditor = window.activeTextEditor; + const workspaceFolder = activeEditor ? + workspace.getWorkspaceFolder(activeEditor.document.uri) : + (workspace.workspaceFolders && workspace.workspaceFolders.length > 0 ? workspace.workspaceFolders[0] : undefined); + if (workspaceFolder) { + // Get repository link + const repository = await act.settingsManager.githubManager.getRepository(workspaceFolder, true); + const githubRepositoryLink = repository?.remoteOriginUrl; + if (githubRepositoryLink) { + fullBugReport.githubRepositoryLink = githubRepositoryLink; + infoItems.push({ + label: bugReportToTemplateMap['githubRepositoryLink'].title, + description: fullBugReport.githubRepositoryLink, + iconPath: new ThemeIcon('link'), + picked: true, + key: 'githubRepositoryLink' + }); + } + + if (activeEditor) { + const workflows = await act.workflowsManager.getWorkflows(workspaceFolder); + const workflow = workflows.find(workflow => workflow.uri.fsPath === activeEditor.document.uri.fsPath); + if (workflow) { + isWorkflowFound = true; + + // Get workflow content + const workflowContent = workflow?.fileContent; + if (workflowContent) { + fullBugReport.workflowContent = workflowContent; + infoItems.push({ + label: bugReportToTemplateMap['workflowContent'].title, + description: path.parse(workflow.uri.fsPath).base, + iconPath: new ThemeIcon('file'), + picked: true, + key: 'workflowContent' + }); + } + + const workflowHistory = act.historyManager.workspaceHistory[workspaceFolder.uri.fsPath]?.filter(history => history.commandArgs.workflow?.uri.fsPath === workflow.uri.fsPath); + if (workflowHistory && workflowHistory.length > 0) { + // Get last act command + const settings = await act.settingsManager.getSettings(workspaceFolder, true); + const history = workflowHistory[workflowHistory.length - 1]; + const actCommandUsed = (await act.buildActCommand(settings, history.commandArgs.options)).displayCommand; + if (actCommandUsed) { + fullBugReport.actCommandUsed = actCommandUsed; + infoItems.push({ + label: bugReportToTemplateMap['actCommandUsed'].title, + description: `${history.name} #${history.count}`, + iconPath: new ThemeIcon('code'), + picked: true, + key: 'actCommandUsed' + }); + } + + try { + // Get last act command output + const actCommandOutput = await fs.readFile(history.logPath, 'utf8'); + if (actCommandOutput) { + fullBugReport.actCommandOutput = actCommandOutput; + infoItems.push({ + label: bugReportToTemplateMap['actCommandOutput'].title, + description: path.parse(history.logPath).base, + iconPath: new ThemeIcon('note'), + picked: true, + key: 'actCommandOutput' + }); + } + } catch (error: any) { } + } + } + } + } + + // Get act bug report + const actBugReport = await new Promise((resolve, reject) => { + childProcess.exec(`${Act.getActCommand()} ${Option.BugReport}`, (error, stdout, stderr) => { + if (!error) { + resolve(stdout); + } + }); + }); + if (actBugReport) { + fullBugReport.actBugReport = actBugReport; + infoItems.push({ + label: bugReportToTemplateMap['actBugReport'].title, + iconPath: new ThemeIcon('report'), + picked: true, + key: 'actBugReport' + }); + } + + const defaultTitle = 'Select the information to include in the bug report'; + const extendedTitle = 'More information can be included in the bug report by having the relevant workflow opened in the editor before invoking this command.'; + const selectedInfo = await window.showQuickPick(infoItems, { + title: isWorkflowFound ? defaultTitle : `${defaultTitle}. ${extendedTitle}`, + placeHolder: 'Bug Report Information', + canPickMany: true + }); + + if (selectedInfo) { + const bugReport: BugReport = {}; + + const selectedInfoKeys = selectedInfo.map(info => info.key); + for (const key of selectedInfoKeys) { + bugReport[key] = fullBugReport[key]; + } + + return bugReport; + } + }); + } +} \ No newline at end of file diff --git a/src/views/settings/settingsTreeDataProvider.ts b/src/views/settings/settingsTreeDataProvider.ts index aa90459..4055421 100644 --- a/src/views/settings/settingsTreeDataProvider.ts +++ b/src/views/settings/settingsTreeDataProvider.ts @@ -289,7 +289,7 @@ export default class SettingsTreeDataProvider implements TreeDataProvider variable.key); if (variableNames.length > 0) { - const repository = await act.settingsManager.githubManager.getRepository(settingTreeItem.workspaceFolder, 'githubLocalActions.importFromGithub', [settingTreeItem]); + const repository = await act.settingsManager.githubManager.getRepository(settingTreeItem.workspaceFolder, false, { command: 'githubLocalActions.importFromGithub', args: [settingTreeItem] }); if (repository) { const variableOptions: QuickPickItem[] = []; const errors: string[] = [];