diff --git a/src/githubManager.ts b/src/githubManager.ts new file mode 100644 index 0000000..722d302 --- /dev/null +++ b/src/githubManager.ts @@ -0,0 +1,155 @@ +import { Octokit } from "octokit"; +import * as path from "path"; +import { authentication, AuthenticationSession, commands, extensions, window, WorkspaceFolder } from "vscode"; +import { GitExtension } from "./import/git"; + +export interface Response { + data: T, + error?: string +} + +export interface GithubRepository { + owner: string, + repo: string +} + +export interface GithubEnvironment { + name: string +} + +export interface GithubVariable { + name: string, + value: string +} + +export class GitHubManager { + async getRepository(workspaceFolder: WorkspaceFolder, command: string, args: any[]): Promise { + const gitApi = extensions.getExtension('vscode.git')?.exports.getAPI(1); + if (gitApi) { + if (gitApi.state === 'initialized') { + const repository = gitApi.getRepository(workspaceFolder.uri); + + if (repository) { + const remoteOriginUrl = await repository.getConfig('remote.origin.url'); + + if (remoteOriginUrl) { + const parsedPath = path.parse(remoteOriginUrl); + const parsedParentPath = path.parse(parsedPath.dir); + + return { + owner: parsedParentPath.name, + repo: parsedPath.name + }; + } else { + window.showErrorMessage('Remote GitHub URL not found.'); + } + } else { + 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); + } + }); + } + } else { + window.showErrorMessage('Failed to load VS Code Git API.'); + } + } + + async getEnvironments(owner: string, repo: string): Promise> { + const environments: Response = { + data: [] + }; + + try { + const response = await this.get( + owner, + repo, + '/repos/{owner}/{repo}/environments' + ); + + if (response) { + for (const environment of response.environments) { + environments.data.push({ + name: environment.name + }); + } + } + } catch (error: any) { + environments.error = error.message ? error.message : error; + } + + return environments; + } + + async getVariables(owner: string, repo: string, environment?: string): Promise> { + const variables: Response = { + data: [] + }; + + try { + const response = environment ? + await this.get( + owner, + repo, + '/repos/{owner}/{repo}/environments/{environment_name}/variables', + { + environment_name: environment + } + ) : + await this.get( + owner, + repo, + '/repos/{owner}/{repo}/actions/variables' + ); + + if (response) { + for (const variable of response.variables) { + variables.data.push({ + name: variable.name, + value: variable.value + }); + } + } + } catch (error: any) { + variables.error = error.message ? error.message : error; + } + + return variables; + } + + private async get(owner: string, repo: string, endpoint: string, additionalParams?: Record) { + const session = await this.getSession(); + if (!session) { + return; + } + + const octokit = new Octokit({ + auth: session.accessToken + }); + + const response = await octokit.request(`GET ${endpoint}`, { + owner: owner, + repo: repo, + ...additionalParams, + headers: { + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + + if (response.status === 200) { + return response.data; + } + } + + private async getSession(): Promise { + try { + return await authentication.getSession('github', ['repo'], { createIfNone: true }); + } catch (error) { + window.showErrorMessage(`Failed to authenticate to GitHub. Error ${error}`); + return; + } + } +} \ No newline at end of file diff --git a/src/historyManager.ts b/src/historyManager.ts index 753cab3..cf38f5d 100644 --- a/src/historyManager.ts +++ b/src/historyManager.ts @@ -58,7 +58,7 @@ export class HistoryManager { async viewOutput(history: History) { try { - const document = await workspace.openTextDocument(history.logPath) + const document = await workspace.openTextDocument(history.logPath); await window.showTextDocument(document); } catch (error) { window.showErrorMessage(`${history.name} #${history.count} log file not found`); diff --git a/src/settingsManager.ts b/src/settingsManager.ts index fc291d1..01ae06c 100644 --- a/src/settingsManager.ts +++ b/src/settingsManager.ts @@ -1,5 +1,6 @@ import { WorkspaceFolder } from "vscode"; import { act } from "./extension"; +import { GitHubManager } from "./githubManager"; import { SecretManager } from "./secretManager"; import { StorageKey, StorageManager } from "./storageManager"; @@ -19,6 +20,7 @@ export enum Visibility { export class SettingsManager { storageManager: StorageManager; secretManager: SecretManager; + githubManager: GitHubManager; static secretsRegExp: RegExp = /\${{\s*secrets\.(.*?)\s*}}/g; static variablesRegExp: RegExp = /\${{\s*vars\.(.*?)(?:\s*==\s*(.*?))?\s*}}/g; static inputsRegExp: RegExp = /\${{\s*(?:inputs|github\.event\.inputs)\.(.*?)(?:\s*==\s*(.*?))?\s*}}/g; @@ -27,6 +29,7 @@ export class SettingsManager { constructor(storageManager: StorageManager, secretManager: SecretManager) { this.storageManager = storageManager; this.secretManager = secretManager; + this.githubManager = new GitHubManager(); } async getSettings(workspaceFolder: WorkspaceFolder, isUserSelected: boolean) { @@ -34,12 +37,14 @@ export class SettingsManager { const variables = (await this.getSetting(workspaceFolder, SettingsManager.variablesRegExp, StorageKey.Variables, false, Visibility.show)).filter(variable => !isUserSelected || variable.selected); const inputs = (await this.getSetting(workspaceFolder, SettingsManager.inputsRegExp, StorageKey.Inputs, false, Visibility.show)).filter(input => !isUserSelected || (input.selected && input.value)); const runners = (await this.getSetting(workspaceFolder, SettingsManager.runnersRegExp, StorageKey.Runners, false, Visibility.show)).filter(runner => !isUserSelected || (runner.selected && runner.value)); + const environments = await this.getEnvironments(workspaceFolder); return { secrets: secrets, variables: variables, inputs: inputs, - runners: runners + runners: runners, + environments: environments }; } @@ -89,6 +94,37 @@ export class SettingsManager { return settings; } + async getEnvironments(workspaceFolder: WorkspaceFolder): Promise { + const environments: Setting[] = []; + + const workflows = await act.workflowsManager.getWorkflows(workspaceFolder); + for (const workflow of workflows) { + if (!workflow.yaml) { + continue; + } + + const jobs = workflow.yaml?.jobs; + if (jobs) { + for (const details of Object.values(jobs)) { + if (details.environment) { + const existingEnvironment = environments.find(environment => environment.key === details.environment); + if (!existingEnvironment) { + environments.push({ + key: details.environment, + value: '', + password: false, + selected: false, + visible: Visibility.show + }); + } + } + } + } + } + + return environments; + } + async editSetting(workspaceFolder: WorkspaceFolder, newSetting: Setting, storageKey: StorageKey) { const value = newSetting.value; if (storageKey === StorageKey.Secrets) { diff --git a/src/views/settings/settingsTreeDataProvider.ts b/src/views/settings/settingsTreeDataProvider.ts index 65ac2ed..4e4beaf 100644 --- a/src/views/settings/settingsTreeDataProvider.ts +++ b/src/views/settings/settingsTreeDataProvider.ts @@ -1,6 +1,7 @@ -import { CancellationToken, commands, EventEmitter, ExtensionContext, TreeCheckboxChangeEvent, TreeDataProvider, TreeItem, TreeItemCheckboxState, window, workspace } from "vscode"; +import { CancellationToken, commands, EventEmitter, ExtensionContext, QuickPickItem, QuickPickItemKind, ThemeIcon, TreeCheckboxChangeEvent, TreeDataProvider, TreeItem, TreeItemCheckboxState, window, workspace } from "vscode"; import { act } from "../../extension"; import { Visibility } from "../../settingsManager"; +import { StorageKey } from "../../storageManager"; import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem"; import SettingTreeItem from "./setting"; import WorkspaceFolderSettingsTreeItem from "./workspaceFolderSettings"; @@ -27,6 +28,112 @@ export default class SettingsTreeDataProvider implements TreeDataProvider { + const settings = await act.settingsManager.getSettings(settingTreeItem.workspaceFolder, false); + const variableNames = settings.variables.map(variable => variable.key); + if (variableNames.length > 0) { + const repository = await act.settingsManager.githubManager.getRepository(settingTreeItem.workspaceFolder, 'githubLocalActions.importFromGithub', [settingTreeItem]); + if (repository) { + const variableOptions: QuickPickItem[] = []; + const errors: string[] = []; + + await window.withProgress({ location: { viewId: SettingsTreeDataProvider.VIEW_ID } }, async () => { + // Get repository variables + const repositoryVariables = await act.settingsManager.githubManager.getVariables(repository.owner, repository.repo); + if (repositoryVariables.error) { + errors.push(repositoryVariables.error); + } else { + const matchingVariables = repositoryVariables.data.filter(variable => variableNames.includes(variable.name)); + if (matchingVariables.length > 0) { + variableOptions.push({ + label: 'Repository Variables', + kind: QuickPickItemKind.Separator + }); + + variableOptions.push( + ...matchingVariables.map(variable => { + return { + label: variable.name, + description: variable.value, + iconPath: new ThemeIcon('symbol-variable') + }; + }) + ); + } + } + + // Get environment variables + const environments = await act.settingsManager.githubManager.getEnvironments(repository.owner, repository.repo); + if (environments.error) { + errors.push(environments.error); + } else { + for (const environment of environments.data) { + const environmentVariables = await act.settingsManager.githubManager.getVariables(repository.owner, repository.repo, environment.name); + if (environmentVariables.error) { + errors.push(environmentVariables.error); + } else { + const matchingVariables = environmentVariables.data.filter(variable => variableNames.includes(variable.name)); + if (matchingVariables.length > 0) { + variableOptions.push({ + label: environment.name, + kind: QuickPickItemKind.Separator + }); + + variableOptions.push( + ...matchingVariables.map(variable => { + return { + label: variable.name, + description: variable.value, + iconPath: new ThemeIcon('symbol-variable') + }; + }) + ); + } + } + } + } + }); + + if (errors.length > 0) { + window.showErrorMessage(`Error(s) encountered retrieving variables from GitHub. Errors: ${[...new Set(errors)].join(' ')}`); + } + + if (variableOptions.length > 0) { + const selectedVariables = await window.showQuickPick(variableOptions, { + title: 'Select the variables to import from GitHub', + placeHolder: 'Variables', + matchOnDescription: true, + canPickMany: true + }); + + if (selectedVariables) { + const seen = new Set(); + const hasDuplicates = selectedVariables.some(variable => { + return seen.size === seen.add(variable.label).size; + }); + + if (hasDuplicates) { + window.showErrorMessage('Duplicate variables selected'); + } else { + for await (const variable of selectedVariables) { + const newSetting = settings.variables.find(existingVariable => existingVariable.key === variable.label); + if (newSetting && variable.description) { + newSetting.value = variable.description; + await act.settingsManager.editSetting(settingTreeItem.workspaceFolder, newSetting, StorageKey.Variables); + } + } + + this.refresh(); + } + } + } else if (errors.length === 0) { + window.showErrorMessage('No matching variables defined in Github'); + } + } + } else { + window.showErrorMessage('No variables found in workflow(s)'); + } + }), commands.registerCommand('githubLocalActions.editSetting', async (settingTreeItem: SettingTreeItem) => { const newValue = await window.showInputBox({ prompt: `Enter the value for ${settingTreeItem.setting.value}`,