Skip to main content
Glama
change_sets.store.ts23.2 kB
import { defineStore } from "pinia"; import * as _ from "lodash-es"; import { watch } from "vue"; import { ApiRequest, addStoreHooks } from "@si/vue-lib/pinia"; import { useToast } from "vue-toastification"; import { ulid } from "ulid"; import { URLPattern } from "@si/vue-lib"; import { ChangeSet, ChangeSetId, ChangeSetStatus, } from "@/api/sdf/dal/change_set"; import { WorkspaceMetadata } from "@/api/sdf/dal/workspace"; import router from "@/router"; import { UserId, useAuthStore } from "@/store/auth.store"; import IncomingChangesMerging from "@/components/toasts/IncomingChangesMerging.vue"; import MovedToHead from "@/components/toasts/MovedToHead.vue"; import RebaseOnBase from "@/components/toasts/RebaseOnBase.vue"; import ChangeSetStatusChanged from "@/components/toasts/ChangeSetStatusChanged.vue"; import { useWorkspacesStore } from "./workspaces.store"; import { useRealtimeStore } from "./realtime/realtime.store"; import { useRouterStore } from "./router.store"; import handleStoreError from "./errors"; import { useStatusStore } from "./status.store"; import * as heimdall from "./realtime/heimdall"; const toast = useToast(); export const diagramUlid = ulid(); export interface StatusWithBase { baseHasUpdates: boolean; changeSetHasUpdates: boolean; conflictsWithBase: boolean; } export interface OpenChangeSetsView { headChangeSetId: ChangeSetId; changeSets: ChangeSet[]; } export type ChangeSetApprovalId = string; export type Ulid = string; export interface ChangeSetApprovalRequirement { entityId: Ulid; entityKind: string; requiredCount: number; isSatisfied: boolean; applicableApprovalIds: ChangeSetApprovalId[]; approverGroups: Record<string, string[]>; approverIndividuals: string[]; } export type ApprovalStatus = "Approved" | "Rejected"; export interface ChangeSetApproval { id: ChangeSetApprovalId; userId: UserId; status: ApprovalStatus; isValid: boolean; // is this approval "out of date" based on the checksum } export interface ApprovalData { requirements: ChangeSetApprovalRequirement[]; latestApprovals: ChangeSetApproval[]; } export const approverForChangeSet = ( userId: UserId, approvalData: ApprovalData, ) => approvalData.requirements.some((r) => Object.values(r.approverGroups) .flat() .concat(r.approverIndividuals) .includes(userId), ); export function useChangeSetsStore() { const workspacesStore = useWorkspacesStore(); const workspacePk = workspacesStore.selectedWorkspacePk; const authStore = useAuthStore(); const realtimeStore = useRealtimeStore(); const BASE_API = [ "v2", "workspaces", { workspacePk }, "change-sets", ] as URLPattern; return addStoreHooks( workspacePk, undefined, defineStore(`w${workspacePk || "NONE"}/change-sets`, { state: () => ({ headChangeSetId: null as ChangeSetId | null, changeSetsById: {} as Record<ChangeSetId, ChangeSet>, changeSetsWrittenAtById: {} as Record<ChangeSetId, Date>, creatingChangeSet: false as boolean, postApplyActor: null as string | null, postAbandonActor: null as string | null, statusWithBase: {} as Record<ChangeSetId, StatusWithBase>, defaultApprovers: [] as UserId[], changeSetsApprovalData: {} as Record<ChangeSetId, ApprovalData>, }), getters: { openChangeSetIds(): ChangeSetId[] { return this.openChangeSets.map((changeSet) => changeSet.id); }, currentUserIsDefaultApprover(): boolean { const userPk = authStore.user?.pk; if (!userPk) return false; return this.defaultApprovers.includes(userPk); }, allChangeSets: (state) => _.values(state.changeSetsById), changeSetsNeedingApproval(): ChangeSet[] { return _.filter(this.allChangeSets, (cs) => [ChangeSetStatus.NeedsApproval].includes(cs.status), ); }, openChangeSets(): ChangeSet[] { return _.filter(this.allChangeSets, (cs) => [ ChangeSetStatus.Open, ChangeSetStatus.NeedsApproval, ChangeSetStatus.NeedsAbandonApproval, ChangeSetStatus.Rejected, ChangeSetStatus.Approved, ].includes(cs.status), ); }, urlSelectedChangeSetId(): ChangeSetId | undefined { const route = useRouterStore().currentRoute; const id = route?.params?.changeSetId as ChangeSetId | undefined; if (id === "head" && this.headChangeSetId) { return this.headChangeSetId; } return id; }, selectedChangeSet(): ChangeSet | null { return this.changeSetsById[this.urlSelectedChangeSetId || ""] || null; }, headSelected(): boolean { if (this.headChangeSetId) { return this.urlSelectedChangeSetId === this.headChangeSetId; } return false; }, selectedChangeSetLastWrittenAt(): Date | null { return ( this.changeSetsWrittenAtById[this.selectedChangeSet?.id || ""] ?? null ); }, selectedChangeSetId(): ChangeSetId | undefined { return this.selectedChangeSet?.id; }, // expose here so other stores can get it without needing to call useWorkspaceStore directly selectedWorkspacePk: () => workspacePk, }, actions: { async setActiveChangeset(changeSetId: string, stayOnView = false) { // We need to force refetch changesets since there's a race condition in which redirects // will be triggered but the frontend won't have refreshed the list of changesets if (!this.changeSetsById[changeSetId]) { await this.FETCH_CHANGE_SETS(); } const route = router.currentRoute.value; const params = { ...route.params }; let name = route.name; // if abandoning changeset and you were looking at view, it may not exist in HEAD if (!stayOnView && name === "workspace-compose-view") { name = "workspace-compose"; delete params.viewId; } if (params.viewId) { name = "workspace-compose-view"; } await router.push({ name: name ?? undefined, params: { ...params, changeSetId, }, }); const statusStore = useStatusStore(changeSetId); statusStore.resetWhenChangingChangeset(); }, async FETCH_APPROVAL_STATUS(changeSetId: ChangeSetId) { return new ApiRequest<ApprovalData>({ method: "get", url: BASE_API.concat([{ changeSetId }, "approval_status"]), onSuccess: (response) => { this.changeSetsApprovalData[changeSetId] = response; }, }); }, async FETCH_CHANGE_SETS() { return new ApiRequest<WorkspaceMetadata>({ method: "get", url: BASE_API, onSuccess: (response) => { this.headChangeSetId = response.defaultChangeSetId; this.changeSetsById = _.keyBy(response.changeSets, "id"); this.defaultApprovers = response.approvers; }, }); }, async CREATE_CHANGE_SET(name: string) { return new ApiRequest<ChangeSet>({ method: "post", url: BASE_API.concat(["create_change_set"]), params: { name, }, onSuccess: (response) => { this.changeSetsById[response.id] = response; }, }); }, async ABANDON_CHANGE_SET() { if (this.creatingChangeSet) throw new Error("Wait until change set is created to abandon"); if (!this.selectedChangeSet) throw new Error("Select a change set"); else if (this.headSelected) { throw new Error("You cannot abandon HEAD!"); } if ( router.currentRoute.value.name && ["workspace-lab-packages", "workspace-lab-assets"].includes( router.currentRoute.value.name.toString(), ) ) { router.push({ name: "workspace-lab" }); } const selectedChangeSetId = this.selectedChangeSetId; return new ApiRequest({ method: "post", url: BASE_API.concat([{ selectedChangeSetId }, "abandon"]), optimistic: () => { // remove component selections, its corrupting navigation const key = `${this.selectedChangeSetId}_selected_component`; window.localStorage.removeItem(key); const headkey = `${this.headChangeSetId}_selected_component`; window.localStorage.removeItem(headkey); }, onSuccess: (_response) => { const statusStore = useStatusStore(); statusStore.resetWhenChangingChangeset(); }, }); }, async REQUEST_CHANGE_SET_APPROVAL() { if (!this.selectedChangeSet) throw new Error("Select a change set"); const selectedChangeSetId = this.selectedChangeSetId; return new ApiRequest({ method: "post", url: BASE_API.concat([{ selectedChangeSetId }, "request_approval"]), }); }, async APPROVE_CHANGE_SET_FOR_APPLY(id?: ChangeSetId) { const changeSetId = id || this.selectedChangeSetId; if (!changeSetId) throw new Error("Select a change set"); return new ApiRequest({ method: "post", url: BASE_API.concat([{ changeSetId }, "approve"]), params: { status: "Approved", }, }); }, async REJECT_CHANGE_SET_APPLY(id?: ChangeSetId) { const changeSetId = id || this.selectedChangeSetId; if (!changeSetId) throw new Error("Select a change set"); return new ApiRequest({ method: "post", url: BASE_API.concat([{ changeSetId }, "approve"]), params: { status: "Rejected", }, }); }, async CANCEL_APPROVAL_REQUEST() { if (!this.selectedChangeSet) throw new Error("Select a change set"); const selectedChangeSetId = this.selectedChangeSetId; return new ApiRequest({ method: "post", url: BASE_API.concat([ { selectedChangeSetId }, "cancel_approval_request", ]), }); }, async REOPEN_CHANGE_SET() { if (!this.selectedChangeSet) throw new Error("Select a change set"); const selectedChangeSetId = this.selectedChangeSetId; return new ApiRequest({ method: "post", url: BASE_API.concat([{ selectedChangeSetId }, "reopen"]), }); }, async APPLY_CHANGE_SET(username: string) { if (!this.selectedChangeSet) throw new Error("Select a change set"); const selectedChangeSetId = this.selectedChangeSetId; return new ApiRequest({ method: "post", url: BASE_API.concat([{ selectedChangeSetId }, "apply"]), optimistic: () => { toast({ component: IncomingChangesMerging, props: { username, }, }); }, _delay: 2000, onFail: (response) => { if (response.response.data.error.message) { toast(response.response.data.error.message, { timeout: 5000 }); } }, }); }, async RENAME_CHANGE_SET(changeSetId: ChangeSetId, newName: string) { return new ApiRequest({ method: "post", url: BASE_API.concat([{ changeSetId }, "rename"]), params: { newName }, optimistic: () => { const changeSet = this.changeSetsById[changeSetId]; if (!changeSet) return; const oldName = changeSet.name; changeSet.name = newName; return () => { // if it fails, revert the name changeSet.name = oldName; }; }, }); }, getAutoSelectedChangeSetId() { const lastChangeSetId = sessionStorage.getItem( `SI:LAST_CHANGE_SET/${workspacePk}`, ); if ( lastChangeSetId && this.changeSetsById[lastChangeSetId]?.status === ChangeSetStatus.Open ) { return lastChangeSetId; } if (this.openChangeSets?.length <= 2) { // will select the single open change set or head if thats all that exists // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return _.last(this.openChangeSets)!.id; } return this.headChangeSetId ?? false; }, registerRequestsBegin(requestUlid: string, actionName: string) { realtimeStore.inflightRequests.set(requestUlid, actionName); }, registerRequestsEnd(requestUlid: string) { realtimeStore.inflightRequests.delete(requestUlid); }, }, async onActivated() { if (!workspacePk) return; await this.FETCH_CHANGE_SETS(); const stopWatchSelectedChangeSet = watch( () => this.selectedChangeSet, () => { // store last used change set (per workspace) in localstorage if (this.selectedChangeSet && workspacePk) { sessionStorage.setItem( `SI:LAST_CHANGE_SET/${workspacePk}`, this.selectedChangeSet.id, ); } }, { immediate: true }, ); realtimeStore.subscribe(this.$id, `workspace/${workspacePk}`, [ { eventType: "ChangeSetCreated", callback: this.FETCH_CHANGE_SETS, }, { eventType: "ChangeSetStatusChanged", callback: async (data) => { if ( [ ChangeSetStatus.Abandoned, ChangeSetStatus.Applied, ChangeSetStatus.Closed, ].includes(data.changeSet.status) && data.changeSet.id !== this.headChangeSetId ) { heimdall.prune(workspacePk, data.changeSet.id); } // If I'm the one who requested this change set - toast that it's been approved/rejected/etc. if ( data.changeSet.mergeRequestedByUserId === authStore.user?.pk ) { if (data.changeSet.status === ChangeSetStatus.Rejected) { toast({ component: ChangeSetStatusChanged, props: { user: data.changeSet.reviewedByUser, command: "rejected the request to apply", changeSetName: data.changeSet.name, }, }); } else if (data.changeSet.status === ChangeSetStatus.Approved) { toast({ component: ChangeSetStatusChanged, props: { user: data.changeSet.reviewedByUser, command: "approved the request to apply", changeSetName: data.changeSet.name, }, }); } } else if ( data.changeSet.status === ChangeSetStatus.NeedsApproval ) { this.FETCH_APPROVAL_STATUS(data.changeSet.id); } await this.FETCH_CHANGE_SETS(); }, }, { eventType: "ChangeSetAbandoned", callback: async (data) => { if (data.changeSetId !== this.headChangeSetId) { heimdall.prune(workspacePk, data.changeSetId); } const changeSetName = this.selectedChangeSet?.name; if (data.changeSetId === this.selectedChangeSetId) { if (this.headChangeSetId) { await this.setActiveChangeset(this.headChangeSetId, false); // TODO(Wendy) - move this logic out of the store when we can // START REDIRECT FOR ABANDONED CHANGE SET IN NEWHOTNESS const route = router.currentRoute.value; const name = route.name; if (name?.toString().startsWith("new-hotness")) { const params = { ...route.params }; delete params.componentId; await router.push({ name: "new-hotness-head", params: { ...params, changeSetId: this.headChangeSetId, }, }); } // END REDIRECT FOR ABANDONED CHANGE SET IN NEWHOTNESS // FIXME(nick): use a new design in the new UI, but keep the toast. toast({ component: MovedToHead, props: { icon: "trash", changeSetName, action: "abandoned", }, }); } } await this.FETCH_CHANGE_SETS(); }, }, { eventType: "ChangeSetCancelled", callback: (data) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { changeSetId, userPk } = data as any as { changeSetId: string; userPk: UserId; }; const changeSet = this.changeSetsById[changeSetId]; if (changeSet) { changeSet.status = ChangeSetStatus.Abandoned; if (this.selectedChangeSet?.id === changeSetId) { this.postAbandonActor = userPk; } this.changeSetsById[changeSetId] = changeSet; } this.FETCH_CHANGE_SETS(); }, }, { eventType: "ChangeSetApplied", callback: (data) => { const { changeSetId, userPk, toRebaseChangeSetId } = data; const changeSet = this.changeSetsById[changeSetId]; if (changeSet) { if (changeSet.id !== this.headChangeSetId) { // never set HEAD to Applied changeSet.status = ChangeSetStatus.Applied; heimdall.prune(workspacePk, changeSet.id); } if (this.selectedChangeSet?.id === changeSetId) { this.postApplyActor = userPk; } this.changeSetsById[changeSetId] = changeSet; // whenever the change set is applied move us to head } // TODO: jobelenus, I'm worried the WsEvent fires before commit happens /* if (this.selectedChangeSetId && !this.headSelected) this.FETCH_STATUS_WITH_BASE(this.selectedChangeSetId); */ // did I get an update from head (and I am not head)? if ( this.selectedChangeSetId === toRebaseChangeSetId && this.selectedChangeSetId !== this.headChangeSetId ) { toast({ component: RebaseOnBase, }); } // `list_open_change_sets` gets called prior on voters // which means the change set is gone, so always move if ( !this.selectedChangeSetId || this.selectedChangeSetId === changeSetId ) { const route = useRouterStore().currentRoute; if (route?.name) { if ((route.name as string).startsWith("new-hotness")) { let name = route.name as string; // if you're on a single-item page in the UI while just bring them to explore // but only if you're not already on head! if ( ![ "new-hotness", "new-hotness-workspace-auto", "new-hotness-head", ].includes(route.name as string) && this.selectedChangeSetId !== this.headChangeSetId ) name = "new-hotness"; router.push({ name, params: { ...route.query, // this will keep map/grid, search, if its there changeSetId: this.headChangeSetId, }, }); } else { router.push({ name: route.name, params: { ...route.params, changeSetId: this.headChangeSetId, }, }); } if ( this.selectedChangeSet && this.selectedChangeSet.name !== "HEAD" ) // FIXME(nick): use a new design in the new UI, but keep the toast. toast({ component: MovedToHead, props: { icon: "tools", changeSetName: this.selectedChangeSet?.name, action: "merged", }, }); } } }, }, { eventType: "ChangeSetWritten", callback: (changeSetId) => { this.changeSetsWrittenAtById[changeSetId] = new Date(); }, }, { eventType: "ChangeSetApprovalStatusChanged", callback: (changeSetId) => { if (this.selectedChangeSet?.id === changeSetId) { this.FETCH_APPROVAL_STATUS(changeSetId); } }, }, { eventType: "ChangeSetRename", callback: ({ changeSetId, newName }) => { const changeSet = this.changeSetsById[changeSetId]; if (changeSet) { changeSet.name = newName; } }, }, ]); const actionUnsub = this.$onAction(handleStoreError); return () => { actionUnsub(); stopWatchSelectedChangeSet(); realtimeStore.unsubscribe(this.$id); }; }, }), )(); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server