diff --git a/package-lock.json b/package-lock.json index 1daf7226448ad521408f28f94b660b3a6194444b..baa4cb993ffbc021f306aa5da9b6b2ea85658ec3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "dompurify": "^3.0.1", "filesize": "^10.0.6", "pinia": "^2.0.32", + "semver": "^7.3.8", "showdown": "^2.1.0", "vue": "^3.2.47", "vue-router": "^4.1.6", @@ -30,6 +31,7 @@ "@types/bootstrap": "^5.2.6", "@types/dompurify": "^2.4.0", "@types/node": "^16.11.45", + "@types/semver": "^7.3.13", "@types/showdown": "^2.0.0", "@vitejs/plugin-vue": "^3.2.0", "@vue/eslint-config-prettier": "^7.0.0", @@ -2363,6 +2365,12 @@ "integrity": "sha512-3rKg/L5x0rofKuuUt5zlXzOnKyIHXmIu5R8A0TuNDMF2062/AOIDBciFIjToLEJ/9F9DzkHNot+BpNsMI1OLdQ==", "dev": true }, + "node_modules/@types/semver": { + "version": "7.3.13", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", + "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", + "dev": true + }, "node_modules/@types/showdown": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/showdown/-/showdown-2.0.0.tgz", @@ -4988,7 +4996,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -5937,10 +5944,9 @@ } }, "node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -6674,8 +6680,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yocto-queue": { "version": "0.1.0", @@ -8694,6 +8699,12 @@ "integrity": "sha512-3rKg/L5x0rofKuuUt5zlXzOnKyIHXmIu5R8A0TuNDMF2062/AOIDBciFIjToLEJ/9F9DzkHNot+BpNsMI1OLdQ==", "dev": true }, + "@types/semver": { + "version": "7.3.13", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", + "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", + "dev": true + }, "@types/showdown": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/showdown/-/showdown-2.0.0.tgz", @@ -10495,7 +10506,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -11152,10 +11162,9 @@ } }, "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", "requires": { "lru-cache": "^6.0.0" } @@ -11671,8 +11680,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yocto-queue": { "version": "0.1.0", diff --git a/package.json b/package.json index ca88bc33a4c626fbff6e516e4522158c6449f3e1..c184236b1dc8476c76f6d563ff8331ab60798e83 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dompurify": "^3.0.1", "filesize": "^10.0.6", "pinia": "^2.0.32", + "semver": "^7.3.8", "showdown": "^2.1.0", "vue": "^3.2.47", "vue-router": "^4.1.6", @@ -36,6 +37,7 @@ "@types/dompurify": "^2.4.0", "@types/node": "^16.11.45", "@types/showdown": "^2.0.0", + "@types/semver": "^7.3.13", "@vitejs/plugin-vue": "^3.2.0", "@vue/eslint-config-prettier": "^7.0.0", "@vue/eslint-config-typescript": "^11.0.2", diff --git a/src/components/object-storage/modals/UploadObjectModal.vue b/src/components/object-storage/modals/UploadObjectModal.vue index 12851500b4c9b61b2c2abdd311bb4b06cbec51c7..5a4e8c4971321b49f74b4136eeb592ff4f630a38 100644 --- a/src/components/object-storage/modals/UploadObjectModal.vue +++ b/src/components/object-storage/modals/UploadObjectModal.vue @@ -94,7 +94,7 @@ async function uploadObject() { bucket: props.bucketName, size: formState.file?.size ?? 0, last_modified: dayjs().toISOString(), - content_type: formState.file?.type ?? "text/plain", + content_type: formState.file?.type ?? "binary/octet-stream", }); formState.key = ""; ( diff --git a/src/components/workflows/modals/CreateWorkflowModal.vue b/src/components/workflows/modals/CreateWorkflowModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..9a4a96c54a423a86e7487b7353a08ba3e55f5245 --- /dev/null +++ b/src/components/workflows/modals/CreateWorkflowModal.vue @@ -0,0 +1,386 @@ +<script setup lang="ts"> +import { computed, onMounted, reactive, ref } from "vue"; +import type { ComputedRef } from "vue"; +import { Modal, Toast } from "bootstrap"; +import type { + Body_Workflow_create_workflow, + WorkflowOut, +} from "@/client/workflow"; +import BootstrapModal from "@/components/modals/BootstrapModal.vue"; +import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; +import { ApiError, WorkflowService } from "@/client/workflow"; +import { GitRepository } from "@/utils/GitRepository"; +import valid from "semver/functions/valid"; + +let createWorkflowModal: Modal | null = null; +let successToast: Toast | null = null; +const workflowCreateForm = ref<HTMLFormElement | undefined>(undefined); +const workflowIconInput = ref<HTMLInputElement | undefined>(undefined); +const workflowVersionElement = ref<HTMLInputElement | undefined>(undefined); +const workflowGitCommitHashElement = ref<HTMLInputElement | undefined>( + undefined +); +const workflowNameElement = ref<HTMLInputElement | undefined>(undefined); +const workflowRepositoryElement = ref<HTMLInputElement | undefined>(undefined); +const randomIDSuffix = Math.random().toString(16).substr(2, 8); + +const workflow = reactive<Body_Workflow_create_workflow>({ + icon: undefined, + name: "", + short_description: "", + repository_url: "", + git_commit_hash: "", + initial_version: undefined, +}); + +const gitIcon: ComputedRef<string> = computed(() => { + let gitProvider = "git-alt"; + if (workflow.repository_url.includes("github")) { + gitProvider = "github"; + } else if (workflow.repository_url.includes("gitlab")) { + gitProvider = "gitlab"; + } else if (workflow.repository_url.includes("bitbucket")) { + gitProvider = "bitbucket"; + } + return "fa-brands fa-".concat(gitProvider); +}); + +const emit = defineEmits<{ + (e: "workflow-created", workflow: WorkflowOut): void; +}>(); + +const formState = reactive<{ + loading: boolean; + checkRepoLoading: boolean; + validated: boolean; + allowUpload: boolean; + missingFiles: string[]; + unsupportedRepository: boolean; +}>({ + validated: false, + allowUpload: false, + loading: false, + checkRepoLoading: false, + missingFiles: [], + unsupportedRepository: false, +}); + +const props = defineProps<{ + modalID: string; +}>(); + +const formValid = computed<boolean>( + () => workflowCreateForm.value?.checkValidity() ?? false +); + +function modalClosed() { + formState.validated = false; +} + +function createWorkflow() { + formState.validated = true; + workflow.name = workflow.name.trim(); + workflow.short_description = workflow.short_description.trim(); + workflow.initial_version = workflow.initial_version?.trim(); + if (formValid.value && formState.allowUpload) { + formState.loading = true; + workflowNameElement.value?.setCustomValidity(""); + workflowGitCommitHashElement.value?.setCustomValidity(""); + WorkflowService.workflowCreateWorkflow(workflow) + .then((w) => { + emit("workflow-created", w); + successToast?.show(); + createWorkflowModal?.hide(); + resetForm(); + }) + .catch((error: ApiError) => { + const errorText = error.body["detail"]; + if (errorText.startsWith("Workflow with name")) { + workflowNameElement.value?.setCustomValidity("Name is already taken"); + } else if (errorText.startsWith("Workflow with git_commit_hash")) { + workflowGitCommitHashElement.value?.setCustomValidity( + "Git commit is already used by a workflow" + ); + } + }) + .finally(() => { + formState.loading = false; + }); + } +} + +function resetForm() { + formState.validated = false; + workflow.icon = undefined; + workflow.name = ""; + workflow.short_description = ""; + workflow.repository_url = ""; + workflow.git_commit_hash = ""; + workflow.initial_version = undefined; + if (workflowIconInput.value != undefined) { + workflowIconInput.value.value = ""; + } +} + +function iconChanged() { + workflow.icon = workflowIconInput.value?.files?.[0].slice(); +} + +function checkRepository() { + formState.validated = true; + if (formValid.value && !formState.allowUpload) { + formState.unsupportedRepository = false; + formState.missingFiles = []; + workflowRepositoryElement.value?.setCustomValidity(""); + workflowGitCommitHashElement.value?.setCustomValidity(""); + try { + const repo = GitRepository.buildRepository( + workflow.repository_url, + workflow.git_commit_hash + ); + repo + .checkFilesExist( + ["main.nf", "CHANGELOG.md", "README.md", "nextflow_schema.json"], + true + ) + .then(() => { + formState.allowUpload = true; + }) + .catch((e: Error) => { + formState.missingFiles = e.message.split(","); + workflowGitCommitHashElement.value?.setCustomValidity( + "Files are missing in the repository" + ); + }); + } catch (e) { + formState.unsupportedRepository = true; + workflowRepositoryElement.value?.setCustomValidity( + "Repository is not supported" + ); + } + } +} + +function checkVersionValidity() { + if (valid(workflow.initial_version) == null) { + workflowVersionElement.value?.setCustomValidity( + "Please use semantic versioning" + ); + } else { + workflowVersionElement.value?.setCustomValidity(""); + } +} + +onMounted(() => { + createWorkflowModal = new Modal("#" + props.modalID); + successToast = new Toast("#successToast-" + randomIDSuffix); +}); +</script> + +<template> + <div class="toast-container position-fixed top-toast end-0 p-3"> + <div + role="alert" + aria-live="assertive" + aria-atomic="true" + class="toast text-bg-success align-items-center border-0" + data-bs-autohide="true" + :id="'successToast-' + randomIDSuffix" + > + <div class="d-flex"> + <div class="toast-body">Successfully created Workflow</div> + <button + type="button" + class="btn-close btn-close-white me-2 m-auto" + data-bs-dismiss="toast" + aria-label="Close" + ></button> + </div> + </div> + </div> + <bootstrap-modal + :modalID="modalID" + :static-backdrop="true" + modal-label="Create Workflow Modal" + v-on="{ 'hidden.bs.modal': modalClosed }" + > + <template v-slot:header> Create new Workflow</template> + <template v-slot:body> + <form + id="workflowCreateForm" + :class="{ 'was-validated': formState.validated }" + ref="workflowCreateForm" + > + <div class="mb-3"> + <label for="workflowNameInput" class="form-label" + >Workflow Name</label + > + <input + type="text" + class="form-control" + id="workflowNameInput" + placeholder="Short descriptive name" + required + ref="workflowNameElement" + minlength="3" + maxlength="64" + v-model="workflow.name" + /> + </div> + <div class="mb-3"> + <label for="workflowDescriptionInput" class="form-label"> + Short Description {{ workflow.short_description.length }} / 64 + </label> + <div class="input-group"> + <textarea + class="form-control" + id="workflowDescriptionInput" + required + rows="3" + minlength="64" + maxlength="256" + v-model="workflow.short_description" + placeholder="Describe the purpose of the workflow in a few words." + ></textarea> + <div class="invalid-feedback"> + Requirements + <ul class="mb-0"> + <li>At least 64 Characters long</li> + </ul> + </div> + </div> + </div> + <div class="mb-3"> + <label for="workflowRepositoryInput" class="form-label" + >Git Repository URL</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon :icon="gitIcon" /> + </div> + <input + type="url" + class="form-control" + id="workflowRepositoryInput" + placeholder="https://..." + required + ref="workflowRepositoryElement" + v-model="workflow.repository_url" + @change="formState.allowUpload = false" + aria-describedby="gitRepoProviderHelp" + /> + </div> + <div id="gitRepoProviderHelp" class="form-text"> + We support Github and GitLab Repositories + </div> + <div class="text-danger"> + <div v-if="formState.unsupportedRepository"> + Repository is not supported + </div> + </div> + </div> + <div class="mb-3"> + <label for="workflowGitCommitInput" class="form-label" + >Git Commit Hash</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-code-commit" /> + </div> + <input + type="text" + class="form-control text-lowercase" + id="workflowGitCommitInput" + placeholder="ba8bcd9..." + required + ref="workflowGitCommitHashElement" + maxlength="40" + pattern="[0-9a-f]{40}" + v-model="workflow.git_commit_hash" + @change="formState.allowUpload = false" + /> + </div> + </div> + <div v-if="formState.missingFiles.length > 0" class="text-danger"> + The following files are missing in the repository + <ul> + <li v-for="file in formState.missingFiles" :key="file"> + {{ file }} + </li> + </ul> + </div> + <div class="row mb-3"> + <div class="col-4"> + <label for="workflowVersionInput" class="form-label" + >Initial Version</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-tag" /> + </div> + <input + type="text" + class="form-control" + id="workflowRepositoryInput" + placeholder="v1.0.0" + maxlength="10" + ref="workflowVersionElement" + @change="checkVersionValidity" + v-model="workflow.initial_version" + /> + </div> + </div> + <div class="col-8"> + <label for="workflowIconInput" class="form-label" + >Optional Icon</label + > + <input + type="file" + ref="workflowIconInput" + accept="image/*" + class="form-control" + id="workflowIconInput" + @change="iconChanged" + /> + </div> + </div> + </form> + </template> + <template v-slot:footer> + <button + type="button" + class="btn btn-info me-auto" + @click="checkRepository" + :disabled="formState.allowUpload" + > + <span + v-if="formState.checkRepoLoading" + class="spinner-border spinner-border-sm" + role="status" + aria-hidden="true" + ></span> + Check Repository + </button> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal"> + Close + </button> + <button + type="submit" + form="workflowCreateForm" + class="btn btn-primary" + :disabled="formState.loading || !formState.allowUpload" + @click.prevent="createWorkflow" + > + <span + v-if="formState.loading" + class="spinner-border spinner-border-sm" + role="status" + aria-hidden="true" + ></span> + Save + </button> + </template> + </bootstrap-modal> +</template> + +<style scoped></style> diff --git a/src/utils/GitRepository.ts b/src/utils/GitRepository.ts new file mode 100644 index 0000000000000000000000000000000000000000..04e183394da94378cb1266ff70723436eb549b8b --- /dev/null +++ b/src/utils/GitRepository.ts @@ -0,0 +1,80 @@ +import axios from "axios"; + +export abstract class GitRepository { + protected repo: URL; + protected gitCommitHash: string; + protected constructor(repoUrl: string, gitCommitHash: string) { + this.repo = new URL(repoUrl); + this.gitCommitHash = gitCommitHash; + } + async checkFileExist(filepath: string): Promise<boolean> { + try { + await axios.head(this.fileUrl(filepath)); + } catch (e) { + return false; + } + return true; + } + + async checkFilesExist( + files: string[], + raiseError = false + ): Promise<boolean[]> { + const checks = Promise.all( + files.map((filepath) => this.checkFileExist(filepath)) + ); + const results = await checks; + if (raiseError) { + const missingFiles = files.filter((file, index) => !results[index]); + if (missingFiles.length > 0) { + throw new Error(missingFiles.reduce((a, b) => a + "," + b)); + } + } + return results; + } + protected abstract fileUrl(filepath: string): string; + + static buildRepository( + repoUrl: string, + gitCommitHash: string + ): GitRepository { + if (repoUrl.includes("github")) { + return new GithubRepository(repoUrl, gitCommitHash); + } else if (repoUrl.includes("gitlab")) { + return new GitlabRepository(repoUrl, gitCommitHash); + } + throw new Error(`Repository is not supported.`); + } +} + +class GithubRepository extends GitRepository { + private readonly account: string; + private readonly repoName: string; + constructor(repoUrl: string, gitCommitHash: string) { + super(repoUrl, gitCommitHash); + const pathParts = this.repo.pathname.slice(1).split("/"); + this.account = pathParts[0]; + this.repoName = pathParts[1]; + } + + fileUrl(filepath: string): string { + return `https://raw.githubusercontent.com/${this.account}/${this.repoName}/${this.gitCommitHash}/${filepath}`; + } +} + +class GitlabRepository extends GitRepository { + private readonly account: string[]; + private readonly repoName: string; + constructor(repoUrl: string, gitCommitHash: string) { + super(repoUrl, gitCommitHash); + const pathParts = this.repo.pathname.slice(1).split("/"); + this.account = pathParts.slice(0, pathParts.length - 2); + this.repoName = pathParts[pathParts.length - 1]; + } + + fileUrl(filepath: string): string { + return `https://${this.repo.host}/${this.account.reduce((a, b) => + a.concat(`/${b}`) + )}/${this.repoName}/-/raw/${this.gitCommitHash}/${filepath}`; + } +} diff --git a/src/views/workflows/ListWorkflowsView.vue b/src/views/workflows/ListWorkflowsView.vue index f133a3b899b73c1c37e1ada8babbe59a9e0c8c34..f1e0d6ef15a4bd20e26e51234024c058461b07e4 100644 --- a/src/views/workflows/ListWorkflowsView.vue +++ b/src/views/workflows/ListWorkflowsView.vue @@ -22,7 +22,10 @@ const workflowsState = reactive({ sortDesc: boolean; }); -const bla: Record<string, (a: WorkflowOut, b: WorkflowOut) => boolean> = { +const filterFunctionMapping: Record< + string, + (a: WorkflowOut, b: WorkflowOut) => boolean +> = { name: (a: WorkflowOut, b: WorkflowOut) => workflowsState.sortDesc ? a.name > b.name : a.name < b.name, release: (a: WorkflowOut, b: WorkflowOut) => { @@ -51,7 +54,9 @@ const processedWorkflows: ComputedRef<WorkflowOut[]> = computed(() => { filterWorkflowByString(workflow) && filterWorkflowWithoutVersion(workflow) ), - ].sort((a, b) => (bla[workflowsState.sortByAttribute](a, b) ? 1 : -1)); + ].sort((a, b) => + filterFunctionMapping[workflowsState.sortByAttribute](a, b) ? 1 : -1 + ); }); onMounted(() => { diff --git a/src/views/workflows/MyWorkflowsView.vue b/src/views/workflows/MyWorkflowsView.vue index 7f091d39c85a89ba216343a92fcae15a31ce54b2..079aa54799a1d01281e291bb8beb28f50ea99d03 100644 --- a/src/views/workflows/MyWorkflowsView.vue +++ b/src/views/workflows/MyWorkflowsView.vue @@ -4,6 +4,8 @@ import { Status, WorkflowService } from "@/client/workflow"; import type { WorkflowOut } from "@/client/workflow"; import { useAuthStore } from "@/stores/auth"; import WorkflowWithVersionsCard from "@/components/workflows/WorkflowWithVersionsCard.vue"; +import CreateWorkflowModal from "@/components/workflows/modals/CreateWorkflowModal.vue"; +import CardTransitionGroup from "@/components/transitions/CardTransitionGroup.vue"; const userRepository = useAuthStore(); const workflowsState = reactive<{ @@ -30,8 +32,21 @@ onMounted(() => { </script> <template> - <h1 class="mt-5">My Workflows</h1> - <div + <create-workflow-modal + modal-i-d="createWorkflowModal" + @workflow-created="(w) => workflowsState.workflows.push(w)" + /> + <div class="d-flex justify-content-between align-items-center mt-5"> + <div class="fs-1 w-fit">My Workflows</div> + <button + class="btn btn-lg btn-primary w-fit" + data-bs-toggle="modal" + data-bs-target="#createWorkflowModal" + > + Create + </button> + </div> + <card-transition-group v-if="!workflowsState.loading" class="d-flex flex-wrap align-items-center justify-content-between mt-5" > @@ -41,7 +56,7 @@ onMounted(() => { :workflow="workflow" :loading="false" /> - </div> + </card-transition-group> <div v-else class="d-flex flex-wrap align-items-center justify-content-between mt-5" diff --git a/src/views/workflows/WorkflowView.vue b/src/views/workflows/WorkflowView.vue index 8cd5816839dcca992fb49ae6415bc3760818b464..a89cc5d7b76560e73aa521a9b6d5d7e781ebc427 100644 --- a/src/views/workflows/WorkflowView.vue +++ b/src/views/workflows/WorkflowView.vue @@ -111,7 +111,7 @@ const versionLaunchable: ComputedRef<boolean> = computed( ); const gitIcon: ComputedRef<string> = computed(() => { - let gitProvider = "git"; + let gitProvider = "git-alt"; if (workflowState.workflow !== undefined) { if (workflowState.workflow.repository_url.includes("github")) { gitProvider = "github";