From f8613bb73f1ae9b964d1fe56f6ce9d654c5371f3 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 15 Nov 2024 19:23:53 +0000 Subject: [PATCH 1/3] feat: repo select --- static/main.ts | 5 +- static/scripts/authentication.ts | 10 ++ static/scripts/config-parser.ts | 156 ++++++++++++++++++++++++------ static/scripts/render-manifest.ts | 120 +++++++++++++++++++++-- static/utils/toaster.ts | 12 ++- 5 files changed, 263 insertions(+), 40 deletions(-) diff --git a/static/main.ts b/static/main.ts index 821b9cc..fcd9aa7 100644 --- a/static/main.ts +++ b/static/main.ts @@ -37,6 +37,8 @@ export async function mainModule() { const cache = fetcher.checkManifestCache(); if (auth.isActiveSession()) { const userOrgs = await auth.getGitHubUserOrgs(); + localStorage.setItem("userOrgs", JSON.stringify(userOrgs)); + const appInstallations = await auth.octokit?.apps.listInstallationsForAuthenticatedUser(); const installs = appInstallations?.data.installations; @@ -52,8 +54,9 @@ export async function mainModule() { install: orgInstall, }; }) as OrgWithInstall[]; - renderer.renderOrgPicker(orgsWithInstalls.map((org) => org.org)); + const userOrgRepos = await auth.getGitHubUserOrgRepos(userOrgs); + localStorage.setItem("orgRepos", JSON.stringify(userOrgRepos)); if (Object.keys(cache).length === 0) { const manifestCache = await fetcher.fetchMarketplaceManifests(); diff --git a/static/scripts/authentication.ts b/static/scripts/authentication.ts index be69976..fd708e5 100644 --- a/static/scripts/authentication.ts +++ b/static/scripts/authentication.ts @@ -130,6 +130,16 @@ export class AuthService { return response.data.map((org: { login: string }) => org.login); } + public async getGitHubUserOrgRepos(orgs: string[]): Promise> { + const octokit = await this.getOctokit(); + const orgRepos: Record = {}; + for (const org of orgs) { + const response = await octokit.rest.repos.listForOrg({ org }); + orgRepos[org] = response.data.map((repo: { name: string }) => repo.name); + } + return orgRepos; + } + public async getOctokit(): Promise { if (this.octokit) return this.octokit; const token = await this.getSessionToken(); diff --git a/static/scripts/config-parser.ts b/static/scripts/config-parser.ts index 85e983a..9dfbe80 100644 --- a/static/scripts/config-parser.ts +++ b/static/scripts/config-parser.ts @@ -1,45 +1,64 @@ import YAML from "yaml"; import { Plugin, PluginConfig } from "../types/plugins"; import { Octokit } from "@octokit/rest"; -const repo = ".ubiquity-os"; -const path = `.github/.ubiquity-os.config.yml`; +import { RequestError } from "@octokit/request-error"; +import { toastNotification } from "../utils/toaster"; + +const CONFIG_PATH = ".github/.ubiquity-os.config.yml"; +const UBIQUITY_OS = ".ubiquity-os"; export class ConfigParser { repoConfig: string | null = null; repoConfigSha: string | null = null; newConfigYml: string | null = null; - async fetchUserInstalledConfig(org: string, env: "development" | "production", octokit: Octokit) { + async fetchUserInstalledConfig(org: string | null, repo: string | null, env: "development" | "production", octokit: Octokit | null) { + if (!org || !octokit) { + throw new Error("Missing required parameters"); + } + const content = this.loadConfig(); if (!content) { throw new Error("No content to push"); } - const existingConfig = await octokit.repos.getContent({ - owner: org, - repo: repo, - path: env === "production" ? path : path.replace(".yml", ".dev.yml"), - }); + try { + const existingConfig = await octokit.repos.getContent({ + owner: org, + repo: repo ? repo : UBIQUITY_OS, + path: env === "production" ? CONFIG_PATH : CONFIG_PATH.replace(".yml", ".dev.yml"), + ref: repo ? "development" : "main", + }); - if (existingConfig && "content" in existingConfig.data) { - this.repoConfigSha = existingConfig.data.sha; - this.repoConfig = atob(existingConfig.data.content); - } else { - throw new Error("No existing config found"); // todo create repo/dirs/files + if (existingConfig && "content" in existingConfig.data) { + this.repoConfigSha = existingConfig.data.sha; + this.repoConfig = atob(existingConfig.data.content); + } + } catch (er) { + console.log(er); + if (er instanceof RequestError && er.status === 404) { + const msgParts = ["Could not find the", env, "config file in", repo ? repo : `your org: ${org}`, ", would you like to create one?"]; + + toastNotification(msgParts.join(" "), { + type: "success", + actionText: "Create", + action: async () => { + await this.handleMissingStorageBranchOrFile(octokit, org, repo); + }, + }); + } } } parseConfig(config?: string | null): PluginConfig { - if (config) { + if (config && typeof config === "string") { return YAML.parse(config); + } else { + return YAML.parse(this.loadConfig()); } - if (!this.newConfigYml) { - this.loadConfig(); - } - return YAML.parse(`${this.newConfigYml}`); } - async updateConfig(org: string, env: "development" | "production", octokit: Octokit, option: "add" | "remove") { + async updateConfig(org: string, repo: string | null, env: "development" | "production", octokit: Octokit, option: "add" | "remove") { let repoPlugins = this.parseConfig(this.repoConfig).plugins; const newPlugins = this.parseConfig().plugins; @@ -76,26 +95,32 @@ export class ConfigParser { } this.saveConfig(); - return this.createOrUpdateFileContents(org, repo, path, env, octokit); + return this.createOrUpdateFileContents(org, repo, env, octokit); } - async createOrUpdateFileContents(org: string, repo: string, path: string, env: "development" | "production", octokit: Octokit) { + async createOrUpdateFileContents(org: string, repo: string | null, env: "development" | "production", octokit: Octokit) { const recentSha = await octokit.repos.getContent({ owner: org, - repo: repo, - path: env === "production" ? path : path.replace(".yml", ".dev.yml"), + repo: repo ? repo : UBIQUITY_OS, + path: env === "production" ? CONFIG_PATH : CONFIG_PATH.replace(".yml", ".dev.yml"), + ref: repo ? "development" : "main", }); const sha = "sha" in recentSha.data ? recentSha.data.sha : null; - return octokit.repos.createOrUpdateFileContents({ - owner: org, - repo: repo, - path: env === "production" ? path : path.replace(".yml", ".dev.yml"), - message: `chore: creating ${env} config`, - content: btoa(`${this.newConfigYml}`), - sha: `${sha}`, - }); + try { + return octokit.repos.createOrUpdateFileContents({ + owner: org, + repo: repo ? repo : UBIQUITY_OS, + path: env === "production" ? CONFIG_PATH : CONFIG_PATH.replace(".yml", ".dev.yml"), + message: `chore: updating ${env} config`, + content: btoa(`${this.newConfigYml}`), + sha: `${sha}`, + branch: repo ? "development" : "main", + }); + } catch (err) { + throw new Error(`Failed to create or update file contents:\n ${String(err)}`); + } } addPlugin(plugin: Plugin) { @@ -156,4 +181,73 @@ export class ConfigParser { this.newConfigYml = YAML.stringify({ plugins: [] }); this.saveConfig(); } + + async handleMissingStorageBranchOrFile(octokit: Octokit, owner: string, repo: string | null) { + let mostRecentDefaultHeadCommitSha; + + try { + const { data: defaultBranchData } = await octokit.rest.repos.getCommit({ + owner, + repo: repo ? repo : UBIQUITY_OS, + ref: repo ? "development" : "main", + }); + mostRecentDefaultHeadCommitSha = defaultBranchData.sha; + } catch (er) { + throw new Error(`Failed to get default branch commit sha:\n ${String(er)}`); + } + + // Check if the branch exists + try { + await octokit.rest.repos.getBranch({ + owner, + repo: repo ? repo : UBIQUITY_OS, + branch: repo ? "development" : "main", + }); + } catch (branchError) { + if (branchError instanceof RequestError || branchError instanceof Error) { + const { message } = branchError; + if (message.toLowerCase().includes(`branch not found`)) { + // Branch doesn't exist, create the branch + try { + await octokit.rest.git.createRef({ + owner, + repo: repo ? repo : UBIQUITY_OS, + ref: `refs/heads/${repo ? "development" : "main"}`, + sha: mostRecentDefaultHeadCommitSha, + }); + } catch (err) { + throw new Error(`Failed to create branch:\n ${String(err)}`); + } + } else { + throw new Error(`Failed to handle missing storage branch or file:\n ${String(branchError)}`); + } + } else { + throw new Error(`Failed to handle missing storage branch or file:\n ${String(branchError)}`); + } + } + + try { + // Create or update the file + await octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo: repo ? repo : UBIQUITY_OS, + path: CONFIG_PATH, + branch: repo ? "development" : "main", + message: `chore: create ${CONFIG_PATH.replace(/([A-Z])/g, " $1").toLowerCase()}`, + content: btoa("{\n}"), + sha: mostRecentDefaultHeadCommitSha, + }); + } catch (err) { + throw new Error(`Failed to create new config file:\n ${String(err)}`); + } + + const config = localStorage.getItem("selectedConfig"); + + const msgParts = ["Created an empty", config, "config in", repo ? repo : `your org: ${owner}`]; + + toastNotification(msgParts.join(" "), { + type: "success", + actionText: "Continue", + }); + } } diff --git a/static/scripts/render-manifest.ts b/static/scripts/render-manifest.ts index 031b84f..a12d48d 100644 --- a/static/scripts/render-manifest.ts +++ b/static/scripts/render-manifest.ts @@ -10,6 +10,7 @@ const ajv = new AJV({ allErrors: true, coerceTypes: true, strict: true }); const TDV_CENTERED = "table-data-value centered"; const PICKER_SELECT_STR = "picker-select"; +const NO_ORG_ERROR = "No selected org found"; type ExtendedHtmlElement = { [key in keyof T]: T[key] extends HTMLElement["innerHTML"] ? string | null : T[key]; @@ -22,7 +23,7 @@ export class ManifestRenderer { private _configDefaults: { [key: string]: { type: string; value: string; items: { type: string } | null } } = {}; private _auth: AuthService; private _backButton: HTMLButtonElement; - private _currentStep: "orgPicker" | "configSelector" | "pluginSelector" | "configEditor" = "orgPicker"; + private _currentStep: "orgPicker" | "repoPicker" | "configSelector" | "pluginSelector" | "configEditor" = "orgPicker"; private _orgs: string[] = []; constructor(auth: AuthService) { @@ -66,6 +67,16 @@ export class ManifestRenderer { this._renderPluginSelector(selectedConfig); break; } + case "repoPicker": { + const orgRepos = JSON.parse(localStorage.getItem("orgRepos") || "{}"); + this.renderRepoPicker(orgRepos); + break; + } + case "orgPicker": { + const orgs = JSON.parse(localStorage.getItem("userOrgs") || "[]"); + this.renderOrgPicker(orgs); + break; + } default: break; } @@ -78,6 +89,20 @@ export class ManifestRenderer { const selectedOrg = selectElement.value; if (selectedOrg) { localStorage.setItem("selectedOrg", selectedOrg); + const orgRepos = JSON.parse(localStorage.getItem("orgRepos") || "{}"); + this.renderRepoPicker(orgRepos); + } + } + + private _handleRepoSelection(event: Event): void { + const selectElement = event.target as HTMLSelectElement; + const selectedRepo = selectElement.value; + if (selectedRepo) { + localStorage.setItem("selectedRepo", selectedRepo); + const selectedOrg = localStorage.getItem("selectedOrg"); + if (!selectedOrg) { + throw new Error(NO_ORG_ERROR); + } this._renderConfigSelector(selectedOrg); } } @@ -107,7 +132,8 @@ export class ManifestRenderer { if (!org || !octokit) { throw new Error("No org or octokit found"); } - await this._configParser.fetchUserInstalledConfig(org, selectedConfig, octokit); + const selectedRepo = localStorage.getItem("selectedRepo"); + await this._configParser.fetchUserInstalledConfig(org, selectedRepo, selectedConfig, octokit); }; localStorage.setItem("selectedConfig", selectedConfig); this._renderPluginSelector(selectedConfig); @@ -188,6 +214,85 @@ export class ManifestRenderer { this._manifestGui?.classList.add("rendered"); } + public renderRepoPicker(repos: Record): void { + this._currentStep = "repoPicker"; + this._controlButtons(true); + this._backButton.style.display = "block"; + this._manifestGui?.classList.add("rendering"); + this._manifestGuiBody.innerHTML = null; + + if (!Reflect.ownKeys(repos).length) { + this._updateGuiTitle("No repositories found"); + this._manifestGuiBody.appendChild(document.createElement("tr")); + this._manifestGui?.classList.add("rendered"); + return; + } + + localStorage.setItem("orgRepos", JSON.stringify(repos)); + + const pickerRow = document.createElement("tr"); + const pickerCell = document.createElement("td"); + pickerCell.colSpan = 4; + pickerCell.className = TDV_CENTERED; + + this._updateGuiTitle("Select a Repository"); + const selectedOrg = localStorage.getItem("selectedOrg"); + + if (!selectedOrg) { + throw new Error(NO_ORG_ERROR); + } + + const orgConfigButton = createElement("button", { + id: "org-config-button", + class: "button", + textContent: "Select Organization Configuration", + }); + + orgConfigButton.addEventListener("click", () => { + this._renderConfigSelector(selectedOrg); + }); + + const orgConfigRow = document.createElement("tr"); + const orgConfigCell = document.createElement("td"); + orgConfigCell.colSpan = 4; + orgConfigCell.className = TDV_CENTERED; + orgConfigCell.appendChild(orgConfigButton); + orgConfigRow.appendChild(orgConfigCell); + this._manifestGuiBody.appendChild(orgConfigRow); + + const repoSelect = createElement("select", { + id: "repo-picker-select", + class: PICKER_SELECT_STR, + style: "width: 100%", + }); + + const defaultOption = createElement("option", { + value: null, + textContent: "Or select a repository...", + }); + repoSelect.appendChild(defaultOption); + + const orgRepos = repos[selectedOrg]; + + if (!orgRepos) { + throw new Error("No org repos found"); + } + + orgRepos.forEach((repo) => { + const option = createElement("option", { + value: repo, + textContent: repo, + }); + repoSelect.appendChild(option); + }); + + repoSelect.addEventListener("change", this._handleRepoSelection.bind(this)); + pickerCell.appendChild(repoSelect); + pickerRow.appendChild(pickerCell); + this._manifestGuiBody.appendChild(pickerRow); + this._manifestGui?.classList.add("rendered"); + } + private _renderConfigSelector(selectedOrg: string): void { this._currentStep = "configSelector"; this._backButton.style.display = "block"; @@ -223,7 +328,6 @@ export class ManifestRenderer { configSelect.addEventListener("change", this._handleConfigSelection.bind(this)); pickerCell.appendChild(configSelect); pickerRow.appendChild(pickerCell); - this._updateGuiTitle(`Select a Configuration for ${selectedOrg}`); this._manifestGuiBody.appendChild(pickerRow); } @@ -480,10 +584,11 @@ export class ManifestRenderer { } const org = localStorage.getItem("selectedOrg"); + const repo = localStorage.getItem("selectedRepo"); const config = localStorage.getItem("selectedConfig") as "development" | "production"; if (!org) { - throw new Error("No selected org found"); + throw new Error(NO_ORG_ERROR); } if (!config) { @@ -491,7 +596,7 @@ export class ManifestRenderer { } try { - await this._configParser.updateConfig(org, config, octokit, "add"); + await this._configParser.updateConfig(org, repo, config, octokit, "add"); } catch (error) { console.error("Error pushing config to GitHub:", error); toastNotification("An error occurred while pushing the configuration to GitHub.", { @@ -521,10 +626,11 @@ export class ManifestRenderer { } const org = localStorage.getItem("selectedOrg"); + const repo = localStorage.getItem("selectedRepo"); const config = localStorage.getItem("selectedConfig") as "development" | "production"; if (!org) { - throw new Error("No selected org found"); + throw new Error(NO_ORG_ERROR); } if (!config) { @@ -532,7 +638,7 @@ export class ManifestRenderer { } try { - await this._configParser.updateConfig(org, config, octokit, "remove"); + await this._configParser.updateConfig(org, repo, config, octokit, "remove"); } catch (error) { console.error("Error pushing config to GitHub:", error); toastNotification("An error occurred while pushing the configuration to GitHub.", { diff --git a/static/utils/toaster.ts b/static/utils/toaster.ts index 3fba532..5170afd 100644 --- a/static/utils/toaster.ts +++ b/static/utils/toaster.ts @@ -8,6 +8,7 @@ export function toastNotification( action?: () => void; shouldAutoDismiss?: boolean; duration?: number; + killAll?: boolean; } = {} ): void { const { type = "info", actionText, action, shouldAutoDismiss = false, duration = 5000 } = options; @@ -38,7 +39,16 @@ export function toastNotification( class: "toast-action", textContent: actionText, }); - actionButton.addEventListener("click", action); + + actionButton.addEventListener("click", async () => { + action(); + setTimeout(() => { + document.querySelectorAll(".toast").forEach((toast) => { + toast.classList.remove("show"); + setTimeout(() => toast.remove(), 250); + }); + }, 5000); + }); toastElement.appendChild(actionButton); } From b780a4646d94e3c07f300fde505480d109c27ab1 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 29 Nov 2024 02:02:12 +0000 Subject: [PATCH 2/3] chore: remove unused fn --- static/scripts/config-parser.ts | 69 --------------------------------- 1 file changed, 69 deletions(-) diff --git a/static/scripts/config-parser.ts b/static/scripts/config-parser.ts index a02b21c..e695375 100644 --- a/static/scripts/config-parser.ts +++ b/static/scripts/config-parser.ts @@ -202,73 +202,4 @@ export class ConfigParser { this.newConfigYml = YAML.stringify({ plugins: [] }); this.saveConfig(); } - - async handleMissingStorageBranchOrFile(octokit: Octokit, owner: string, repo: string | null) { - let mostRecentDefaultHeadCommitSha; - - try { - const { data: defaultBranchData } = await octokit.rest.repos.getCommit({ - owner, - repo: repo ? repo : UBIQUITY_OS, - ref: repo ? "development" : "main", - }); - mostRecentDefaultHeadCommitSha = defaultBranchData.sha; - } catch (er) { - throw new Error(`Failed to get default branch commit sha:\n ${String(er)}`); - } - - // Check if the branch exists - try { - await octokit.rest.repos.getBranch({ - owner, - repo: repo ? repo : UBIQUITY_OS, - branch: repo ? "development" : "main", - }); - } catch (branchError) { - if (branchError instanceof RequestError || branchError instanceof Error) { - const { message } = branchError; - if (message.toLowerCase().includes(`branch not found`)) { - // Branch doesn't exist, create the branch - try { - await octokit.rest.git.createRef({ - owner, - repo: repo ? repo : UBIQUITY_OS, - ref: `refs/heads/${repo ? "development" : "main"}`, - sha: mostRecentDefaultHeadCommitSha, - }); - } catch (err) { - throw new Error(`Failed to create branch:\n ${String(err)}`); - } - } else { - throw new Error(`Failed to handle missing storage branch or file:\n ${String(branchError)}`); - } - } else { - throw new Error(`Failed to handle missing storage branch or file:\n ${String(branchError)}`); - } - } - - try { - // Create or update the file - await octokit.rest.repos.createOrUpdateFileContents({ - owner, - repo: repo ? repo : UBIQUITY_OS, - path: CONFIG_PATH, - branch: repo ? "development" : "main", - message: `chore: create ${CONFIG_PATH.replace(/([A-Z])/g, " $1").toLowerCase()}`, - content: btoa("{\n}"), - sha: mostRecentDefaultHeadCommitSha, - }); - } catch (err) { - throw new Error(`Failed to create new config file:\n ${String(err)}`); - } - - const config = localStorage.getItem("selectedConfig"); - - const msgParts = ["Created an empty", config, "config in", repo ? repo : `your org: ${owner}`]; - - toastNotification(msgParts.join(" "), { - type: "success", - actionText: "Continue", - }); - } } From 4672facdf9dc941ff715f83639493683778810c8 Mon Sep 17 00:00:00 2001 From: Keyrxng <106303466+Keyrxng@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:29:12 +0000 Subject: [PATCH 3/3] chore: org config select --- static/scripts/config-parser.ts | 9 +++++- static/scripts/rendering/repo-select.ts | 43 +++++++++++++++++++++---- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/static/scripts/config-parser.ts b/static/scripts/config-parser.ts index e695375..68fb36e 100644 --- a/static/scripts/config-parser.ts +++ b/static/scripts/config-parser.ts @@ -1,7 +1,6 @@ import YAML from "yaml"; import { Plugin, PluginConfig } from "../types/plugins"; import { Octokit } from "@octokit/rest"; -import { RequestError } from "@octokit/request-error"; import { toastNotification } from "../utils/toaster"; import { CONFIG_FULL_PATH, CONFIG_ORG_REPO } from "@ubiquity-os/plugin-sdk/constants"; @@ -70,6 +69,10 @@ export class ConfigParser { } async fetchUserInstalledConfig(org: string, octokit: Octokit, repo = CONFIG_ORG_REPO, path = CONFIG_FULL_PATH) { + if (org === repo) { + repo = CONFIG_ORG_REPO; + } + if (repo === CONFIG_ORG_REPO) { await this.configRepoExistenceCheck(org, repo, octokit); } @@ -119,6 +122,10 @@ export class ConfigParser { } async createOrUpdateFileContents(org: string, repo: string, path: string, octokit: Octokit) { + if (org === repo) { + repo = CONFIG_ORG_REPO; + } + const recentSha = await octokit.repos.getContent({ owner: org, repo: repo, diff --git a/static/scripts/rendering/repo-select.ts b/static/scripts/rendering/repo-select.ts index 14da202..675a7f6 100644 --- a/static/scripts/rendering/repo-select.ts +++ b/static/scripts/rendering/repo-select.ts @@ -22,17 +22,47 @@ export function renderRepoPicker(renderer: ManifestRenderer, repos: Record { + localStorage.setItem("selectedRepo", selectedOrg); + fetchOrgConfig(renderer, selectedOrg, selectedOrg) + .then(() => { + renderPluginSelector(renderer); + }) + .catch((error) => { + console.error(error); + toastNotification("Error fetching org config", { type: "error" }); + }); + }); + + topLevelCell.appendChild(useOrgConfigButton); + topLevelRow.appendChild(topLevelCell); + renderer.manifestGuiBody.appendChild(topLevelRow); + const pickerRow = document.createElement("tr"); const pickerCell = document.createElement("td"); pickerCell.colSpan = 4; pickerCell.className = STRINGS.TDV_CENTERED; updateGuiTitle("Select a Repository"); - const selectedOrg = localStorage.getItem("selectedOrg"); - - if (!selectedOrg) { - throw new Error(`No selected org found in local storage`); - } const repoSelect = createElement("select", { id: "repo-picker-select", @@ -42,7 +72,7 @@ export function renderRepoPicker(renderer: ManifestRenderer, repos: Record