diff --git a/app/client/src/IDE/Components/EditableName/EditableName.test.tsx b/app/client/src/IDE/Components/EditableName/EditableName.test.tsx index 6dfadcebe2f..eec5e6a26c1 100644 --- a/app/client/src/IDE/Components/EditableName/EditableName.test.tsx +++ b/app/client/src/IDE/Components/EditableName/EditableName.test.tsx @@ -151,7 +151,7 @@ describe("EditableName", () => { await userEvent.click(document.body); - expect(getByRole("tooltip").textContent).toEqual(validationError); + expect(getByRole("tooltip").textContent).toEqual(""); expect(exitEditing).toHaveBeenCalled(); expect(onNameSave).not.toHaveBeenCalledWith(invalidTitle); @@ -187,7 +187,7 @@ describe("EditableName", () => { }); fireEvent.focusOut(inputElement); - expect(getByRole("tooltip").textContent).toEqual(validationError); + expect(getByRole("tooltip").textContent).toEqual(""); expect(exitEditing).toHaveBeenCalled(); expect(onNameSave).not.toHaveBeenCalledWith(invalidTitle); }); diff --git a/app/client/src/IDE/Components/EditableName/EditableName.tsx b/app/client/src/IDE/Components/EditableName/EditableName.tsx index 1f91d827b80..0bc2829728c 100644 --- a/app/client/src/IDE/Components/EditableName/EditableName.tsx +++ b/app/client/src/IDE/Components/EditableName/EditableName.tsx @@ -5,10 +5,11 @@ import React, { useRef, useState, } from "react"; -import { Spinner, Text, Tooltip } from "@appsmith/ads"; +import { Spinner, Text as ADSText, Tooltip } from "@appsmith/ads"; import { useEventCallback, useEventListener } from "usehooks-ts"; import { usePrevious } from "@mantine/hooks"; import { useNameEditor } from "./useNameEditor"; +import styled from "styled-components"; interface EditableTextProps { name: string; @@ -29,6 +30,10 @@ interface EditableTextProps { inputTestId?: string; } +export const Text = styled(ADSText)` + min-width: 3ch; +`; + export const EditableName = ({ exitEditing, icon, @@ -72,10 +77,15 @@ export const EditableName = ({ const nameError = validate(editableName); if (editableName === name) { + // No change detected exitWithoutSaving(); } else if (nameError === null) { + // Save the new name exitEditing(); onNameSave(editableName); + } else { + // Exit edit mode and revert name + exitWithoutSaving(); } }, [ editableName, @@ -119,7 +129,9 @@ export const EditableName = ({ useEventListener( "focusout", function handleFocusOut() { - if (isEditing) { + const input = inputRef.current; + + if (input) { attemptSave(); } }, diff --git a/app/client/src/PluginActionEditor/components/PluginActionForm/components/ActionForm/Zone/styles.module.css b/app/client/src/PluginActionEditor/components/PluginActionForm/components/ActionForm/Zone/styles.module.css index 0af35a6d69c..9f616da7c93 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionForm/components/ActionForm/Zone/styles.module.css +++ b/app/client/src/PluginActionEditor/components/PluginActionForm/components/ActionForm/Zone/styles.module.css @@ -67,5 +67,10 @@ & :global(.ads-v2-select > .rc-select-selector) { min-width: unset; } + + /* Remove this once the config in DB is updated to use Section and Zone (Twilio, Airtable) */ + & :global(.ar-form-info-text) { + max-width: unset; + } /* Removable section ends here */ } diff --git a/app/client/src/PluginActionEditor/components/PluginActionForm/components/UQIEditor/UQIEditorForm.tsx b/app/client/src/PluginActionEditor/components/PluginActionForm/components/UQIEditor/UQIEditorForm.tsx index 3b284f55d29..69b7a4d0ed2 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionForm/components/UQIEditor/UQIEditorForm.tsx +++ b/app/client/src/PluginActionEditor/components/PluginActionForm/components/UQIEditor/UQIEditorForm.tsx @@ -20,6 +20,7 @@ const UQIEditorForm = () => { return ( ` ? "5px" : "0px"}; line-height: 16px; + max-width: 270px; + overflow: hidden; + break-word: break-all; `; const FormSubtitleText = styled.span<{ config?: ControlProps }>` @@ -177,7 +180,9 @@ function FormLabel(props: FormLabelProps) { //Wrapper on styled function FormInfoText(props: FormLabelProps) { return ( - {props.children} + + {props.children} + ); } diff --git a/app/client/src/git/actions/helpers/singleArtifactInitialState.ts b/app/client/src/git/actions/helpers/singleArtifactInitialState.ts index 0c8e0cb282f..26735b772c4 100644 --- a/app/client/src/git/actions/helpers/singleArtifactInitialState.ts +++ b/app/client/src/git/actions/helpers/singleArtifactInitialState.ts @@ -3,7 +3,7 @@ import { GitImportStep, GitOpsTab, GitSettingsTab, -} from "../../enums"; +} from "../../constants/enums"; import type { GitSingleArtifactAPIResponsesReduxState, GitSingleArtifactUIReduxState, diff --git a/app/client/src/git/components/DisconnectModal/index.test.tsx b/app/client/src/git/components/DisconnectModal/index.test.tsx new file mode 100644 index 00000000000..70f6fb14ea2 --- /dev/null +++ b/app/client/src/git/components/DisconnectModal/index.test.tsx @@ -0,0 +1,165 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import DisconnectModal from "."; + +jest.mock("ee/utils/AnalyticsUtil", () => ({ + logEvent: jest.fn(), +})); + +describe("DisconnectModal", () => { + const defaultProps = { + isModalOpen: true, + disconnectingApp: { + id: "app123", + name: "TestApp", + }, + closeModal: jest.fn(), + onBackClick: jest.fn(), + onDisconnect: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render the modal when isModalOpen is true", () => { + render(); + expect(screen.getByTestId("t--disconnect-git-modal")).toBeInTheDocument(); + }); + + it("should not render the modal when isModalOpen is false", () => { + render(); + expect( + screen.queryByTestId("t--disconnect-git-modal"), + ).not.toBeInTheDocument(); + }); + + it("should display the correct modal header", () => { + render(); + expect(screen.getByText("Revoke access to TestApp")).toBeInTheDocument(); + }); + + it("should display the correct instruction text", () => { + render(); + expect( + screen.getByText("Type “TestApp” in the input box to revoke access."), + ).toBeInTheDocument(); + }); + + it("should update appName state when input changes", () => { + render(); + const input = screen.getByLabelText("Application name"); + + fireEvent.change(input, { target: { value: "TestApp" } }); + expect(input).toHaveValue("TestApp"); + }); + + it("should enable Revoke button when appName matches disconnectingApp.name", () => { + render(); + const input = screen.getByLabelText("Application name"); + const revokeButton = document.getElementsByClassName( + "t--git-revoke-button", + )[0]; + + expect(revokeButton).toBeDisabled(); + + fireEvent.change(input, { target: { value: "TestApp" } }); + expect(revokeButton).toBeEnabled(); + }); + + it("should disable Revoke button when appName does not match disconnectingApp.name", () => { + render(); + const input = screen.getByLabelText("Application name"); + const revokeButton = document.getElementsByClassName( + "t--git-revoke-button", + )[0]; + + fireEvent.change(input, { target: { value: "WrongAppName" } }); + expect(revokeButton).toBeDisabled(); + }); + + it("should call onBackClick when Go Back button is clicked", () => { + render(); + const goBackButton = document.getElementsByClassName( + "t--git-revoke-back-button", + )[0]; + + fireEvent.click(goBackButton); + expect(defaultProps.onBackClick).toHaveBeenCalledTimes(1); + }); + + it("should call onDisconnect when Revoke button is clicked", () => { + render(); + const input = screen.getByLabelText("Application name"); + const revokeButton = document.getElementsByClassName( + "t--git-revoke-button", + )[0]; + + fireEvent.change(input, { target: { value: "TestApp" } }); + fireEvent.click(revokeButton); + + expect(defaultProps.onDisconnect).toHaveBeenCalledTimes(1); + }); + + it("should disable Revoke button when isRevoking is true", () => { + const { rerender } = render(); + const input = screen.getByLabelText("Application name"); + const revokeButton = document.getElementsByClassName( + "t--git-revoke-button", + )[0]; + + fireEvent.change(input, { target: { value: "TestApp" } }); + expect(revokeButton).toBeEnabled(); + + fireEvent.click(revokeButton); + // Rerender to reflect state change + rerender(); + + expect(defaultProps.onDisconnect).toHaveBeenCalledTimes(1); + expect(revokeButton).toBeDisabled(); + }); + + it("should log analytics event on input blur", () => { + render(); + const input = screen.getByLabelText("Application name"); + + fireEvent.change(input, { target: { value: "SomeValue" } }); + fireEvent.blur(input); + + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith( + "GS_MATCHING_REPO_NAME_ON_GIT_DISCONNECT_MODAL", + { + value: "SomeValue", + expecting: "TestApp", + }, + ); + }); + + it("should display callout with non-reversible message and learn more link", () => { + render(); + expect( + screen.getByText( + "This action is non-reversible. Please proceed with caution.", + ), + ).toBeInTheDocument(); + const learnMoreLink = screen.getByText("Learn more").parentElement; + + expect(learnMoreLink).toBeInTheDocument(); + expect(learnMoreLink).toHaveAttribute( + "href", + "https://docs.appsmith.com/advanced-concepts/version-control-with-git/disconnect-the-git-repository", + ); + }); + + it("should not call onDisconnect when Revoke button is clicked and appName does not match", () => { + render(); + const revokeButton = document.getElementsByClassName( + "t--git-revoke-button", + )[0]; + + fireEvent.click(revokeButton); + expect(defaultProps.onDisconnect).not.toHaveBeenCalled(); + }); +}); diff --git a/app/client/src/git/components/DisconnectModal/index.tsx b/app/client/src/git/components/DisconnectModal/index.tsx new file mode 100644 index 00000000000..b18750a3842 --- /dev/null +++ b/app/client/src/git/components/DisconnectModal/index.tsx @@ -0,0 +1,146 @@ +import React, { useCallback, useState } from "react"; +import { + Button, + Callout, + Flex, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Text, +} from "@appsmith/ads"; +import { + APPLICATION_NAME, + createMessage, + GIT_REVOKE_ACCESS, + GIT_TYPE_REPO_NAME_FOR_REVOKING_ACCESS, + GO_BACK, + NONE_REVERSIBLE_MESSAGE, + REVOKE, +} from "ee/constants/messages"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import styled from "styled-components"; + +const DOCS_URL = + "https://docs.appsmith.com/advanced-concepts/version-control-with-git/disconnect-the-git-repository"; +const DOCS_LINK_PROPS = [ + { + children: "Learn more", + to: DOCS_URL, + className: "t--disconnect-learn-more", + }, +]; +const MODAL_WIDTH = 640; + +interface DisconnectModalProps { + isModalOpen: boolean; + disconnectingApp: { + id: string; + name: string; + }; + closeModal: () => void; + onBackClick: () => void; + onDisconnect: () => void; +} + +const StyledModalContent = styled(ModalContent)` + width: ${MODAL_WIDTH}px; +`; + +function DisconnectModal({ + closeModal, + disconnectingApp, + isModalOpen, + onBackClick, + onDisconnect, +}: DisconnectModalProps) { + const [appName, setAppName] = useState(""); + const [isRevoking, setIsRevoking] = useState(false); + + const onDisconnectGit = useCallback(() => { + setIsRevoking(true); + onDisconnect(); + }, [onDisconnect]); + + const shouldDisableRevokeButton = + disconnectingApp.id === "" || + appName !== disconnectingApp.name || + isRevoking; + + const onModalOpenValueChange = useCallback( + (open: boolean) => { + if (!open) { + closeModal(); + } + }, + [closeModal], + ); + + const inputOnBlur = useCallback( + (event: React.FocusEvent) => { + AnalyticsUtil.logEvent("GS_MATCHING_REPO_NAME_ON_GIT_DISCONNECT_MODAL", { + value: "value" in event.target ? event.target.value : "", + expecting: disconnectingApp.name, + }); + }, + [disconnectingApp.name], + ); + + const inputOnChange = useCallback((value: string) => { + setAppName(value); + }, []); + + return ( + + + + {createMessage(GIT_REVOKE_ACCESS, disconnectingApp.name)} + + + + + {createMessage( + GIT_TYPE_REPO_NAME_FOR_REVOKING_ACCESS, + disconnectingApp.name, + )} + + + + {createMessage(NONE_REVERSIBLE_MESSAGE)} + + + + + + + + + + ); +} + +export default DisconnectModal; diff --git a/app/client/src/git/components/QuickActions/AutocommitStatusbar.test.tsx b/app/client/src/git/components/QuickActions/AutocommitStatusbar.test.tsx new file mode 100644 index 00000000000..69f0d44f263 --- /dev/null +++ b/app/client/src/git/components/QuickActions/AutocommitStatusbar.test.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import AutocommitStatusbar from "./AutocommitStatusbar"; +import "@testing-library/jest-dom"; + +// Mock timers using Jest +jest.useFakeTimers(); + +// Mock the Statusbar component from '@appsmith/ads-old' +jest.mock("@appsmith/ads-old", () => ({ + Statusbar: ({ percentage }: { percentage: number }) => ( +
{percentage}%
+ ), +})); + +const TOTAL_DURATION_MS = 4000; +const STEPS = 9; +const INTERVAL_MS = TOTAL_DURATION_MS / STEPS; + +describe("AutocommitStatusbar Component", () => { + afterEach(() => { + jest.clearAllTimers(); + }); + + it("should render with initial percentage 0 when completed is false", () => { + render(); + const statusbar = screen.getByTestId("statusbar"); + + expect(statusbar).toBeInTheDocument(); + expect(statusbar).toHaveTextContent("0%"); + }); + + it("should increment percentage over time when completed is false", () => { + render(); + const statusbar = screen.getByTestId("statusbar"); + + // Initial percentage + expect(statusbar).toHaveTextContent("0%"); + + // Advance timer by one interval + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(statusbar).toHaveTextContent("10%"); + + // Advance timer by another interval + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(statusbar).toHaveTextContent("20%"); + + // Continue until percentage reaches 90% + act(() => { + jest.advanceTimersByTime((4 * 1000 * 7) / 9); + }); + expect(statusbar).toHaveTextContent("90%"); + }); + + it("should not increment percentage beyond 90 when completed is false", () => { + render(); + const statusbar = screen.getByTestId("statusbar"); + + // Advance time beyond the total interval duration + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(statusbar).toHaveTextContent("90%"); + + // Advance time further to ensure percentage doesn't exceed 90% + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(statusbar).toHaveTextContent("90%"); + }); + + it("should set percentage to 100 when completed is true", () => { + render(); + const statusbar = screen.getByTestId("statusbar"); + + expect(statusbar).toHaveTextContent("100%"); + }); + + it("should call onHide after 1 second when completed is true", () => { + const onHide = jest.fn(); + + render(); + expect(onHide).not.toHaveBeenCalled(); + + // Advance timer by 1 second + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(onHide).toHaveBeenCalledTimes(1); + }); + + it("should clean up intervals and timeouts on unmount", () => { + const onHide = jest.fn(); + + const { unmount } = render( + , + ); + + // Start the interval + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + + // Unmount the component + unmount(); + + // Advance time to see if any timers are still running + act(() => { + jest.advanceTimersByTime(10000); + }); + expect(onHide).not.toHaveBeenCalled(); + }); + + it("should handle transition from false to true for completed prop", () => { + const onHide = jest.fn(); + const { rerender } = render( + , + ); + const statusbar = screen.getByTestId("statusbar"); + + // Advance timer to increase percentage + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(statusbar).toHaveTextContent("10%"); + + // Update the completed prop to true + rerender(); + expect(statusbar).toHaveTextContent("100%"); + + // Ensure onHide is called after 1 second + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(onHide).toHaveBeenCalledTimes(1); + }); + + it("should not reset percentage when completed changes from true to false", () => { + const { rerender } = render(); + const statusbar = screen.getByTestId("statusbar"); + + expect(statusbar).toHaveTextContent("100%"); + + // Change completed to false + rerender(); + expect(statusbar).toHaveTextContent("100%"); + + // Advance timer to check if percentage increments beyond 100% + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(statusbar).toHaveTextContent("100%"); + }); +}); diff --git a/app/client/src/git/components/QuickActions/AutocommitStatusbar.tsx b/app/client/src/git/components/QuickActions/AutocommitStatusbar.tsx new file mode 100644 index 00000000000..88a3eb46073 --- /dev/null +++ b/app/client/src/git/components/QuickActions/AutocommitStatusbar.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Statusbar } from "@appsmith/ads-old"; +import styled from "styled-components"; +import { + AUTOCOMMIT_IN_PROGRESS_MESSAGE, + createMessage, +} from "ee/constants/messages"; + +interface AutocommitStatusbarProps { + completed: boolean; + onHide?: () => void; +} + +const PROGRESSBAR_WIDTH = 150; +const TOTAL_DURATION_MS = 4000; // in ms +const MAX_PROGRESS_PERCENTAGE = 90; +const PROGRESS_INCREMENT = 10; +const STEPS = 9; +const INTERVAL_MS = TOTAL_DURATION_MS / STEPS; + +const StatusbarWrapper = styled.div` + > div { + display: flex; + height: initial; + align-items: center; + } + + > div > div { + margin-top: 0px; + width: ${PROGRESSBAR_WIDTH}px; + margin-right: var(--ads-v2-spaces-4); + } + + > div > p { + margin-top: 0; + } +`; + +export default function AutocommitStatusbar({ + completed, + onHide, +}: AutocommitStatusbarProps) { + const intervalRef = useRef(null); + const timeoutRef = useRef(null); + const [percentage, setPercentage] = useState(0); + + // Effect for incrementing percentage when not completed + useEffect( + function incrementPercentage() { + if (!completed) { + intervalRef.current = setInterval(() => { + setPercentage((prevPercentage) => { + if (prevPercentage < MAX_PROGRESS_PERCENTAGE) { + return prevPercentage + PROGRESS_INCREMENT; + } else { + // Clear the interval when percentage reaches 90% + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + return prevPercentage; + } + }); + }, INTERVAL_MS); + } + + // Cleanup function to clear the interval + return () => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, + [completed], + ); // Removed 'percentage' from dependencies + + // Effect for setting percentage to 100% when completed + useEffect( + function finishPercentage() { + if (completed) { + setPercentage(100); + } + }, + [completed], + ); + + // Effect for calling onHide after 1 second when completed + useEffect( + function onCompleteCallback() { + if (completed && onHide) { + timeoutRef.current = setTimeout(() => { + onHide(); + }, 1000); + } + + return () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, + [completed, onHide], + ); + + return ( + + + + ); +} diff --git a/app/client/src/git/components/QuickActions/ConnectButton.test.tsx b/app/client/src/git/components/QuickActions/ConnectButton.test.tsx new file mode 100644 index 00000000000..3b017c0c018 --- /dev/null +++ b/app/client/src/git/components/QuickActions/ConnectButton.test.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import ConnectButton from "./ConnectButton"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import { GitSyncModalTab } from "entities/GitSync"; +import "@testing-library/jest-dom"; +import { theme } from "constants/DefaultTheme"; +import { ThemeProvider } from "styled-components"; + +// Mock the AnalyticsUtil +jest.mock("ee/utils/AnalyticsUtil", () => ({ + logEvent: jest.fn(), +})); + +// Mock the components from '@appsmith/ads' +jest.mock("@appsmith/ads", () => ({ + ...jest.requireActual("@appsmith/ads"), + Icon: ({ name }: Record) => ( +
{name}
+ ), + Tooltip: ({ children, content, isDisabled }: Record) => ( +
+ {children} + {!isDisabled &&
{content}
} +
+ ), +})); + +describe("ConnectButton Component", () => { + const openGitSyncModalMock = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render correctly when isConnectPermitted is true", () => { + render( + + + , + ); + + // Check that the button is rendered and enabled + const button = screen.getByRole("button"); + + expect(button).toBeInTheDocument(); + expect(button).toBeEnabled(); + + // Tooltip should be disabled + const tooltipContent = screen.queryByTestId("tooltip-content"); + + expect(tooltipContent).not.toBeInTheDocument(); + + // Icon should be rendered + const icon = screen.getByTestId("icon"); + + expect(icon).toBeInTheDocument(); + expect(icon).toHaveTextContent("git-commit"); + }); + + it("should handle click when isConnectPermitted is true", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Connect Git (Beta)" }); + + fireEvent.click(button); + + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith( + "GS_CONNECT_GIT_CLICK", + { + source: "BOTTOM_BAR_GIT_CONNECT_BUTTON", + }, + ); + + expect(openGitSyncModalMock).toHaveBeenCalledWith({ + tab: GitSyncModalTab.GIT_CONNECTION, + }); + }); + + it("should render correctly when isConnectPermitted is false", () => { + render( + + + , + ); + + // Check that the button is rendered and disabled + const button = screen.getByRole("button", { name: "Connect Git (Beta)" }); + + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + + // Tooltip should be enabled and display correct content + const tooltipContent = screen.getByTestId("tooltip-content"); + + expect(tooltipContent).toBeInTheDocument(); + expect(tooltipContent).toHaveTextContent( + "Please contact your workspace admin to connect your app to a git repo", + ); + + // Icon should be rendered + const icon = screen.getByTestId("icon"); + + expect(icon).toBeInTheDocument(); + expect(icon).toHaveTextContent("git-commit"); + }); + + it("should not handle click when isConnectPermitted is false", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Connect Git (Beta)" }); + + fireEvent.click(button); + + expect(AnalyticsUtil.logEvent).not.toHaveBeenCalled(); + expect(openGitSyncModalMock).not.toHaveBeenCalled(); + }); + + it("should display correct tooltip content when isConnectPermitted is true", () => { + render( + + + , + ); + + // Tooltip should be disabled, so content should not be visible + const tooltipContent = screen.queryByTestId("tooltip-content"); + + expect(tooltipContent).not.toBeInTheDocument(); + }); +}); diff --git a/app/client/src/git/components/QuickActions/ConnectButton.tsx b/app/client/src/git/components/QuickActions/ConnectButton.tsx new file mode 100644 index 00000000000..426b2a05ec8 --- /dev/null +++ b/app/client/src/git/components/QuickActions/ConnectButton.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useMemo } from "react"; +import { GitSyncModalTab } from "entities/GitSync"; +import styled from "styled-components"; +import { + COMING_SOON, + CONNECT_GIT_BETA, + CONTACT_ADMIN_FOR_GIT, + createMessage, + NOT_LIVE_FOR_YOU_YET, +} from "ee/constants/messages"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import { Button, Icon, Tooltip } from "@appsmith/ads"; + +interface ConnectButtonProps { + isConnectPermitted: boolean; + openGitSyncModal: (options: { tab: GitSyncModalTab }) => void; +} + +const CenterDiv = styled.div` + text-align: center; +`; + +const Container = styled.div` + height: 100%; + display: flex; + align-items: center; + margin-left: 0; + cursor: pointer; +`; + +const StyledIcon = styled(Icon)` + cursor: default; + margin-right: ${(props) => props.theme.spaces[3]}px; +`; + +const OuterContainer = styled.div` + padding: 4px 16px; + height: 100%; +`; + +function ConnectButton({ + isConnectPermitted, + openGitSyncModal, +}: ConnectButtonProps) { + const isTooltipEnabled = !isConnectPermitted; + const tooltipContent = useMemo(() => { + if (!isConnectPermitted) { + return {createMessage(CONTACT_ADMIN_FOR_GIT)}; + } + + return ( + <> +
{createMessage(NOT_LIVE_FOR_YOU_YET)}
+
{createMessage(COMING_SOON)}
+ + ); + }, [isConnectPermitted]); + + const handleClickOnGitConnect = useCallback(() => { + AnalyticsUtil.logEvent("GS_CONNECT_GIT_CLICK", { + source: "BOTTOM_BAR_GIT_CONNECT_BUTTON", + }); + + openGitSyncModal({ + tab: GitSyncModalTab.GIT_CONNECTION, + }); + }, [openGitSyncModal]); + + return ( + + + + + + + + + ); +} + +export default ConnectButton; diff --git a/app/client/src/git/components/QuickActions/QuickActionButton.test.tsx b/app/client/src/git/components/QuickActions/QuickActionButton.test.tsx new file mode 100644 index 00000000000..6bd94a586f5 --- /dev/null +++ b/app/client/src/git/components/QuickActions/QuickActionButton.test.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import QuickActionButton from "./QuickActionButton"; +import "@testing-library/jest-dom"; +import { theme } from "constants/DefaultTheme"; +import { ThemeProvider } from "styled-components"; + +jest.mock("pages/common/SpinnerLoader", () => { + return function SpinnerLoader() { + return
Loading...
; + }; +}); + +jest.mock("@appsmith/ads", () => ({ + ...jest.requireActual("@appsmith/ads"), + Tooltip: ({ children, content }: Record) => ( +
+
{content}
+ {children} +
+ ), +})); + +describe("QuickActionButton", () => { + const defaultProps = { + icon: "plus", + onClick: jest.fn(), + tooltipText: "default action", + className: "t--test-btn", + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render without crashing", () => { + const { container } = render( + + + , + ); + const btn = container.getElementsByClassName("t--test-btn")[0]; + + expect(btn).toBeInTheDocument(); + }); + + it("should call onClick when button is clicked", () => { + const { container } = render( + + + , + ); + const btn = container.getElementsByClassName("t--test-btn")[0]; + + fireEvent.click(btn); + expect(defaultProps.onClick).toHaveBeenCalledTimes(1); + }); + + it("should not call onClick when button is disabled", () => { + const { container } = render( + + + , + ); + const btn = container.getElementsByClassName("t--test-btn")[0]; + + fireEvent.click(btn); + expect(defaultProps.onClick).not.toHaveBeenCalled(); + }); + + it("should display the tooltip with capitalized text", () => { + render( + + + , + ); + expect(screen.getByTestId("tooltip-content")).toHaveTextContent( + "Default action", + ); + }); + + it("should display the spinner when loading is true", () => { + render( + + + , + ); + expect(screen.getByTestId("spinner-loader")).toBeInTheDocument(); + expect(screen.queryByTestId("t--test-btn")).not.toBeInTheDocument(); + }); + + it("should display the count badge when count is greater than 0", () => { + render( + + + , + ); + expect(screen.getByText("5")).toBeInTheDocument(); + }); + + it("should not display the count badge when count is 0", () => { + render( + + + , + ); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + }); +}); diff --git a/app/client/src/git/components/QuickActions/QuickActionButton.tsx b/app/client/src/git/components/QuickActions/QuickActionButton.tsx new file mode 100644 index 00000000000..42d7721ab29 --- /dev/null +++ b/app/client/src/git/components/QuickActions/QuickActionButton.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import styled from "styled-components"; +import { capitalizeFirstLetter } from "./helpers"; +import SpinnerLoader from "pages/common/SpinnerLoader"; +import { Button, Tooltip, Text } from "@appsmith/ads"; +import { getTypographyByKey } from "@appsmith/ads-old"; + +interface QuickActionButtonProps { + className?: string; + count?: number; + disabled?: boolean; + icon: string; + loading?: boolean; + onClick: () => void; + tooltipText: string; +} + +const SpinnerContainer = styled.div` + padding: 0 10px; +`; + +const QuickActionButtonContainer = styled.button<{ disabled?: boolean }>` + margin: 0 ${(props) => props.theme.spaces[1]}px; + display: block; + position: relative; + overflow: visible; + cursor: ${({ disabled = false }) => (disabled ? "not-allowed" : "pointer")}; + opacity: ${({ disabled = false }) => (disabled ? 0.6 : 1)}; +`; + +const StyledCountText = styled(Text)` + align-items: center; + background-color: var(--ads-v2-color-bg-brand-secondary-emphasis-plus); + color: var(--ads-v2-color-white); + display: flex; + justify-content: center; + position: absolute; + height: var(--ads-v2-spaces-5); + top: ${(props) => -1 * props.theme.spaces[3]}px; + left: ${(props) => props.theme.spaces[10]}px; + border-radius: ${(props) => props.theme.spaces[3]}px; + ${getTypographyByKey("p3")}; + z-index: 1; + padding: ${(props) => props.theme.spaces[1]}px + ${(props) => props.theme.spaces[2]}px; +`; + +function QuickActionButton({ + className = "", + count = 0, + disabled = false, + icon, + loading = false, + onClick, + tooltipText, +}: QuickActionButtonProps) { + const content = capitalizeFirstLetter(tooltipText); + + return ( + + {loading ? ( + + + + ) : ( + +
+
+
+ )} +
+ ); +} + +export default QuickActionButton; diff --git a/app/client/src/git/components/QuickActions/helper.test.ts b/app/client/src/git/components/QuickActions/helper.test.ts new file mode 100644 index 00000000000..a7b3a6d3020 --- /dev/null +++ b/app/client/src/git/components/QuickActions/helper.test.ts @@ -0,0 +1,191 @@ +import { getPullBtnStatus, capitalizeFirstLetter } from "./helpers"; + +describe("getPullBtnStatus", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return disabled with message "No commits to pull" when behindCount is 0', () => { + const gitStatus: Record = { + behindCount: 0, + isClean: true, + }; + const pullFailed = false; + const isProtected = false; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: true, + message: + "No commits to pull. This branch is in sync with the remote repository", + }); + }); + + it('should return disabled with message "Cannot pull with local uncommitted changes" when isClean is false and isProtected is false', () => { + const gitStatus: Record = { + behindCount: 5, + isClean: false, + }; + const pullFailed = false; + const isProtected = false; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: true, + message: + "You have uncommitted changes. Please commit or discard before pulling the remote changes.", + }); + }); + + it('should return enabled with message "Pull changes" when isClean is false, isProtected is true, and behindCount > 0', () => { + const gitStatus: Record = { + behindCount: 3, + isClean: false, + }; + const pullFailed = false; + const isProtected = true; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: false, + message: "Pull changes", + }); + }); + + it('should return message "Conflicts found" when pullFailed is true', () => { + const gitStatus: Record = { + behindCount: 2, + isClean: true, + }; + const pullFailed = true; + const isProtected = false; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: false, + message: "Conflicts found. Please resolve them and pull again.", + }); + }); + + it('should return enabled with message "Pull changes" when behindCount > 0 and no other conditions met', () => { + const gitStatus: Record = { + behindCount: 1, + isClean: true, + }; + const pullFailed = false; + const isProtected = false; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: false, + message: "Pull changes", + }); + }); + + it('should return disabled with message "No commits to pull" when behindCount is 0 regardless of isClean and isProtected', () => { + const gitStatus: Record = { + behindCount: 0, + isClean: false, + }; + const pullFailed = false; + const isProtected = true; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: true, + message: + "No commits to pull. This branch is in sync with the remote repository", + }); + }); + + it("should prioritize pullFailed over other conditions", () => { + const gitStatus: Record = { + behindCount: 0, + isClean: true, + }; + const pullFailed = true; + const isProtected = false; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: true, + message: "Conflicts found. Please resolve them and pull again.", + }); + }); + + it("should handle edge case when isClean is false, isProtected is true, behindCount is 0", () => { + const gitStatus: Record = { + behindCount: 0, + isClean: false, + }; + const pullFailed = false; + const isProtected = true; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: true, + message: + "No commits to pull. This branch is in sync with the remote repository", + }); + }); +}); + +describe("capitalizeFirstLetter", () => { + it("should capitalize the first letter of a lowercase word", () => { + const result = capitalizeFirstLetter("hello"); + + expect(result).toBe("Hello"); + }); + + it("should capitalize the first letter of an uppercase word", () => { + const result = capitalizeFirstLetter("WORLD"); + + expect(result).toBe("World"); + }); + + it("should handle empty string", () => { + const result = capitalizeFirstLetter(""); + + expect(result).toBe(""); + }); + + it("should handle single character", () => { + const result = capitalizeFirstLetter("a"); + + expect(result).toBe("A"); + }); + + it("should handle strings with spaces", () => { + const result = capitalizeFirstLetter("multiple words here"); + + expect(result).toBe("Multiple words here"); + }); + + it("should handle undefined input", () => { + // The function provides a default value of " " when input is undefined + // So we expect the output to be a single space with capitalized first letter + const result = capitalizeFirstLetter(); + + expect(result).toBe(" "); + }); + + it("should handle strings with special characters", () => { + const result = capitalizeFirstLetter("123abc"); + + expect(result).toBe("123abc"); + }); + + it("should not modify strings where the first character is not a letter", () => { + const result = capitalizeFirstLetter("!test"); + + expect(result).toBe("!test"); + }); +}); diff --git a/app/client/src/git/components/QuickActions/helpers.ts b/app/client/src/git/components/QuickActions/helpers.ts new file mode 100644 index 00000000000..10919726489 --- /dev/null +++ b/app/client/src/git/components/QuickActions/helpers.ts @@ -0,0 +1,45 @@ +import { + CANNOT_PULL_WITH_LOCAL_UNCOMMITTED_CHANGES, + CONFLICTS_FOUND, + createMessage, + NO_COMMITS_TO_PULL, + PULL_CHANGES, +} from "ee/constants/messages"; +import type { GitStatus } from "../../types"; + +export const getPullBtnStatus = ( + gitStatus: GitStatus, + pullFailed: boolean, + isProtected: boolean, +) => { + const { behindCount, isClean } = gitStatus; + let message = createMessage(NO_COMMITS_TO_PULL); + let disabled = behindCount === 0; + + if (!isClean && !isProtected) { + disabled = true; + message = createMessage(CANNOT_PULL_WITH_LOCAL_UNCOMMITTED_CHANGES); + // TODO: Remove this when gitStatus typings are finalized + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + } else if (!isClean && isProtected && behindCount > 0) { + disabled = false; + message = createMessage(PULL_CHANGES); + } else if (pullFailed) { + message = createMessage(CONFLICTS_FOUND); + // TODO: Remove this when gitStatus typings are finalized + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + } else if (behindCount > 0) { + message = createMessage(PULL_CHANGES); + } + + return { + disabled, + message, + }; +}; + +export const capitalizeFirstLetter = (string = " ") => { + return string.charAt(0).toUpperCase() + string.toLowerCase().slice(1); +}; diff --git a/app/client/src/git/components/QuickActions/index.test.tsx b/app/client/src/git/components/QuickActions/index.test.tsx new file mode 100644 index 00000000000..189b3ea67be --- /dev/null +++ b/app/client/src/git/components/QuickActions/index.test.tsx @@ -0,0 +1,334 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import QuickActions from "."; +import { GitSettingsTab } from "../../constants/enums"; +import { GitSyncModalTab } from "entities/GitSync"; +import { theme } from "constants/DefaultTheme"; +import { ThemeProvider } from "styled-components"; +import "@testing-library/jest-dom/extend-expect"; + +jest.mock("ee/utils/AnalyticsUtil", () => ({ + logEvent: jest.fn(), +})); + +jest.mock("./ConnectButton", () => () => ( +
ConnectButton
+)); + +jest.mock("./AutocommitStatusbar", () => () => ( +
AutocommitStatusbar
+)); + +describe("QuickActions Component", () => { + const defaultProps = { + isGitConnected: false, + gitStatus: { + behindCount: 0, + isClean: true, + }, + pullFailed: false, + isProtectedMode: false, + isDiscardInProgress: false, + isPollingAutocommit: false, + isPullInProgress: false, + isFetchingGitStatus: false, + changesToCommit: 0, + gitMetadata: {}, + isAutocommitEnabled: false, + isConnectPermitted: true, + openGitSyncModal: jest.fn(), + openGitSettingsModal: jest.fn(), + discardChanges: jest.fn(), + pull: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render ConnectButton when isGitConnected is false", () => { + render( + + + , + ); + expect(screen.getByTestId("connect-button")).toBeInTheDocument(); + }); + + it("should render QuickActionButtons when isGitConnected is true", () => { + const props = { + ...defaultProps, + isGitConnected: true, + }; + + const { container } = render( + + + , + ); + + expect( + container.getElementsByClassName("t--bottom-bar-commit").length, + ).toBe(1); + expect(container.getElementsByClassName("t--bottom-bar-pull").length).toBe( + 1, + ); + expect(container.getElementsByClassName("t--bottom-bar-merge").length).toBe( + 1, + ); + expect( + container.getElementsByClassName("t--bottom-git-settings").length, + ).toBe(1); + }); + + it("should render AutocommitStatusbar when isAutocommitEnabled and isPollingAutocommit are true", () => { + const props = { + ...defaultProps, + isGitConnected: true, + gitMetadata: { + autoCommitConfig: { + enabled: true, + }, + }, + isPollingAutocommit: true, + }; + + const { container } = render( + + + , + ); + + expect(screen.getByTestId("autocommit-statusbar")).toBeInTheDocument(); + expect( + container.getElementsByClassName("t--bottom-bar-commit").length, + ).toBe(0); + }); + + it("should call onCommitClick when commit button is clicked", () => { + const props = { + ...defaultProps, + isGitConnected: true, + }; + + const { container } = render( + + + , + ); + const commitButton = container.getElementsByClassName( + "t--bottom-bar-commit", + )[0]; + + fireEvent.click(commitButton); + expect(props.openGitSyncModal).toHaveBeenCalledWith({ + tab: GitSyncModalTab.DEPLOY, + }); + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith( + "GS_DEPLOY_GIT_MODAL_TRIGGERED", + { + source: "BOTTOM_BAR_GIT_COMMIT_BUTTON", + }, + ); + }); + + it("should call onPullClick when pull button is clicked", () => { + const props = { + ...defaultProps, + isGitConnected: true, + isDiscardInProgress: false, + isPullInProgress: false, + isFetchingGitStatus: false, + pullDisabled: false, + gitStatus: { + behindCount: 1, + isClean: false, + }, + isProtectedMode: true, + }; + + const { container } = render( + + + , + ); + const pullButton = + container.getElementsByClassName("t--bottom-bar-pull")[0]; + + fireEvent.click(pullButton); + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith("GS_PULL_GIT_CLICK", { + source: "BOTTOM_BAR_GIT_PULL_BUTTON", + }); + }); + + it("should call onMerge when merge button is clicked", () => { + const props = { + ...defaultProps, + isGitConnected: true, + }; + + const { container } = render( + + + , + ); + const mergeButton = container.getElementsByClassName( + "t--bottom-bar-merge", + )[0]; + + fireEvent.click(mergeButton); + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith( + "GS_MERGE_GIT_MODAL_TRIGGERED", + { + source: "BOTTOM_BAR_GIT_MERGE_BUTTON", + }, + ); + expect(props.openGitSyncModal).toHaveBeenCalledWith({ + tab: GitSyncModalTab.MERGE, + isDeploying: true, + }); + }); + + it("should call onSettingsClick when settings button is clicked", () => { + const props = { + ...defaultProps, + isGitConnected: true, + }; + + const { container } = render( + + + , + ); + const settingsButton = container.getElementsByClassName( + "t--bottom-git-settings", + )[0]; + + fireEvent.click(settingsButton); + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith("GS_SETTING_CLICK", { + source: "BOTTOM_BAR_GIT_SETTING_BUTTON", + }); + expect(props.openGitSettingsModal).toHaveBeenCalledWith({ + tab: GitSettingsTab.General, + }); + }); + + it("should disable commit button when isProtectedMode is true", () => { + const props = { + ...defaultProps, + isGitConnected: true, + isProtectedMode: true, + }; + + const { container } = render( + + + , + ); + const commitButton = container.getElementsByClassName( + "t--bottom-bar-commit", + )[0]; + + expect(commitButton).toBeDisabled(); + }); + + it("should show loading state on pull button when showPullLoadingState is true", () => { + const props = { + ...defaultProps, + isGitConnected: true, + isPullInProgress: true, + }; + + const { container } = render( + + + , + ); + + const pullButton = + container.getElementsByClassName("t--bottom-bar-pull")[0]; + const pullLoading = pullButton.getElementsByClassName( + "t--loader-quick-git-action", + )[0]; + + expect(pullLoading).toBeInTheDocument(); + }); + + it("should display changesToCommit count on commit button", () => { + const props = { + ...defaultProps, + isGitConnected: true, + changesToCommit: 5, + }; + + render( + + + , + ); + const countElement = screen.getByTestId("t--bottom-bar-count"); + + expect(countElement).toHaveTextContent("5"); + }); + + it("should not display count on commit button when isProtectedMode is true", () => { + const props = { + ...defaultProps, + isGitConnected: true, + isProtectedMode: true, + changesToCommit: 5, + }; + + render( + + + , + ); + expect(screen.queryByTestId("t--bottom-bar-count")).not.toBeInTheDocument(); + }); + + it("should disable pull button when pullDisabled is true", () => { + const mockGetPullBtnStatus = jest.requireMock("./helpers").getPullBtnStatus; + + mockGetPullBtnStatus.mockReturnValue({ + disabled: true, + message: "Pull Disabled", + }); + + const props = { + ...defaultProps, + isGitConnected: true, + }; + + const { container } = render( + + + , + ); + const pullButton = + container.getElementsByClassName("t--bottom-bar-pull")[0]; + + expect(pullButton).toBeDisabled(); + }); + + it("should show behindCount on pull button", () => { + const props = { + ...defaultProps, + isGitConnected: true, + gitStatus: { + behindCount: 3, + isClean: true, + }, + }; + + render( + + + , + ); + const countElement = screen.getByTestId("t--bottom-bar-count"); + + expect(countElement).toHaveTextContent("3"); + }); +}); diff --git a/app/client/src/git/components/QuickActions/index.tsx b/app/client/src/git/components/QuickActions/index.tsx new file mode 100644 index 00000000000..21483fb83ad --- /dev/null +++ b/app/client/src/git/components/QuickActions/index.tsx @@ -0,0 +1,183 @@ +import React, { useCallback } from "react"; +import styled from "styled-components"; + +import { + COMMIT_CHANGES, + createMessage, + GIT_SETTINGS, + MERGE, +} from "ee/constants/messages"; + +import { GitSyncModalTab } from "entities/GitSync"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import type { GitMetadata, GitStatus } from "../../types"; +import { getPullBtnStatus } from "./helpers"; +import { GitSettingsTab } from "../../constants/enums"; +import ConnectButton from "./ConnectButton"; +import QuickActionButton from "./QuickActionButton"; +import AutocommitStatusbar from "./AutocommitStatusbar"; + +interface QuickActionsProps { + isGitConnected: boolean; + gitStatus: GitStatus; + pullFailed: boolean; + isProtectedMode: boolean; + isDiscardInProgress: boolean; + isPollingAutocommit: boolean; + isPullInProgress: boolean; + isFetchingGitStatus: boolean; + changesToCommit: number; + gitMetadata: GitMetadata; + isAutocommitEnabled: boolean; + isConnectPermitted: boolean; + openGitSyncModal: (options: { + tab: GitSyncModalTab; + isDeploying?: boolean; + }) => void; + openGitSettingsModal: (options: { tab: GitSettingsTab }) => void; + discardChanges: () => void; + pull: (options: { triggeredFromBottomBar: boolean }) => void; +} + +const Container = styled.div` + height: 100%; + display: flex; + align-items: center; +`; + +function QuickActions({ + changesToCommit, + discardChanges, + gitMetadata, + gitStatus, + isConnectPermitted, + isDiscardInProgress, + isFetchingGitStatus, + isGitConnected, + isPollingAutocommit, + isProtectedMode, + isPullInProgress, + openGitSettingsModal, + openGitSyncModal, + pull, + pullFailed, +}: QuickActionsProps) { + const { disabled: pullDisabled, message: pullTooltipMessage } = + getPullBtnStatus(gitStatus, !!pullFailed, isProtectedMode); + + const showPullLoadingState = + isDiscardInProgress || isPullInProgress || isFetchingGitStatus; + + // TODO - Update once the gitMetadata typing is added + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const isAutocommitEnabled: boolean = gitMetadata?.autoCommitConfig?.enabled; + const onCommitClick = useCallback(() => { + if (!isFetchingGitStatus && !isProtectedMode) { + openGitSyncModal({ + tab: GitSyncModalTab.DEPLOY, + }); + + AnalyticsUtil.logEvent("GS_DEPLOY_GIT_MODAL_TRIGGERED", { + source: "BOTTOM_BAR_GIT_COMMIT_BUTTON", + }); + } + }, [isFetchingGitStatus, isProtectedMode, openGitSyncModal]); + + const onPullClick = useCallback(() => { + if (!showPullLoadingState && !pullDisabled) { + AnalyticsUtil.logEvent("GS_PULL_GIT_CLICK", { + source: "BOTTOM_BAR_GIT_PULL_BUTTON", + }); + + if (isProtectedMode) { + discardChanges(); + } else { + pull({ triggeredFromBottomBar: true }); + } + } + }, [ + discardChanges, + isProtectedMode, + pull, + pullDisabled, + showPullLoadingState, + ]); + + const onMerge = useCallback(() => { + AnalyticsUtil.logEvent("GS_MERGE_GIT_MODAL_TRIGGERED", { + source: "BOTTOM_BAR_GIT_MERGE_BUTTON", + }); + openGitSyncModal({ + tab: GitSyncModalTab.MERGE, + isDeploying: true, + }); + }, [openGitSyncModal]); + + const onSettingsClick = useCallback(() => { + openGitSettingsModal({ + tab: GitSettingsTab.General, + }); + AnalyticsUtil.logEvent("GS_SETTING_CLICK", { + source: "BOTTOM_BAR_GIT_SETTING_BUTTON", + }); + }, [openGitSettingsModal]); + + return isGitConnected ? ( + + {/* */} + {isAutocommitEnabled && isPollingAutocommit ? ( + + ) : ( + <> + + + + + + )} + + ) : ( + + ); +} + +export default QuickActions; diff --git a/app/client/src/git/enums.ts b/app/client/src/git/constants/enums.ts similarity index 69% rename from app/client/src/git/enums.ts rename to app/client/src/git/constants/enums.ts index fe72149e9fe..e431e6323ca 100644 --- a/app/client/src/git/enums.ts +++ b/app/client/src/git/constants/enums.ts @@ -25,3 +25,12 @@ export enum GitSettingsTab { General = "General", Branch = "Branch", } + +export enum AutocommitStatus { + IN_PROGRESS = "IN_PROGRESS", + LOCKED = "LOCKED", + PUBLISHED = "PUBLISHED", + IDLE = "IDLE", + NOT_REQUIRED = "NOT_REQUIRED", + NON_GIT_APP = "NON_GIT_APP", +} diff --git a/app/client/src/git/requests/checkoutBranchRequest.ts b/app/client/src/git/requests/checkoutBranchRequest.ts new file mode 100644 index 00000000000..4df5da6112d --- /dev/null +++ b/app/client/src/git/requests/checkoutBranchRequest.ts @@ -0,0 +1,17 @@ +import type { AxiosResponse } from "axios"; +import type { + CheckoutBranchRequestParams, + CheckoutBranchResponse, +} from "./checkoutBranchRequest.types"; +import { GIT_BASE_URL } from "./constants"; +import Api from "api/Api"; + +export default async function checkoutBranchRequest( + branchedApplicationId: string, + params: CheckoutBranchRequestParams, +): Promise> { + return Api.get( + `${GIT_BASE_URL}/checkout-branch/app/${branchedApplicationId}`, + params, + ); +} diff --git a/app/client/src/git/requests/checkoutBranchRequest.types.ts b/app/client/src/git/requests/checkoutBranchRequest.types.ts new file mode 100644 index 00000000000..8c465fc624e --- /dev/null +++ b/app/client/src/git/requests/checkoutBranchRequest.types.ts @@ -0,0 +1,8 @@ +export interface CheckoutBranchRequestParams { + branchName: string; +} + +export interface CheckoutBranchResponse { + id: string; // applicationId + baseId: string; // baseApplicationId +} diff --git a/app/client/src/git/requests/commitRequest.ts b/app/client/src/git/requests/commitRequest.ts new file mode 100644 index 00000000000..69541d03036 --- /dev/null +++ b/app/client/src/git/requests/commitRequest.ts @@ -0,0 +1,17 @@ +import Api from "api/Api"; +import type { + CommitRequestParams, + CommitResponse, +} from "./commitRequest.types"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; + +export default async function commitRequest( + branchedApplicationId: string, + params: CommitRequestParams, +): Promise> { + return Api.post( + `${GIT_BASE_URL}/commit/app/${branchedApplicationId}`, + params, + ); +} diff --git a/app/client/src/git/requests/commitRequest.types.ts b/app/client/src/git/requests/commitRequest.types.ts new file mode 100644 index 00000000000..b9c4bd7d787 --- /dev/null +++ b/app/client/src/git/requests/commitRequest.types.ts @@ -0,0 +1,6 @@ +export interface CommitRequestParams { + commitMessage: string; + doPush: boolean; +} + +export type CommitResponse = string; diff --git a/app/client/src/git/requests/connectRequest.ts b/app/client/src/git/requests/connectRequest.ts new file mode 100644 index 00000000000..b6257836102 --- /dev/null +++ b/app/client/src/git/requests/connectRequest.ts @@ -0,0 +1,14 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { + ConnectRequestParams, + ConnectResponse, +} from "./connectRequest.types"; +import type { AxiosResponse } from "axios"; + +export default async function connectRequest( + baseApplicationId: string, + params: ConnectRequestParams, +): Promise> { + return Api.post(`${GIT_BASE_URL}/connect/app/${baseApplicationId}`, params); +} diff --git a/app/client/src/git/requests/connectRequest.types.ts b/app/client/src/git/requests/connectRequest.types.ts new file mode 100644 index 00000000000..ef529d1bc1f --- /dev/null +++ b/app/client/src/git/requests/connectRequest.types.ts @@ -0,0 +1,24 @@ +export interface ConnectRequestParams { + remoteUrl: string; + gitProfile?: { + authorName: string; + authorEmail: string; + useDefaultProfile?: boolean; + }; +} + +export interface ConnectResponse { + id: string; + baseId: string; + gitApplicationMetadata: { + branchName: string; + browserSupportedRemoteUrl: string; + defaultApplicationId: string; + defaultArtifactId: string; + defaultBranchName: string; + isRepoPrivate: boolean; + lastCommitedAt: string; + remoteUrl: string; + repoName: string; + }; +} diff --git a/app/client/src/git/requests/constants.ts b/app/client/src/git/requests/constants.ts new file mode 100644 index 00000000000..e8626c4cc4a --- /dev/null +++ b/app/client/src/git/requests/constants.ts @@ -0,0 +1,2 @@ +export const GIT_BASE_URL = "/v1/git"; +export const APPLICATION_BASE_URL = "/v1/applications"; diff --git a/app/client/src/git/requests/createBranchRequest.ts b/app/client/src/git/requests/createBranchRequest.ts new file mode 100644 index 00000000000..a67b5ee0409 --- /dev/null +++ b/app/client/src/git/requests/createBranchRequest.ts @@ -0,0 +1,17 @@ +import type { AxiosResponse } from "axios"; +import type { + CreateBranchRequestParams, + CreateBranchResponse, +} from "./createBranchRequest.types"; +import { GIT_BASE_URL } from "./constants"; +import Api from "api/Api"; + +export default async function createBranchRequest( + branchedApplicationId: string, + params: CreateBranchRequestParams, +): Promise> { + return Api.post( + `${GIT_BASE_URL}/create-branch/app/${branchedApplicationId}`, + params, + ); +} diff --git a/app/client/src/git/requests/createBranchRequest.types.ts b/app/client/src/git/requests/createBranchRequest.types.ts new file mode 100644 index 00000000000..28735db7518 --- /dev/null +++ b/app/client/src/git/requests/createBranchRequest.types.ts @@ -0,0 +1,8 @@ +export interface CreateBranchRequestParams { + branchName: string; +} + +export interface CreateBranchResponse { + id: string; // applicationId + baseId: string; // baseApplicationId +} diff --git a/app/client/src/git/requests/deleteBranchRequest.ts b/app/client/src/git/requests/deleteBranchRequest.ts new file mode 100644 index 00000000000..63f718506d4 --- /dev/null +++ b/app/client/src/git/requests/deleteBranchRequest.ts @@ -0,0 +1,14 @@ +import type { AxiosResponse } from "axios"; +import type { + DeleteBranchRequestParams, + DeleteBranchResponse, +} from "./deleteBranchRequest.types"; +import { GIT_BASE_URL } from "./constants"; +import Api from "api/Api"; + +export default async function deleteBranchRequest( + baseApplicationId: string, + params: DeleteBranchRequestParams, +): Promise> { + return Api.delete(`${GIT_BASE_URL}/branch/app/${baseApplicationId}`, params); +} diff --git a/app/client/src/git/requests/deleteBranchRequest.types.ts b/app/client/src/git/requests/deleteBranchRequest.types.ts new file mode 100644 index 00000000000..f7db6f83485 --- /dev/null +++ b/app/client/src/git/requests/deleteBranchRequest.types.ts @@ -0,0 +1,8 @@ +export interface DeleteBranchRequestParams { + branchName: string; +} + +export interface DeleteBranchResponse { + id: string; // applicationId + baseId: string; // baseApplicationId +} diff --git a/app/client/src/git/requests/discardRequest.ts b/app/client/src/git/requests/discardRequest.ts new file mode 100644 index 00000000000..fda452fc206 --- /dev/null +++ b/app/client/src/git/requests/discardRequest.ts @@ -0,0 +1,9 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; + +export default async function discardRequest( + branchedApplicationId: string, +): Promise> { + return Api.put(`${GIT_BASE_URL}/discard/app/${branchedApplicationId}`); +} diff --git a/app/client/src/git/requests/disconnectRequest.ts b/app/client/src/git/requests/disconnectRequest.ts new file mode 100644 index 00000000000..9ec9b3a4e2b --- /dev/null +++ b/app/client/src/git/requests/disconnectRequest.ts @@ -0,0 +1,10 @@ +import type { AxiosResponse } from "axios"; +import { GIT_BASE_URL } from "./constants"; +import type { DisconnectResponse } from "./disconnectRequest.types"; +import Api from "api/Api"; + +export default async function disconnectRequest( + baseApplicationId: string, +): Promise> { + return Api.post(`${GIT_BASE_URL}/disconnect/app/${baseApplicationId}`); +} diff --git a/app/client/src/git/requests/disconnectRequest.types.ts b/app/client/src/git/requests/disconnectRequest.types.ts new file mode 100644 index 00000000000..34ac4728a32 --- /dev/null +++ b/app/client/src/git/requests/disconnectRequest.types.ts @@ -0,0 +1,3 @@ +export interface DisconnectResponse { + [key: string]: string; +} diff --git a/app/client/src/git/requests/fetchAutocommitProgressRequest.ts b/app/client/src/git/requests/fetchAutocommitProgressRequest.ts new file mode 100644 index 00000000000..8ad1c71d22c --- /dev/null +++ b/app/client/src/git/requests/fetchAutocommitProgressRequest.ts @@ -0,0 +1,12 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; +import type { FetchAutocommitProgressResponse } from "./fetchAutocommitProgressRequest.types"; + +export default async function fetchAutocommitProgressRequest( + baseApplicationId: string, +): Promise> { + return Api.get( + `${GIT_BASE_URL}/auto-commit/progress/app/${baseApplicationId}`, + ); +} diff --git a/app/client/src/git/requests/fetchAutocommitProgressRequest.types.ts b/app/client/src/git/requests/fetchAutocommitProgressRequest.types.ts new file mode 100644 index 00000000000..60f10b5fc6b --- /dev/null +++ b/app/client/src/git/requests/fetchAutocommitProgressRequest.types.ts @@ -0,0 +1,7 @@ +import type { AutocommitStatus } from "../constants/enums"; + +export interface FetchAutocommitProgressResponse { + autoCommitResponse: AutocommitStatus; + progress: number; + branchName: string; +} diff --git a/app/client/src/git/requests/fetchBranchesRequest.ts b/app/client/src/git/requests/fetchBranchesRequest.ts new file mode 100644 index 00000000000..90fdbf73d94 --- /dev/null +++ b/app/client/src/git/requests/fetchBranchesRequest.ts @@ -0,0 +1,21 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { + FetchBranchesRequestParams, + FetchBranchesResponse, +} from "./fetchBranchesRequest.types"; +import type { AxiosResponse } from "axios"; + +export default async function fetchBranchesRequest( + branchedApplicationId: string, + params?: FetchBranchesRequestParams, +): Promise> { + const queryParams = {} as FetchBranchesRequestParams; + + if (params?.pruneBranches) queryParams.pruneBranches = true; + + return Api.get( + `${GIT_BASE_URL}/branch/app/${branchedApplicationId}`, + queryParams, + ); +} diff --git a/app/client/src/git/requests/fetchBranchesRequest.types.ts b/app/client/src/git/requests/fetchBranchesRequest.types.ts new file mode 100644 index 00000000000..e86e545b309 --- /dev/null +++ b/app/client/src/git/requests/fetchBranchesRequest.types.ts @@ -0,0 +1,11 @@ +export interface FetchBranchesRequestParams { + pruneBranches: boolean; +} + +interface SingleBranch { + branchName: string; + createdFromLocal: string; + default: boolean; +} + +export type FetchBranchesResponse = SingleBranch[]; diff --git a/app/client/src/git/requests/fetchGitMetadataRequest.ts b/app/client/src/git/requests/fetchGitMetadataRequest.ts new file mode 100644 index 00000000000..136f5776f55 --- /dev/null +++ b/app/client/src/git/requests/fetchGitMetadataRequest.ts @@ -0,0 +1,10 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; +import type { FetchGitMetadataResponse } from "./fetchGitMetadataRequest.types"; + +export default async function fetchGitMetadataRequest( + baseApplicationId: string, +): Promise> { + return Api.get(`${GIT_BASE_URL}/metadata/app/${baseApplicationId}`); +} diff --git a/app/client/src/git/requests/fetchGitMetadataRequest.types.ts b/app/client/src/git/requests/fetchGitMetadataRequest.types.ts new file mode 100644 index 00000000000..95ef9f6ec3a --- /dev/null +++ b/app/client/src/git/requests/fetchGitMetadataRequest.types.ts @@ -0,0 +1,15 @@ +export interface FetchGitMetadataResponse { + branchName: string; + defaultBranchName: string; + remoteUrl: string; + repoName: string; + browserSupportedUrl?: string; + isRepoPrivate?: boolean; + browserSupportedRemoteUrl: string; + defaultApplicationId: string; + isProtectedBranch: boolean; + autoCommitConfig: { + enabled: boolean; + }; + isAutoDeploymentEnabled?: boolean; +} diff --git a/app/client/src/git/requests/fetchGlobalConfigRequest.ts b/app/client/src/git/requests/fetchGlobalConfigRequest.ts new file mode 100644 index 00000000000..32b048c0bbf --- /dev/null +++ b/app/client/src/git/requests/fetchGlobalConfigRequest.ts @@ -0,0 +1,10 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; +import type { FetchGlobalConfigResponse } from "./fetchGlobalConfigRequest.types"; + +export default async function fetchGlobalConfigRequest(): Promise< + AxiosResponse +> { + return Api.get(`${GIT_BASE_URL}/profile/default`); +} diff --git a/app/client/src/git/requests/fetchGlobalConfigRequest.types.ts b/app/client/src/git/requests/fetchGlobalConfigRequest.types.ts new file mode 100644 index 00000000000..1939b2df65f --- /dev/null +++ b/app/client/src/git/requests/fetchGlobalConfigRequest.types.ts @@ -0,0 +1,4 @@ +export interface FetchGlobalConfigResponse { + authorName: string; + authorEmail: string; +} diff --git a/app/client/src/git/requests/fetchLocalConfigRequest.ts b/app/client/src/git/requests/fetchLocalConfigRequest.ts new file mode 100644 index 00000000000..53159fc886f --- /dev/null +++ b/app/client/src/git/requests/fetchLocalConfigRequest.ts @@ -0,0 +1,10 @@ +import Api from "api/Api"; +import type { AxiosResponse } from "axios"; +import { GIT_BASE_URL } from "./constants"; +import type { FetchLocalConfigResponse } from "./fetchLocalConfigRequest.types"; + +export default async function fetchLocalConfigRequest( + baseApplicationId: string, +): Promise> { + return Api.get(`${GIT_BASE_URL}/profile/app/${baseApplicationId}`); +} diff --git a/app/client/src/git/requests/fetchLocalConfigRequest.types.ts b/app/client/src/git/requests/fetchLocalConfigRequest.types.ts new file mode 100644 index 00000000000..abc83e1d83b --- /dev/null +++ b/app/client/src/git/requests/fetchLocalConfigRequest.types.ts @@ -0,0 +1,5 @@ +export interface FetchLocalConfigResponse { + authorName: string; + authorEmail: string; + useGlobalProfile: boolean; +} diff --git a/app/client/src/git/requests/fetchMergeStatusRequest.ts b/app/client/src/git/requests/fetchMergeStatusRequest.ts new file mode 100644 index 00000000000..95701d5cadc --- /dev/null +++ b/app/client/src/git/requests/fetchMergeStatusRequest.ts @@ -0,0 +1,17 @@ +import type { AxiosResponse } from "axios"; +import type { + FetchMergeStatusRequestParams, + FetchMergeStatusResponse, +} from "./fetchMergeStatusRequest.types"; +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; + +export default async function fetchMergeStatusRequest( + branchedApplicationId: string, + params: FetchMergeStatusRequestParams, +): Promise> { + return Api.post( + `${GIT_BASE_URL}/merge/status/app/${branchedApplicationId}`, + params, + ); +} diff --git a/app/client/src/git/requests/fetchMergeStatusRequest.types.ts b/app/client/src/git/requests/fetchMergeStatusRequest.types.ts new file mode 100644 index 00000000000..76965ee37ff --- /dev/null +++ b/app/client/src/git/requests/fetchMergeStatusRequest.types.ts @@ -0,0 +1,10 @@ +export interface FetchMergeStatusRequestParams { + sourceBranch: string; + destinationBranch: string; +} + +export interface FetchMergeStatusResponse { + isMergeAble: boolean; + status: string; // merge status + message: string; +} diff --git a/app/client/src/git/requests/fetchProtectedBranchesRequest.ts b/app/client/src/git/requests/fetchProtectedBranchesRequest.ts new file mode 100644 index 00000000000..492a23c5eec --- /dev/null +++ b/app/client/src/git/requests/fetchProtectedBranchesRequest.ts @@ -0,0 +1,10 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; +import type { FetchProtectedBranches } from "./fetchProtectedBranchesRequest.types"; + +export default async function fetchProtectedBranchesRequest( + baseApplicationId: string, +): Promise> { + return Api.get(`${GIT_BASE_URL}/branch/app/${baseApplicationId}/protected`); +} diff --git a/app/client/src/git/requests/fetchProtectedBranchesRequest.types.ts b/app/client/src/git/requests/fetchProtectedBranchesRequest.types.ts new file mode 100644 index 00000000000..166cc05322e --- /dev/null +++ b/app/client/src/git/requests/fetchProtectedBranchesRequest.types.ts @@ -0,0 +1 @@ +export type FetchProtectedBranches = string[]; diff --git a/app/client/src/git/requests/fetchSSHKeyRequest.ts b/app/client/src/git/requests/fetchSSHKeyRequest.ts new file mode 100644 index 00000000000..e61884e28a3 --- /dev/null +++ b/app/client/src/git/requests/fetchSSHKeyRequest.ts @@ -0,0 +1,10 @@ +import type { AxiosResponse } from "axios"; +import type { FetchSSHKeyResponse } from "./fetchSSHKeyRequest.types"; +import Api from "api/Api"; +import { APPLICATION_BASE_URL } from "./constants"; + +export default async function fetchSSHKeyRequest( + baseApplicationId: string, +): Promise> { + return Api.get(`${APPLICATION_BASE_URL}/ssh-keypair/${baseApplicationId}`); +} diff --git a/app/client/src/git/requests/fetchSSHKeyRequest.types.ts b/app/client/src/git/requests/fetchSSHKeyRequest.types.ts new file mode 100644 index 00000000000..55b4f305b66 --- /dev/null +++ b/app/client/src/git/requests/fetchSSHKeyRequest.types.ts @@ -0,0 +1,6 @@ +export interface FetchSSHKeyResponse { + publicKey: string; + docUrl: string; + isRegeneratedKey: boolean; + regeneratedKey: boolean; +} diff --git a/app/client/src/git/requests/fetchStatusRequest.ts b/app/client/src/git/requests/fetchStatusRequest.ts new file mode 100644 index 00000000000..587b4f66ea0 --- /dev/null +++ b/app/client/src/git/requests/fetchStatusRequest.ts @@ -0,0 +1,14 @@ +import Api from "api/Api"; +import type { + FetchStatusRequestParams, + FetchStatusResponse, +} from "./fetchStatusRequest.types"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; + +export default async function fetchStatusRequest( + branchedApplicationId: string, + params: FetchStatusRequestParams, +): Promise> { + return Api.get(`${GIT_BASE_URL}/status/app/${branchedApplicationId}`, params); +} diff --git a/app/client/src/git/requests/fetchStatusRequest.types.ts b/app/client/src/git/requests/fetchStatusRequest.types.ts new file mode 100644 index 00000000000..9a63fc87948 --- /dev/null +++ b/app/client/src/git/requests/fetchStatusRequest.types.ts @@ -0,0 +1,38 @@ +export interface FetchStatusRequestParams { + compareRemote: boolean; +} + +export interface FetchStatusResponse { + added: string[]; + aheadCount: number; + behindCount: number; + conflicting: string[]; + datasourcesAdded: string[]; + datasourcesModified: string[]; + datasourcesRemoved: string[]; + discardDocUrl: string; + isClean: boolean; + jsLibsAdded: string[]; + jsLibsModified: string[]; + jsLibsRemoved: string[]; + jsObjectsAdded: string[]; + jsObjectsModified: string[]; + jsObjectsRemoved: string[]; + migrationMessage: string; + modified: string[]; + modifiedDatasources: number; + modifiedJSLibs: number; + modifiedJSObjects: number; + modifiedModuleInstances: number; + modifiedModules: number; + modifiedPages: number; + modifiedQueries: number; + pagesAdded: string[]; + pagesModified: string[]; + pagesRemoved: string[]; + queriesAdded: string[]; + queriesModified: string[]; + queriesRemoved: string[]; + remoteBranch: string; + removed: string[]; +} diff --git a/app/client/src/git/requests/generateSSHKeyRequest.ts b/app/client/src/git/requests/generateSSHKeyRequest.ts new file mode 100644 index 00000000000..525c0423a16 --- /dev/null +++ b/app/client/src/git/requests/generateSSHKeyRequest.ts @@ -0,0 +1,18 @@ +import type { AxiosResponse } from "axios"; +import type { + GenerateSSHKeyRequestParams, + GenerateSSHKeyResponse, +} from "./generateSSHKeyRequest.types"; +import { APPLICATION_BASE_URL, GIT_BASE_URL } from "./constants"; +import Api from "api/Api"; + +export default async function generateSSHKeyRequest( + baseApplicationId: string, + params: GenerateSSHKeyRequestParams, +): Promise> { + const url = params.isImporting + ? `${GIT_BASE_URL}/import/keys?keyType=${params.keyType}` + : `${APPLICATION_BASE_URL}/ssh-keypair/${baseApplicationId}?keyType=${params.keyType}`; + + return params.isImporting ? Api.get(url) : Api.post(url); +} diff --git a/app/client/src/git/requests/generateSSHKeyRequest.types.ts b/app/client/src/git/requests/generateSSHKeyRequest.types.ts new file mode 100644 index 00000000000..ced29ad6dbc --- /dev/null +++ b/app/client/src/git/requests/generateSSHKeyRequest.types.ts @@ -0,0 +1,11 @@ +export interface GenerateSSHKeyRequestParams { + keyType: string; + isImporting: boolean; +} + +export interface GenerateSSHKeyResponse { + publicKey: string; + docUrl: string; + isRegeneratedKey: boolean; + regeneratedKey: boolean; +} diff --git a/app/client/src/git/requests/importGitRequest.ts b/app/client/src/git/requests/importGitRequest.ts new file mode 100644 index 00000000000..e9378361da8 --- /dev/null +++ b/app/client/src/git/requests/importGitRequest.ts @@ -0,0 +1,14 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { + ImportGitRequestParams, + ImportGitResponse, +} from "./importGitRequest.types"; +import type { AxiosResponse } from "axios"; + +export default async function importGitRequest( + workspaceId: string, + params: ImportGitRequestParams, +): Promise> { + return Api.post(`${GIT_BASE_URL}/import/${workspaceId}`, params); +} diff --git a/app/client/src/git/requests/importGitRequest.types.ts b/app/client/src/git/requests/importGitRequest.types.ts new file mode 100644 index 00000000000..b0f3113d7a6 --- /dev/null +++ b/app/client/src/git/requests/importGitRequest.types.ts @@ -0,0 +1,24 @@ +export interface ImportGitRequestParams { + remoteUrl: string; + gitProfile?: { + authorName: string; + authorEmail: string; + useDefaultProfile?: boolean; + }; +} + +export interface ImportGitResponse { + id: string; + baseId: string; + gitApplicationMetadata: { + branchName: string; + browserSupportedRemoteUrl: string; + defaultApplicationId: string; + defaultArtifactId: string; + defaultBranchName: string; + isRepoPrivate: boolean; + lastCommitedAt: string; + remoteUrl: string; + repoName: string; + }; +} diff --git a/app/client/src/git/requests/mergeRequest.ts b/app/client/src/git/requests/mergeRequest.ts new file mode 100644 index 00000000000..ee30566c493 --- /dev/null +++ b/app/client/src/git/requests/mergeRequest.ts @@ -0,0 +1,11 @@ +import Api from "api/Api"; +import type { MergeRequestParams, MergeResponse } from "./mergeRequest.types"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; + +export default async function mergeRequest( + branchedApplicationId: string, + params: MergeRequestParams, +): Promise> { + return Api.post(`${GIT_BASE_URL}/merge/app/${branchedApplicationId}`, params); +} diff --git a/app/client/src/git/requests/mergeRequest.types.ts b/app/client/src/git/requests/mergeRequest.types.ts new file mode 100644 index 00000000000..7ec27500b1e --- /dev/null +++ b/app/client/src/git/requests/mergeRequest.types.ts @@ -0,0 +1,9 @@ +export interface MergeRequestParams { + sourceBranch: string; + destinationBranch: string; +} + +export interface MergeResponse { + isMergAble: boolean; + status: string; // merge status +} diff --git a/app/client/src/git/requests/pullRequest.ts b/app/client/src/git/requests/pullRequest.ts new file mode 100644 index 00000000000..21bd6f4f2a3 --- /dev/null +++ b/app/client/src/git/requests/pullRequest.ts @@ -0,0 +1,10 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; +import type { PullRequestResponse } from "./pullRequest.types"; + +export default async function pullRequest( + branchedApplicationId: string, +): Promise> { + return Api.get(`${GIT_BASE_URL}/pull/app/${branchedApplicationId}`); +} diff --git a/app/client/src/git/requests/pullRequest.types.ts b/app/client/src/git/requests/pullRequest.types.ts new file mode 100644 index 00000000000..abfb2586ca8 --- /dev/null +++ b/app/client/src/git/requests/pullRequest.types.ts @@ -0,0 +1,6 @@ +export interface PullRequestResponse { + mergeStatus: { + isMergeAble: boolean; + status: string; // pull merge status + }; +} diff --git a/app/client/src/git/requests/toggleAutocommitRequest.ts b/app/client/src/git/requests/toggleAutocommitRequest.ts new file mode 100644 index 00000000000..deba662ded3 --- /dev/null +++ b/app/client/src/git/requests/toggleAutocommitRequest.ts @@ -0,0 +1,12 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; +import type { ToggleAutocommitResponse } from "./toggleAutocommitRequest.types"; + +export default async function toggleAutocommitRequest( + baseApplicationId: string, +): Promise> { + return Api.patch( + `${GIT_BASE_URL}/auto-commit/toggle/app/${baseApplicationId}`, + ); +} diff --git a/app/client/src/git/requests/toggleAutocommitRequest.types.ts b/app/client/src/git/requests/toggleAutocommitRequest.types.ts new file mode 100644 index 00000000000..9dc99ed8452 --- /dev/null +++ b/app/client/src/git/requests/toggleAutocommitRequest.types.ts @@ -0,0 +1 @@ +export type ToggleAutocommitResponse = boolean; diff --git a/app/client/src/git/requests/triggerAutocommitRequest.ts b/app/client/src/git/requests/triggerAutocommitRequest.ts new file mode 100644 index 00000000000..01c603cb4ce --- /dev/null +++ b/app/client/src/git/requests/triggerAutocommitRequest.ts @@ -0,0 +1,10 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { AxiosResponse } from "axios"; +import type { TriggerAutocommitResponse } from "./triggerAutocommitRequest.types"; + +export default async function triggerAutocommitRequest( + branchedApplicationId: string, +): Promise> { + return Api.post(`${GIT_BASE_URL}/auto-commit/app/${branchedApplicationId}`); +} diff --git a/app/client/src/git/requests/triggerAutocommitRequest.types.ts b/app/client/src/git/requests/triggerAutocommitRequest.types.ts new file mode 100644 index 00000000000..6abf80ecf22 --- /dev/null +++ b/app/client/src/git/requests/triggerAutocommitRequest.types.ts @@ -0,0 +1,7 @@ +import type { AutocommitStatus } from "../constants/enums"; + +export interface TriggerAutocommitResponse { + autoCommitResponse: AutocommitStatus; + progress: number; + branchName: string; +} diff --git a/app/client/src/git/requests/updateGlobalConfigRequest.ts b/app/client/src/git/requests/updateGlobalConfigRequest.ts new file mode 100644 index 00000000000..a20cb5beb87 --- /dev/null +++ b/app/client/src/git/requests/updateGlobalConfigRequest.ts @@ -0,0 +1,13 @@ +import type { AxiosResponse } from "axios"; +import type { + UpdateGlobalConfigRequestParams, + UpdateGlobalConfigResponse, +} from "./updateGlobalConfigRequest.types"; +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; + +export default async function updateGlobalConfigRequest( + params: UpdateGlobalConfigRequestParams, +): Promise> { + return Api.post(`${GIT_BASE_URL}/profile/default`, params); +} diff --git a/app/client/src/git/requests/updateGlobalConfigRequest.types.ts b/app/client/src/git/requests/updateGlobalConfigRequest.types.ts new file mode 100644 index 00000000000..10116f7dc99 --- /dev/null +++ b/app/client/src/git/requests/updateGlobalConfigRequest.types.ts @@ -0,0 +1,11 @@ +export interface UpdateGlobalConfigRequestParams { + authorName: string; + authorEmail: string; +} + +export interface UpdateGlobalConfigResponse { + default: { + authorName: string; + authorEmail: string; + }; +} diff --git a/app/client/src/git/requests/updateLocalConfigRequest.ts b/app/client/src/git/requests/updateLocalConfigRequest.ts new file mode 100644 index 00000000000..4b69bc72c27 --- /dev/null +++ b/app/client/src/git/requests/updateLocalConfigRequest.ts @@ -0,0 +1,14 @@ +import type { AxiosResponse } from "axios"; +import type { + UpdateLocalConfigRequestParams, + UpdateLocalConfigResponse, +} from "./updateLocalConfigRequest.types"; +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; + +export default async function updateLocalConfigRequest( + baseApplicationId: string, + params: UpdateLocalConfigRequestParams, +): Promise> { + return Api.put(`${GIT_BASE_URL}/profile/app/${baseApplicationId}`, params); +} diff --git a/app/client/src/git/requests/updateLocalConfigRequest.types.ts b/app/client/src/git/requests/updateLocalConfigRequest.types.ts new file mode 100644 index 00000000000..5414637a46c --- /dev/null +++ b/app/client/src/git/requests/updateLocalConfigRequest.types.ts @@ -0,0 +1,13 @@ +export interface UpdateLocalConfigRequestParams { + authorName: string; + authorEmail: string; + useGlobalProfile: boolean; +} + +export interface UpdateLocalConfigResponse { + [baseApplicationId: string]: { + authorName: string; + authorEmail: string; + useGlobalProfile: boolean; + }; +} diff --git a/app/client/src/git/requests/updateProtectedBranchesRequest.ts b/app/client/src/git/requests/updateProtectedBranchesRequest.ts new file mode 100644 index 00000000000..5af603ecaa7 --- /dev/null +++ b/app/client/src/git/requests/updateProtectedBranchesRequest.ts @@ -0,0 +1,17 @@ +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; +import type { + UpdateProtectedBranchesRequestParams, + UpdateProtectedBranchesResponse, +} from "./updateProtectedBranchesRequest.types"; +import type { AxiosResponse } from "axios"; + +export default async function updateProtectedBranchesRequest( + baseApplicationId: string, + params: UpdateProtectedBranchesRequestParams, +): Promise> { + return Api.post( + `${GIT_BASE_URL}/branch/app/${baseApplicationId}/protected`, + params, + ); +} diff --git a/app/client/src/git/requests/updateProtectedBranchesRequest.types.ts b/app/client/src/git/requests/updateProtectedBranchesRequest.types.ts new file mode 100644 index 00000000000..fff7073624e --- /dev/null +++ b/app/client/src/git/requests/updateProtectedBranchesRequest.types.ts @@ -0,0 +1,5 @@ +export interface UpdateProtectedBranchesRequestParams { + branchNames: string[]; +} + +export type UpdateProtectedBranchesResponse = string[]; diff --git a/app/client/src/git/types.ts b/app/client/src/git/types.ts index 7786dcc1b4a..af349c168fb 100644 --- a/app/client/src/git/types.ts +++ b/app/client/src/git/types.ts @@ -5,7 +5,7 @@ import type { GitImportStep, GitOpsTab, GitSettingsTab, -} from "./enums"; +} from "./constants/enums"; // These will be updated when contracts are finalized export type GitMetadata = Record; diff --git a/app/server/appsmith-git/src/main/java/com/appsmith/git/files/FileUtilsCEImpl.java b/app/server/appsmith-git/src/main/java/com/appsmith/git/files/FileUtilsCEImpl.java index d919338cef9..b7f39502212 100644 --- a/app/server/appsmith-git/src/main/java/com/appsmith/git/files/FileUtilsCEImpl.java +++ b/app/server/appsmith-git/src/main/java/com/appsmith/git/files/FileUtilsCEImpl.java @@ -6,6 +6,9 @@ import com.appsmith.external.git.FileInterface; import com.appsmith.external.git.GitExecutor; import com.appsmith.external.git.constants.GitSpan; +import com.appsmith.external.git.models.GitResourceIdentity; +import com.appsmith.external.git.models.GitResourceMap; +import com.appsmith.external.git.models.GitResourceType; import com.appsmith.external.git.operations.FileOperations; import com.appsmith.external.helpers.ObservationHelper; import com.appsmith.external.helpers.Stopwatch; @@ -14,10 +17,12 @@ import com.appsmith.git.configurations.GitServiceConfig; import com.appsmith.git.constants.CommonConstants; import com.appsmith.git.helpers.DSLTransformerHelper; +import com.fasterxml.jackson.databind.ObjectMapper; import io.micrometer.tracing.Span; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.eclipse.jgit.api.errors.GitAPIException; import org.json.JSONObject; @@ -28,6 +33,8 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; import java.io.BufferedWriter; import java.io.File; @@ -48,7 +55,10 @@ import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static com.appsmith.external.git.constants.GitConstants.ACTION_COLLECTION_LIST; import static com.appsmith.external.git.constants.GitConstants.ACTION_LIST; @@ -74,6 +84,7 @@ public class FileUtilsCEImpl implements FileInterface { private final GitExecutor gitExecutor; protected final FileOperations fileOperations; private final ObservationHelper observationHelper; + protected final ObjectMapper objectMapper; private static final String EDIT_MODE_URL_TEMPLATE = "{{editModeUrl}}"; @@ -90,11 +101,23 @@ public FileUtilsCEImpl( GitServiceConfig gitServiceConfig, GitExecutor gitExecutor, FileOperations fileOperations, - ObservationHelper observationHelper) { + ObservationHelper observationHelper, + ObjectMapper objectMapper) { this.gitServiceConfig = gitServiceConfig; this.gitExecutor = gitExecutor; this.fileOperations = fileOperations; this.observationHelper = observationHelper; + this.objectMapper = objectMapper; + } + + protected Map getModifiedResourcesTypes() { + return Map.of( + GitResourceType.JSLIB_CONFIG, GitResourceType.JSLIB_CONFIG, + GitResourceType.CONTEXT_CONFIG, GitResourceType.CONTEXT_CONFIG, + GitResourceType.QUERY_CONFIG, GitResourceType.QUERY_CONFIG, + GitResourceType.QUERY_DATA, GitResourceType.QUERY_CONFIG, + GitResourceType.JSOBJECT_CONFIG, GitResourceType.JSOBJECT_CONFIG, + GitResourceType.JSOBJECT_DATA, GitResourceType.JSOBJECT_CONFIG); } /** @@ -215,6 +238,101 @@ public Mono saveApplicationToGitRepo( .subscribeOn(scheduler); } + @Override + public Mono saveArtifactToGitRepo(Path baseRepoSuffix, GitResourceMap gitResourceMap, String branchName) + throws GitAPIException, IOException { + + // Repo path will be: + // baseRepo : root/orgId/defaultAppId/repoName/{applicationData} + // Checkout to mentioned branch if not already checked-out + return gitExecutor + .resetToLastCommit(baseRepoSuffix, branchName) + .flatMap(isSwitched -> { + Path baseRepo = Paths.get(gitServiceConfig.getGitRootPath()).resolve(baseRepoSuffix); + + try { + updateEntitiesInRepo(gitResourceMap, baseRepo); + } catch (IOException e) { + return Mono.error(e); + } + + return Mono.just(baseRepo); + }) + .subscribeOn(scheduler); + } + + protected Set getExistingFilesInRepo(Path baseRepo) throws IOException { + try (Stream stream = Files.walk(baseRepo).parallel()) { + return stream.filter(path -> { + try { + return Files.isRegularFile(path) || FileUtils.isEmptyDirectory(path.toFile()); + } catch (IOException e) { + log.error("Unable to find file details. Please check the file at file path: {}", path); + log.error("Assuming that it does not exist for now ..."); + return false; + } + }) + .map(baseRepo::relativize) + .map(Path::toString) + .collect(Collectors.toSet()); + } + } + + protected Set updateEntitiesInRepo(GitResourceMap gitResourceMap, Path baseRepo) throws IOException { + ModifiedResources modifiedResources = gitResourceMap.getModifiedResources(); + Map resourceMap = gitResourceMap.getGitResourceMap(); + + Set filesInRepo = getExistingFilesInRepo(baseRepo); + + Set updatedFilesToBeSerialized = resourceMap.keySet().parallelStream() + .map(gitResourceIdentity -> gitResourceIdentity.getFilePath()) + .collect(Collectors.toSet()); + + // Remove all files that need to be serialized from the existing files list, as well as the README file + // What we are left with are all the files to be deleted + filesInRepo.removeAll(updatedFilesToBeSerialized); + filesInRepo.remove("README.md"); + + // Delete all the files because they are no longer needed + // This covers both older structures of storing files and, + // legitimate changes in the artifact that might cause deletions + filesInRepo.stream().parallel().forEach(filePath -> { + try { + Files.deleteIfExists(baseRepo.resolve(filePath)); + } catch (IOException e) { + // We ignore files that could not be deleted and expect to come back to this at a later point + // Just log the path for now + log.error("Unable to delete file at path: {}", filePath); + } + }); + + // Now go through the resource map and based on resource type, check if the resource is modified before + // serialization + // Or simply choose the mechanism for serialization + Map modifiedResourcesTypes = getModifiedResourcesTypes(); + return resourceMap.entrySet().parallelStream() + .map(entry -> { + GitResourceIdentity key = entry.getKey(); + boolean resourceUpdated = true; + if (modifiedResourcesTypes.containsKey(key.getResourceType()) && modifiedResources != null) { + GitResourceType comparisonType = modifiedResourcesTypes.get(key.getResourceType()); + + resourceUpdated = + modifiedResources.isResourceUpdatedNew(comparisonType, key.getResourceIdentifier()); + } + + if (resourceUpdated) { + String filePath = key.getFilePath(); + saveResourceCommon(entry.getValue(), baseRepo.resolve(filePath)); + + return filePath; + } + return null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + protected Set updateEntitiesInRepo(ApplicationGitReference applicationGitReference, Path baseRepo) { Set validDatasourceFileNames = new HashSet<>(); @@ -434,6 +552,23 @@ protected boolean saveResource(Object sourceEntity, Path path) { return false; } + protected void saveResourceCommon(Object sourceEntity, Path path) { + try { + Files.createDirectories(path.getParent()); + if (sourceEntity instanceof String s) { + writeStringToFile(s, path); + return; + } + if (sourceEntity instanceof JSONObject) { + sourceEntity = objectMapper.readTree(sourceEntity.toString()); + } + fileOperations.writeToFile(sourceEntity, path); + } catch (IOException e) { + log.error("Error while writing resource to file {} with {}", path, e.getMessage()); + log.debug(e.getMessage()); + } + } + /** * This method is used to write actionCollection specific resource to file system. We write the data in two steps * 1. Actual js code @@ -514,9 +649,9 @@ private void writeStringToFile(String sourceEntity, Path path) throws IOExceptio /** * This will reconstruct the application from the repo * - * @param organisationId To which organisation application needs to be rehydrated + * @param organisationId To which organisation application needs to be rehydrated * @param baseApplicationId To which organisation application needs to be rehydrated - * @param branchName for which the application needs to be rehydrate + * @param branchName for which the application needs to be rehydrate * @return application reference from which entire application can be rehydrated */ public Mono reconstructApplicationReferenceFromGitRepo( @@ -672,6 +807,84 @@ private Object readPageMetadata(Path directoryPath) { directoryPath.resolve(directoryPath.toFile().getName() + CommonConstants.JSON_EXTENSION)); } + protected GitResourceMap fetchGitResourceMap(Path baseRepoPath) throws IOException { + // Extract application metadata from the json + Object metadata = fileOperations.readFile( + baseRepoPath.resolve(CommonConstants.METADATA + CommonConstants.JSON_EXTENSION)); + Integer fileFormatVersion = fileOperations.getFileFormatVersion(metadata); + // Check if fileFormat of the saved files in repo is compatible + if (!isFileFormatCompatible(fileFormatVersion)) { + throw new AppsmithPluginException(AppsmithPluginError.INCOMPATIBLE_FILE_FORMAT); + } + + GitResourceMap gitResourceMap = new GitResourceMap(); + Map resourceMap = gitResourceMap.getGitResourceMap(); + + Set filesInRepo = getExistingFilesInRepo(baseRepoPath); + + filesInRepo.parallelStream() + .filter(path -> !Files.isDirectory(baseRepoPath.resolve(path))) + .forEach(filePath -> { + Tuple2 identity = getGitResourceIdentity(baseRepoPath, filePath); + + resourceMap.put(identity.getT1(), identity.getT2()); + }); + + return gitResourceMap; + } + + protected Tuple2 getGitResourceIdentity(Path baseRepoPath, String filePath) { + Path path = baseRepoPath.resolve(filePath); + GitResourceIdentity identity; + Object contents = fileOperations.readFile(path); + if (!filePath.contains("/")) { + identity = new GitResourceIdentity(GitResourceType.ROOT_CONFIG, filePath, filePath); + } else if (filePath.matches(DATASOURCE_DIRECTORY + "/.*")) { + String gitSyncId = + objectMapper.valueToTree(contents).get("gitSyncId").asText(); + identity = new GitResourceIdentity(GitResourceType.DATASOURCE_CONFIG, gitSyncId, filePath); + } else if (filePath.matches(JS_LIB_DIRECTORY + "/.*")) { + String fileName = FilenameUtils.getBaseName(filePath); + identity = new GitResourceIdentity(GitResourceType.JSLIB_CONFIG, fileName, filePath); + } else if (filePath.matches(PAGE_DIRECTORY + "/[^/]*/[^/]*.json]")) { + String gitSyncId = + objectMapper.valueToTree(contents).get("gitSyncId").asText(); + identity = new GitResourceIdentity(GitResourceType.CONTEXT_CONFIG, gitSyncId, filePath); + } else if (filePath.matches(PAGE_DIRECTORY + "/[^/]*/" + ACTION_DIRECTORY + "/.*/metadata.json")) { + String gitSyncId = + objectMapper.valueToTree(contents).get("gitSyncId").asText(); + identity = new GitResourceIdentity(GitResourceType.QUERY_CONFIG, gitSyncId, filePath); + } else if (filePath.matches(PAGE_DIRECTORY + "/[^/]*/" + ACTION_DIRECTORY + "/.*\\.txt")) { + Object configContents = fileOperations.readFile(path.getParent().resolve("metadata.json")); + String gitSyncId = + objectMapper.valueToTree(configContents).get("gitSyncId").asText(); + identity = new GitResourceIdentity(GitResourceType.QUERY_DATA, gitSyncId, filePath); + } else if (filePath.matches(PAGE_DIRECTORY + "/[^/]*/" + ACTION_COLLECTION_DIRECTORY + "/.*/metadata.json")) { + String gitSyncId = + objectMapper.valueToTree(contents).get("gitSyncId").asText(); + identity = new GitResourceIdentity(GitResourceType.JSOBJECT_CONFIG, gitSyncId, filePath); + } else if (filePath.matches(PAGE_DIRECTORY + "/[^/]*/" + ACTION_COLLECTION_DIRECTORY + "/.*\\.js")) { + Object configContents = fileOperations.readFile(path.getParent().resolve("metadata.json")); + String gitSyncId = + objectMapper.valueToTree(configContents).get("gitSyncId").asText(); + identity = new GitResourceIdentity(GitResourceType.JSOBJECT_DATA, gitSyncId, filePath); + } else if (filePath.matches(PAGE_DIRECTORY + "/[^/]*/widgets/.*\\.json")) { + Pattern pageDirPattern = Pattern.compile("(" + PAGE_DIRECTORY + "/([^/]*))/widgets/.*\\.json"); + Matcher matcher = pageDirPattern.matcher(filePath); + matcher.find(); + String pageDirectory = matcher.group(1); + String pageName = matcher.group(2) + ".json"; + Object configContents = + fileOperations.readFile(baseRepoPath.resolve(pageDirectory).resolve(pageName)); + String gitSyncId = + objectMapper.valueToTree(configContents).get("gitSyncId").asText(); + String widgetId = objectMapper.valueToTree(contents).get("widgetId").asText(); + identity = new GitResourceIdentity(GitResourceType.JSOBJECT_DATA, gitSyncId + "-" + widgetId, filePath); + } else return null; + + return Tuples.of(identity, contents); + } + private ApplicationGitReference fetchApplicationReference(Path baseRepoPath) { ApplicationGitReference applicationGitReference = new ApplicationGitReference(); // Extract application metadata from the json diff --git a/app/server/appsmith-git/src/main/java/com/appsmith/git/files/FileUtilsImpl.java b/app/server/appsmith-git/src/main/java/com/appsmith/git/files/FileUtilsImpl.java index b45aba4a723..34256a08096 100644 --- a/app/server/appsmith-git/src/main/java/com/appsmith/git/files/FileUtilsImpl.java +++ b/app/server/appsmith-git/src/main/java/com/appsmith/git/files/FileUtilsImpl.java @@ -5,6 +5,7 @@ import com.appsmith.external.git.operations.FileOperations; import com.appsmith.external.helpers.ObservationHelper; import com.appsmith.git.configurations.GitServiceConfig; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Import; @@ -22,7 +23,8 @@ public FileUtilsImpl( GitServiceConfig gitServiceConfig, GitExecutor gitExecutor, FileOperations fileOperations, - ObservationHelper observationHelper) { - super(gitServiceConfig, gitExecutor, fileOperations, observationHelper); + ObservationHelper observationHelper, + ObjectMapper objectMapper) { + super(gitServiceConfig, gitExecutor, fileOperations, observationHelper, objectMapper); } } diff --git a/app/server/appsmith-git/src/main/java/com/appsmith/git/helpers/DSLTransformerHelper.java b/app/server/appsmith-git/src/main/java/com/appsmith/git/helpers/DSLTransformerHelper.java index 9d444b857df..67b7aafc002 100644 --- a/app/server/appsmith-git/src/main/java/com/appsmith/git/helpers/DSLTransformerHelper.java +++ b/app/server/appsmith-git/src/main/java/com/appsmith/git/helpers/DSLTransformerHelper.java @@ -16,6 +16,10 @@ import java.util.stream.Collectors; import static com.appsmith.git.constants.CommonConstants.CANVAS_WIDGET; +import static com.appsmith.git.constants.CommonConstants.DELIMITER_PATH; +import static com.appsmith.git.constants.CommonConstants.DELIMITER_POINT; +import static com.appsmith.git.constants.CommonConstants.EMPTY_STRING; +import static com.appsmith.git.constants.CommonConstants.MAIN_CONTAINER; @Component @RequiredArgsConstructor @@ -24,7 +28,7 @@ public class DSLTransformerHelper { public static Map flatten(JSONObject jsonObject) { Map flattenedMap = new HashMap<>(); - flattenObject(jsonObject, CommonConstants.EMPTY_STRING, flattenedMap); + flattenObject(jsonObject, EMPTY_STRING, flattenedMap); return new TreeMap<>(flattenedMap); } @@ -44,8 +48,7 @@ private static void flattenObject(JSONObject jsonObject, String prefix, Map> calculateParentDirectories(List Map> parentDirectories = new HashMap<>(); paths = paths.stream() - .map(currentPath -> currentPath.replace(CommonConstants.JSON_EXTENSION, CommonConstants.EMPTY_STRING)) + .map(currentPath -> currentPath.replace(CommonConstants.JSON_EXTENSION, EMPTY_STRING)) .collect(Collectors.toList()); for (String path : paths) { - String[] directories = path.split(CommonConstants.DELIMITER_PATH); + String[] directories = path.split(DELIMITER_PATH); int lastDirectoryIndex = directories.length - 1; - if (lastDirectoryIndex > 0 && directories[lastDirectoryIndex].equals(directories[lastDirectoryIndex - 1])) { + if (lastDirectoryIndex <= 0) { + // This is not a valid path anymore, ignore + continue; + } + if (directories[lastDirectoryIndex].equals(directories[lastDirectoryIndex - 1])) { if (lastDirectoryIndex - 2 >= 0) { String parentDirectory = directories[lastDirectoryIndex - 2]; List pathsList = parentDirectories.getOrDefault(parentDirectory, new ArrayList<>()); @@ -143,10 +150,10 @@ public static JSONObject getNestedDSL( Map jsonMap, Map> pathMapping, JSONObject mainContainer) { // start from the root // Empty page with no widgets - if (!pathMapping.containsKey(CommonConstants.MAIN_CONTAINER)) { + if (!pathMapping.containsKey(MAIN_CONTAINER)) { return mainContainer; } - for (String path : pathMapping.get(CommonConstants.MAIN_CONTAINER)) { + for (String path : pathMapping.get(MAIN_CONTAINER)) { JSONObject child = getChildren(path, jsonMap, pathMapping); JSONArray children = mainContainer.optJSONArray(CommonConstants.CHILDREN); if (children == null) { @@ -179,7 +186,7 @@ public static JSONObject getChildren( } public static String getWidgetName(String path) { - String[] directories = path.split(CommonConstants.DELIMITER_PATH); + String[] directories = path.split(DELIMITER_PATH); return directories[directories.length - 1]; } @@ -229,15 +236,16 @@ private static Map getWidgetIdWidgetNameMapping(JSONArray ex public static String getPathToWidgetFile(String key, JSONObject jsonObject, String widgetName) { // get path with splitting the name via key - String childPath = key.replace(CommonConstants.MAIN_CONTAINER, CommonConstants.EMPTY_STRING) - .replace(CommonConstants.DELIMITER_POINT, CommonConstants.DELIMITER_PATH); + String childPath = key.replace(MAIN_CONTAINER, EMPTY_STRING).replace(DELIMITER_POINT, DELIMITER_PATH); // Replace the canvas Widget as a child and add it to the same level as parent - childPath = childPath.replaceAll(CANVAS_WIDGET, CommonConstants.EMPTY_STRING); + childPath = childPath.replaceAll(CANVAS_WIDGET, EMPTY_STRING); if (!DSLTransformerHelper.hasChildren(jsonObject) && !DSLTransformerHelper.isTabsWidget(jsonObject)) { // Save the widget as a directory or Save the widget as a file // Only consider widgetName at the end of the childPath to reset // For example, "foobar/bar" should convert into "foobar/" - childPath = childPath.replaceAll(widgetName + "$", CommonConstants.EMPTY_STRING); + childPath = childPath.replaceAll(widgetName + "$", EMPTY_STRING); + } else { + childPath += DELIMITER_PATH; } return childPath; diff --git a/app/server/appsmith-git/src/test/java/com/appsmith/git/helpers/FileUtilsImplTest.java b/app/server/appsmith-git/src/test/java/com/appsmith/git/helpers/FileUtilsImplTest.java index 58b4c3bfc77..e222feff2a9 100644 --- a/app/server/appsmith-git/src/test/java/com/appsmith/git/helpers/FileUtilsImplTest.java +++ b/app/server/appsmith-git/src/test/java/com/appsmith/git/helpers/FileUtilsImplTest.java @@ -7,6 +7,7 @@ import com.appsmith.git.files.FileUtilsImpl; import com.appsmith.git.files.operations.FileOperationsImpl; import com.appsmith.git.service.GitExecutorImpl; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.io.FileUtils; import org.eclipse.jgit.api.errors.GitAPIException; import org.junit.jupiter.api.AfterEach; @@ -42,7 +43,8 @@ public void setUp() { GitServiceConfig gitServiceConfig = new GitServiceConfig(); gitServiceConfig.setGitRootPath(localTestDirectoryPath.toString()); FileOperations fileOperations = new FileOperationsImpl(null, ObservationHelper.NOOP); - fileUtils = new FileUtilsImpl(gitServiceConfig, gitExecutor, fileOperations, ObservationHelper.NOOP); + fileUtils = new FileUtilsImpl( + gitServiceConfig, gitExecutor, fileOperations, ObservationHelper.NOOP, new ObjectMapper()); } @AfterEach diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/spans/HealthSpan.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/spans/HealthSpan.java new file mode 100644 index 00000000000..52cdc7b82ba --- /dev/null +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/spans/HealthSpan.java @@ -0,0 +1,5 @@ +package com.appsmith.external.constants.spans; + +import com.appsmith.external.constants.spans.ce.HealthSpanCE; + +public class HealthSpan extends HealthSpanCE {} diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/spans/ce/HealthSpanCE.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/spans/ce/HealthSpanCE.java new file mode 100644 index 00000000000..7c13af46643 --- /dev/null +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/spans/ce/HealthSpanCE.java @@ -0,0 +1,10 @@ +package com.appsmith.external.constants.spans.ce; + +import com.appsmith.external.constants.spans.BaseSpan; + +public class HealthSpanCE { + + public static final String HEALTH = "health."; + public static final String MONGO_HEALTH = BaseSpan.APPSMITH_SPAN_PREFIX + HEALTH + "mongo"; + public static final String REDIS_HEALTH = BaseSpan.APPSMITH_SPAN_PREFIX + HEALTH + "redis"; +} diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/dtos/ModifiedResources.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/dtos/ModifiedResources.java index 8157d6c2763..166b379c426 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/dtos/ModifiedResources.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/dtos/ModifiedResources.java @@ -1,13 +1,7 @@ package com.appsmith.external.dtos; +import com.appsmith.external.dtos.ce.ModifiedResourcesCE; import lombok.Data; -import org.apache.commons.lang3.StringUtils; -import org.springframework.util.CollectionUtils; - -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; /** * This DTO class is used to store which resources have been updated after the last commit. @@ -17,49 +11,4 @@ * the pages to file system for difference git processes e.g. check git status, commit etc */ @Data -public class ModifiedResources { - // boolean flag to set whether all the resources should be considered as updated or not, it'll be false by default - private boolean isAllModified; - - // a map to store the type of the resources and related entries - Map> modifiedResourceMap = new ConcurrentHashMap<>(); - - /** - * Checks whether the provided resource name should be considered as modified or not. - * It'll return true if the isAllModified flag is set or the resource is present in the modifiedResourceMap - * @param resourceType String, type of the resource e.g. PAGE_LIST - * @param resourceName String, name of the resource e.g. "Home Page" - * @return true if modified, false otherwise - */ - public boolean isResourceUpdated(String resourceType, String resourceName) { - return StringUtils.isNotEmpty(resourceType) - && (isAllModified - || (!CollectionUtils.isEmpty(modifiedResourceMap.get(resourceType)) - && modifiedResourceMap.get(resourceType).contains(resourceName))); - } - - /** - * Adds a new resource to the map. Will create a new set if no set found for the provided resource type. - * @param resourceType String, type of the resource e.g. PAGE_LST - * @param resourceName String, name of the resource e.g. Home Page - */ - public void putResource(String resourceType, String resourceName) { - if (!this.modifiedResourceMap.containsKey(resourceType)) { - this.modifiedResourceMap.put(resourceType, new HashSet<>()); - } - this.modifiedResourceMap.get(resourceType).add(resourceName); - } - - /** - * Adds a set of resources to the map. Will create a new set if no set found for the provided resource type. - * It'll append the resources to the set. - * @param resourceType String, type of the resource e.g. PAGE_LST - * @param resourceNames Set of String, names of the resource e.g. Home Page, About page - */ - public void putResource(String resourceType, Set resourceNames) { - if (!this.modifiedResourceMap.containsKey(resourceType)) { - this.modifiedResourceMap.put(resourceType, new HashSet<>()); - } - this.modifiedResourceMap.get(resourceType).addAll(resourceNames); - } -} +public class ModifiedResources extends ModifiedResourcesCE {} diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/dtos/ce/ModifiedResourcesCE.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/dtos/ce/ModifiedResourcesCE.java new file mode 100644 index 00000000000..9e3e5a3aa8d --- /dev/null +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/dtos/ce/ModifiedResourcesCE.java @@ -0,0 +1,86 @@ +package com.appsmith.external.dtos.ce; + +import com.appsmith.external.git.models.GitResourceType; +import lombok.Data; +import org.apache.commons.lang3.StringUtils; +import org.springframework.util.CollectionUtils; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * This DTO class is used to store which resources have been updated after the last commit. + * Primarily the export process sets this information and git import process uses this information to identify + * which resources need to be written in file system. For example, if a page has not been updated after the last commit, + * the name of the page should not be part of the modifiedResourceMap so that git will skip this page when it writes + * the pages to file system for difference git processes e.g. check git status, commit etc + */ +@Data +public class ModifiedResourcesCE { + // boolean flag to set whether all the resources should be considered as updated or not, it'll be false by default + private boolean isAllModified; + + // a map to store the type of the resources and related entries + Map> modifiedResourceMap = new ConcurrentHashMap<>(); + + Map> modifiedResourceIdentifiers = new ConcurrentHashMap<>(); + + public Map> getModifiedResourceIdentifiers() { + if (this.modifiedResourceIdentifiers.isEmpty()) { + this.modifiedResourceIdentifiers.putAll(Map.of( + GitResourceType.CONTEXT_CONFIG, ConcurrentHashMap.newKeySet(), + GitResourceType.JSLIB_CONFIG, ConcurrentHashMap.newKeySet(), + GitResourceType.QUERY_CONFIG, ConcurrentHashMap.newKeySet(), + GitResourceType.JSOBJECT_CONFIG, ConcurrentHashMap.newKeySet())); + } + return modifiedResourceIdentifiers; + } + + /** + * Checks whether the provided resource name should be considered as modified or not. + * It'll return true if the isAllModified flag is set or the resource is present in the modifiedResourceMap + * @param resourceType String, type of the resource e.g. PAGE_LIST + * @param resourceName String, name of the resource e.g. "Home Page" + * @return true if modified, false otherwise + */ + public boolean isResourceUpdated(String resourceType, String resourceName) { + return StringUtils.isNotEmpty(resourceType) + && (isAllModified + || (!CollectionUtils.isEmpty(modifiedResourceMap.get(resourceType)) + && modifiedResourceMap.get(resourceType).contains(resourceName))); + } + + public boolean isResourceUpdatedNew(GitResourceType resourceType, String resourceIdentifier) { + return StringUtils.isNotEmpty(resourceIdentifier) + && (isAllModified + || (!CollectionUtils.isEmpty(modifiedResourceIdentifiers.get(resourceType)) + && modifiedResourceIdentifiers.get(resourceType).contains(resourceIdentifier))); + } + + /** + * Adds a new resource to the map. Will create a new set if no set found for the provided resource type. + * @param resourceType String, type of the resource e.g. PAGE_LST + * @param resourceName String, name of the resource e.g. Home Page + */ + public void putResource(String resourceType, String resourceName) { + if (!this.modifiedResourceMap.containsKey(resourceType)) { + this.modifiedResourceMap.put(resourceType, new HashSet<>()); + } + this.modifiedResourceMap.get(resourceType).add(resourceName); + } + + /** + * Adds a set of resources to the map. Will create a new set if no set found for the provided resource type. + * It'll append the resources to the set. + * @param resourceType String, type of the resource e.g. PAGE_LST + * @param resourceNames Set of String, names of the resource e.g. Home Page, About page + */ + public void putResource(String resourceType, Set resourceNames) { + if (!this.modifiedResourceMap.containsKey(resourceType)) { + this.modifiedResourceMap.put(resourceType, new HashSet<>()); + } + this.modifiedResourceMap.get(resourceType).addAll(resourceNames); + } +} diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/git/FileInterface.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/git/FileInterface.java index fd17dd1e51c..d5422a24ef4 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/git/FileInterface.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/git/FileInterface.java @@ -1,5 +1,6 @@ package com.appsmith.external.git; +import com.appsmith.external.git.models.GitResourceMap; import com.appsmith.external.models.ApplicationGitReference; import com.appsmith.external.models.ArtifactGitReference; import org.eclipse.jgit.api.errors.GitAPIException; @@ -34,6 +35,9 @@ Mono saveApplicationToGitRepo( Path baseRepoSuffix, ArtifactGitReference artifactGitReference, String branchName) throws IOException, GitAPIException; + Mono saveArtifactToGitRepo(Path baseRepoSuffix, GitResourceMap gitResourceMap, String branchName) + throws GitAPIException, IOException; + /** * This method will reconstruct the application from the repo * diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/git/models/GitResourceIdentity.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/git/models/GitResourceIdentity.java index e1428216656..2bf12f779f4 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/git/models/GitResourceIdentity.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/git/models/GitResourceIdentity.java @@ -8,8 +8,6 @@ @Data @RequiredArgsConstructor public class GitResourceIdentity { - // TODO @Nidhi should we persist the info from parsing this filePath ? - String filePath; // TODO @Nidhi should we persist this sha against the Appsmith domain to integrate with the isModified logic? String sha; @@ -25,4 +23,6 @@ public class GitResourceIdentity { // root dir files -> fileName @NonNull @EqualsAndHashCode.Include String resourceIdentifier; + + @NonNull String filePath; } diff --git a/app/server/appsmith-plugins/appsmithAiPlugin/src/main/java/com/external/plugins/utils/FileUtils.java b/app/server/appsmith-plugins/appsmithAiPlugin/src/main/java/com/external/plugins/utils/FileUtils.java index 9f224b4deec..27acf3d3bc0 100644 --- a/app/server/appsmith-plugins/appsmithAiPlugin/src/main/java/com/external/plugins/utils/FileUtils.java +++ b/app/server/appsmith-plugins/appsmithAiPlugin/src/main/java/com/external/plugins/utils/FileUtils.java @@ -19,7 +19,8 @@ public static boolean hasFiles(DatasourceConfiguration datasourceConfiguration) } public static List getFileIds(DatasourceConfiguration datasourceConfiguration) { - if (datasourceConfiguration.getProperties() != null + if (datasourceConfiguration != null + && datasourceConfiguration.getProperties() != null && datasourceConfiguration.getProperties().size() > 0) { for (Property property : datasourceConfiguration.getProperties()) { if (property.getKey().equalsIgnoreCase(FILES) diff --git a/app/server/appsmith-plugins/appsmithAiPlugin/src/test/java/com/external/plugins/services/FileUtilTest.java b/app/server/appsmith-plugins/appsmithAiPlugin/src/test/java/com/external/plugins/services/FileUtilTest.java new file mode 100644 index 00000000000..c242428d61f --- /dev/null +++ b/app/server/appsmith-plugins/appsmithAiPlugin/src/test/java/com/external/plugins/services/FileUtilTest.java @@ -0,0 +1,44 @@ +package com.external.plugins.services; + +import com.appsmith.external.models.DatasourceConfiguration; +import com.appsmith.external.models.Property; +import com.external.plugins.utils.FileUtils; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +public class FileUtilTest { + @Test + public void getFileIds_withNullDatasourceConfig_returnsEmptyList() { + DatasourceConfiguration datasourceConfiguration = null; + List actualFileIds = FileUtils.getFileIds(datasourceConfiguration); + assertThat(actualFileIds).isEmpty(); + } + + @Test + public void getFileIds_withValidDatasourceConfig_returnsFileIdList() { + DatasourceConfiguration datasourceConfiguration = new DatasourceConfiguration(); + datasourceConfiguration.setUrl("https://example.com"); + + // create file object + Map fileMap = new HashMap(); + fileMap.put("id", "fileId"); + fileMap.put("name", "fileName"); + fileMap.put("size", 10); + fileMap.put("mimetype", "fileMimetype"); + + Property property = new Property(); + property.setKey("Files"); + property.setValue(List.of(fileMap)); + + datasourceConfiguration.setProperties(List.of(property)); + List actualFileIds = FileUtils.getFileIds(datasourceConfiguration); + assertThat(actualFileIds).contains("fileId"); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/exportable/ActionCollectionExportableServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/exportable/ActionCollectionExportableServiceCEImpl.java index 34f46f26e3c..dd4d34a12c6 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/exportable/ActionCollectionExportableServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/exportable/ActionCollectionExportableServiceCEImpl.java @@ -1,5 +1,6 @@ package com.appsmith.server.actioncollections.exportable; +import com.appsmith.external.git.models.GitResourceType; import com.appsmith.server.acl.AclPermission; import com.appsmith.server.constants.FieldName; import com.appsmith.server.domains.ActionCollection; @@ -11,7 +12,6 @@ import com.appsmith.server.dtos.MappedExportableResourcesDTO; import com.appsmith.server.exports.exportable.ExportableServiceCE; import com.appsmith.server.exports.exportable.artifactbased.ArtifactBasedExportableService; -import com.appsmith.server.helpers.ImportExportUtils; import com.appsmith.server.solutions.ActionPermission; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Flux; @@ -67,6 +67,7 @@ public Mono getExportableEntities( // Because the actions will have a reference to the collection Set updatedActionCollectionSet = new HashSet<>(); + Set updatedIdentifiers = new HashSet<>(); actionCollections.forEach(actionCollection -> { ActionCollectionDTO publishedActionCollectionDTO = actionCollection.getPublishedCollection(); ActionCollectionDTO unpublishedActionCollectionDTO = @@ -78,9 +79,12 @@ public Mono getExportableEntities( // we've replaced page id with page name in previous step String contextNameAtIdReference = artifactBasedExportableService.getContextNameAtIdReference(actionCollectionDTO); - String contextListPath = artifactBasedExportableService.getContextListPath(); - boolean isContextUpdated = ImportExportUtils.isContextNameInUpdatedList( - artifactExchangeJson, contextNameAtIdReference, contextListPath); + String contextGitSyncId = mappedExportableResourcesDTO + .getContextNameToGitSyncIdMap() + .get(contextNameAtIdReference); + boolean isContextUpdated = artifactExchangeJson + .getModifiedResources() + .isResourceUpdatedNew(GitResourceType.CONTEXT_CONFIG, contextGitSyncId); String actionCollectionName = actionCollectionDTO.getUserExecutableName() + NAME_SEPARATOR + contextNameAtIdReference; Instant actionCollectionUpdatedAt = actionCollection.getUpdatedAt(); @@ -92,6 +96,7 @@ public Mono getExportableEntities( || exportingMetaDTO.getArtifactLastCommittedAt().isBefore(actionCollectionUpdatedAt); if (isActionCollectionUpdated) { updatedActionCollectionSet.add(actionCollectionName); + updatedIdentifiers.add(actionCollection.getGitSyncId()); } actionCollection.sanitiseToExportDBObject(); }); @@ -100,6 +105,11 @@ public Mono getExportableEntities( artifactExchangeJson .getModifiedResources() .putResource(FieldName.ACTION_COLLECTION_LIST, updatedActionCollectionSet); + artifactExchangeJson + .getModifiedResources() + .getModifiedResourceIdentifiers() + .get(GitResourceType.JSOBJECT_CONFIG) + .addAll(updatedIdentifiers); return actionCollections; }) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/ApplicationGitFileUtilsCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/ApplicationGitFileUtilsCEImpl.java index dbe2f5dc2a6..a5628db2df1 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/ApplicationGitFileUtilsCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/ApplicationGitFileUtilsCEImpl.java @@ -19,6 +19,7 @@ import com.appsmith.server.domains.Application; import com.appsmith.server.domains.ApplicationPage; import com.appsmith.server.domains.CustomJSLib; +import com.appsmith.server.domains.Layout; import com.appsmith.server.domains.NewAction; import com.appsmith.server.domains.NewPage; import com.appsmith.server.domains.Theme; @@ -32,9 +33,12 @@ import com.appsmith.server.helpers.ce.ArtifactGitFileUtilsCE; import com.appsmith.server.migrations.JsonSchemaMigration; import com.appsmith.server.newactions.base.NewActionService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.gson.Gson; import lombok.NonNull; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.minidev.json.JSONObject; import net.minidev.json.parser.JSONParser; @@ -60,6 +64,11 @@ import static com.appsmith.external.git.constants.GitConstants.NAME_SEPARATOR; import static com.appsmith.external.helpers.AppsmithBeanUtils.copyNestedNonNullProperties; import static com.appsmith.external.helpers.AppsmithBeanUtils.copyProperties; +import static com.appsmith.git.constants.CommonConstants.DELIMITER_PATH; +import static com.appsmith.git.constants.CommonConstants.JSON_EXTENSION; +import static com.appsmith.git.constants.CommonConstants.MAIN_CONTAINER; +import static com.appsmith.git.constants.CommonConstants.WIDGETS; +import static com.appsmith.git.constants.ce.GitDirectoriesCE.PAGE_DIRECTORY; import static com.appsmith.server.constants.FieldName.ACTION_COLLECTION_LIST; import static com.appsmith.server.constants.FieldName.ACTION_LIST; import static com.appsmith.server.constants.FieldName.CHILDREN; @@ -70,20 +79,36 @@ import static com.appsmith.server.constants.FieldName.EXPORTED_APPLICATION; import static com.appsmith.server.constants.FieldName.PAGE_LIST; import static com.appsmith.server.constants.FieldName.WIDGET_ID; +import static com.appsmith.server.constants.ce.FieldNameCE.WIDGET_NAME; import static com.appsmith.server.helpers.ce.CommonGitFileUtilsCE.removeUnwantedFieldsFromBaseDomain; @Slf4j @Component -@RequiredArgsConstructor @Import({FileUtilsImpl.class}) public class ApplicationGitFileUtilsCEImpl implements ArtifactGitFileUtilsCE { private final Gson gson; + private final ObjectMapper objectMapper; private final NewActionService newActionService; private final FileInterface fileUtils; private final JsonSchemaMigration jsonSchemaMigration; private final ActionCollectionService actionCollectionService; + public ApplicationGitFileUtilsCEImpl( + Gson gson, + ObjectMapper objectMapper, + NewActionService newActionService, + FileInterface fileUtils, + JsonSchemaMigration jsonSchemaMigration, + ActionCollectionService actionCollectionService) { + this.gson = gson; + this.objectMapper = objectMapper.copy().disable(MapperFeature.USE_ANNOTATIONS); + this.newActionService = newActionService; + this.fileUtils = fileUtils; + this.jsonSchemaMigration = jsonSchemaMigration; + this.actionCollectionService = actionCollectionService; + } + // Only include the application helper fields in metadata object protected Set getBlockedMetadataFields() { return Set.of( @@ -109,6 +134,11 @@ public ApplicationGitReference createArtifactReferenceObject() { return new ApplicationGitReference(); } + @Override + public ArtifactExchangeJson createArtifactExchangeJsonObject() { + return new ApplicationJson(); + } + @Override public void addArtifactReferenceFromExportedJson( ArtifactExchangeJson artifactExchangeJson, ArtifactGitReference artifactGitReference) { @@ -141,8 +171,9 @@ public void setArtifactDependentResources( // application Application application = applicationJson.getExportedApplication(); removeUnwantedFieldsFromApplication(application); - GitResourceIdentity applicationIdentity = new GitResourceIdentity( - GitResourceType.ROOT_CONFIG, CommonConstants.APPLICATION + CommonConstants.JSON_EXTENSION); + final String applicationFilePath = CommonConstants.APPLICATION + JSON_EXTENSION; + GitResourceIdentity applicationIdentity = + new GitResourceIdentity(GitResourceType.ROOT_CONFIG, applicationFilePath, applicationFilePath); resourceMap.put(applicationIdentity, application); // metadata @@ -154,9 +185,11 @@ public void setArtifactDependentResources( ApplicationJson applicationMetadata = new ApplicationJson(); applicationJson.setModifiedResources(null); copyProperties(applicationJson, applicationMetadata, keys); - GitResourceIdentity metadataIdentity = new GitResourceIdentity( - GitResourceType.ROOT_CONFIG, CommonConstants.METADATA + CommonConstants.JSON_EXTENSION); - resourceMap.put(metadataIdentity, applicationMetadata); + final String metadataFilePath = CommonConstants.METADATA + JSON_EXTENSION; + ObjectNode metadata = objectMapper.valueToTree(applicationMetadata); + GitResourceIdentity metadataIdentity = + new GitResourceIdentity(GitResourceType.ROOT_CONFIG, metadataFilePath, metadataFilePath); + resourceMap.put(metadataIdentity, metadata); // pages and widgets applicationJson.getPageList().stream() @@ -166,23 +199,30 @@ public void setArtifactDependentResources( && newPage.getUnpublishedPage().getDeletedAt() == null) .forEach(newPage -> { removeUnwantedFieldsFromPage(newPage); - JSONObject dsl = - newPage.getUnpublishedPage().getLayouts().get(0).getDsl(); + PageDTO pageDTO = newPage.getUnpublishedPage(); + JSONObject dsl = pageDTO.getLayouts().get(0).getDsl(); // Get MainContainer widget data, remove the children and club with Canvas.json file JSONObject mainContainer = new JSONObject(dsl); mainContainer.remove(CHILDREN); - newPage.getUnpublishedPage().getLayouts().get(0).setDsl(mainContainer); + pageDTO.getLayouts().get(0).setDsl(mainContainer); // pageName will be used for naming the json file - GitResourceIdentity pageIdentity = - new GitResourceIdentity(GitResourceType.CONTEXT_CONFIG, newPage.getGitSyncId()); + final String pagePathPrefix = PAGE_DIRECTORY + DELIMITER_PATH + pageDTO.getName() + DELIMITER_PATH; + final String pageFilePath = pagePathPrefix + pageDTO.getName() + JSON_EXTENSION; + GitResourceIdentity pageIdentity = new GitResourceIdentity( + GitResourceType.CONTEXT_CONFIG, newPage.getGitSyncId(), pageFilePath); resourceMap.put(pageIdentity, newPage); Map result = DSLTransformerHelper.flatten(new org.json.JSONObject(dsl.toString())); result.forEach((key, jsonObject) -> { String widgetId = newPage.getGitSyncId() + "-" + jsonObject.getString(WIDGET_ID); + String widgetsPath = pagePathPrefix + WIDGETS + DELIMITER_PATH; + String widgetName = jsonObject.getString(WIDGET_NAME); + String subPath = DSLTransformerHelper.getPathToWidgetFile(key, jsonObject, widgetName); + + String widgetPath = widgetsPath + subPath + widgetName + JSON_EXTENSION; GitResourceIdentity widgetIdentity = - new GitResourceIdentity(GitResourceType.WIDGET_CONFIG, widgetId); + new GitResourceIdentity(GitResourceType.WIDGET_CONFIG, widgetId, widgetPath); resourceMap.put(widgetIdentity, jsonObject); }); }); @@ -629,4 +669,80 @@ public Path getRepoSuffixPath(String workspaceId, String artifactId, String repo varargs.addAll(List.of(args)); return Paths.get(workspaceId, varargs.toArray(new String[0])); } + + @Override + public void setArtifactDependentPropertiesInJson( + GitResourceMap gitResourceMap, ArtifactExchangeJson artifactExchangeJson) { + Map resourceMap = gitResourceMap.getGitResourceMap(); + + // exported application + final String applicationFilePath = CommonConstants.APPLICATION + JSON_EXTENSION; + GitResourceIdentity applicationJsonIdentity = + new GitResourceIdentity(GitResourceType.ROOT_CONFIG, applicationFilePath, applicationFilePath); + + Object applicationObject = resourceMap.get(applicationJsonIdentity); + Application application = objectMapper.convertValue(applicationObject, Application.class); + artifactExchangeJson.setArtifact(application); + + // metadata + final String metadataFilePath = CommonConstants.METADATA + JSON_EXTENSION; + GitResourceIdentity metadataIdentity = + new GitResourceIdentity(GitResourceType.ROOT_CONFIG, metadataFilePath, metadataFilePath); + + Object metadataObject = resourceMap.get(metadataIdentity); + ApplicationJson metadata = objectMapper.convertValue(metadataObject, ApplicationJson.class); + copyNestedNonNullProperties(metadata, artifactExchangeJson); + + // pages + List pageList = resourceMap.entrySet().stream() + .filter(entry -> { + GitResourceIdentity key = entry.getKey(); + return GitResourceType.CONTEXT_CONFIG.equals(key.getResourceType()); + }) + .map(Map.Entry::getValue) + .map(pageObject -> objectMapper.convertValue(pageObject, NewPage.class)) + .collect(Collectors.toList()); + artifactExchangeJson.setContextList(pageList); + + // widgets + + pageList.parallelStream().forEach(newPage -> { + Map widgetsData = resourceMap.entrySet().stream() + .filter(entry -> { + GitResourceIdentity key = entry.getKey(); + return GitResourceType.WIDGET_CONFIG.equals(key.getResourceType()) + && key.getResourceIdentifier().startsWith(newPage.getGitSyncId() + "-"); + }) + .collect(Collectors.toMap( + entry -> entry.getKey() + .getFilePath() + .replaceFirst( + PAGE_DIRECTORY + + newPage.getUnpublishedPage() + .getName() + + DELIMITER_PATH + + WIDGETS + + DELIMITER_PATH, + MAIN_CONTAINER + DELIMITER_PATH), + entry -> (org.json.JSONObject) entry.getValue())); + + Layout layout = newPage.getUnpublishedPage().getLayouts().get(0); + org.json.JSONObject mainContainer; + try { + mainContainer = new org.json.JSONObject(objectMapper.writeValueAsString(layout.getDsl())); + + Map> parentDirectories = DSLTransformerHelper.calculateParentDirectories( + widgetsData.keySet().stream().toList()); + org.json.JSONObject nestedDSL = + DSLTransformerHelper.getNestedDSL(widgetsData, parentDirectories, mainContainer); + + JSONParser jsonParser = new JSONParser(); + JSONObject parsedDSL = jsonParser.parse(nestedDSL.toString(), JSONObject.class); + + layout.setDsl(parsedDSL); + } catch (ParseException | JsonProcessingException e) { + throw new RuntimeException(e); + } + }); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/ApplicationGitFileUtilsImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/ApplicationGitFileUtilsImpl.java index a5535c5f055..fcf0b2682ad 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/ApplicationGitFileUtilsImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/ApplicationGitFileUtilsImpl.java @@ -6,6 +6,7 @@ import com.appsmith.server.helpers.ArtifactGitFileUtils; import com.appsmith.server.migrations.JsonSchemaMigration; import com.appsmith.server.newactions.base.NewActionService; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.gson.Gson; import org.springframework.stereotype.Component; @@ -15,10 +16,11 @@ public class ApplicationGitFileUtilsImpl extends ApplicationGitFileUtilsCEImpl public ApplicationGitFileUtilsImpl( Gson gson, + ObjectMapper objectMapper, NewActionService newActionService, FileInterface fileUtils, JsonSchemaMigration jsonSchemaMigration, ActionCollectionService actionCollectionService) { - super(gson, newActionService, fileUtils, jsonSchemaMigration, actionCollectionService); + super(gson, objectMapper, newActionService, fileUtils, jsonSchemaMigration, actionCollectionService); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/GitApplicationHelperCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/GitApplicationHelperCEImpl.java index 9e5d38dfd71..e59d74b7ae6 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/GitApplicationHelperCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/applications/git/GitApplicationHelperCEImpl.java @@ -317,4 +317,9 @@ public Application getNewArtifact(String workspaceId, String repoName) { newApplication.setGitApplicationMetadata(new GitArtifactMetadata()); return newApplication; } + + @Override + public Mono publishArtifactPostCommit(Artifact committedArtifact) { + return publishArtifact(committedArtifact, true); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ApplicationJsonCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ApplicationJsonCE.java index 7f4ec76ee8a..348ca06e01a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ApplicationJsonCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ApplicationJsonCE.java @@ -10,6 +10,7 @@ import com.appsmith.server.domains.ActionCollection; import com.appsmith.server.domains.Application; import com.appsmith.server.domains.Artifact; +import com.appsmith.server.domains.Context; import com.appsmith.server.domains.CustomJSLib; import com.appsmith.server.domains.NewAction; import com.appsmith.server.domains.NewPage; @@ -123,6 +124,11 @@ public Artifact getArtifact() { return this.getExportedApplication(); } + @Override + public void setArtifact(T application) { + this.exportedApplication = (Application) application; + } + @Override public void setThemes(Theme unpublishedTheme, Theme publishedTheme) { this.setEditModeTheme(unpublishedTheme); @@ -138,4 +144,9 @@ public Theme getUnpublishedTheme() { public List getContextList() { return this.pageList; } + + @Override + public void setContextList(List contextList) { + this.pageList = (List) contextList; + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ArtifactExchangeJsonCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ArtifactExchangeJsonCE.java index 47bf907e3d0..f22d68d3699 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ArtifactExchangeJsonCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ArtifactExchangeJsonCE.java @@ -30,6 +30,8 @@ public interface ArtifactExchangeJsonCE { Artifact getArtifact(); + void setArtifact(T artifact); + default void setThemes(Theme unpublishedTheme, Theme publishedTheme) {} default List getCustomJSLibList() { @@ -68,4 +70,6 @@ default Theme getPublishedTheme() { @JsonView(Views.Internal.class) List getContextList(); + + void setContextList(List contextList); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/MappedExportableResourcesCE_DTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/MappedExportableResourcesCE_DTO.java index 0490dbc2d59..d69a8866026 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/MappedExportableResourcesCE_DTO.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/MappedExportableResourcesCE_DTO.java @@ -15,6 +15,7 @@ public class MappedExportableResourcesCE_DTO { Map datasourceIdToNameMap = new HashMap<>(); Map datasourceNameToUpdatedAtMap = new HashMap<>(); Map contextIdToNameMap = new HashMap<>(); + Map contextNameToGitSyncIdMap = new HashMap<>(); Map actionIdToNameMap = new HashMap<>(); Map collectionIdToNameMap = new HashMap<>(); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/GitRedisUtils.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/GitRedisUtils.java index f61661f02e8..6c27759f3ea 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/GitRedisUtils.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/GitRedisUtils.java @@ -23,15 +23,20 @@ public class GitRedisUtils { private final RedisUtils redisUtils; private final ObservationRegistry observationRegistry; - public Mono addFileLock(String defaultApplicationId, String commandName, Boolean isRetryAllowed) { + /** + * Adds a baseArtifact id as a key in redis, the presence of this key represents a symbolic lock, essentially meaning that no new operations + * should be performed till this key remains present. + * @param baseArtifactId : base id of the artifact for which the key is getting added. + * @param commandName : Name of the operation which is trying to acquire the lock, this value will be added against the key + * @param isRetryAllowed : Boolean for whether retries for adding the value is allowed + * @return a boolean publisher for the added file locks + */ + public Mono addFileLock(String baseArtifactId, String commandName, Boolean isRetryAllowed) { long numberOfRetries = Boolean.TRUE.equals(isRetryAllowed) ? MAX_RETRIES : 0L; - log.info( - "Git command {} is trying to acquire the lock for application id {}", - commandName, - defaultApplicationId); + log.info("Git command {} is trying to acquire the lock for application id {}", commandName, baseArtifactId); return redisUtils - .addFileLock(defaultApplicationId, commandName) + .addFileLock(baseArtifactId, commandName) .retryWhen(Retry.fixedDelay(numberOfRetries, RETRY_DELAY) .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> { if (retrySignal.failure() instanceof AppsmithException) { @@ -54,4 +59,38 @@ public Mono releaseFileLock(String defaultApplicationId) { .name(GitSpan.RELEASE_FILE_LOCK) .tap(Micrometer.observation(observationRegistry)); } + + /** + * This is a wrapper method for acquiring git lock, since multiple ops are used in sequence + * for a complete composite operation not all ops require to acquire the lock hence a dummy flag is sent back for + * operations in that is getting executed in between + * @param baseArtifactId : id of the base artifact for which ops would be locked + * @param isLockRequired : is lock really required or is it a proxy function + * @return : Boolean for whether the lock is acquired + */ + // TODO @Manish add artifactType reference in incoming prs. + public Mono acquireGitLock(String baseArtifactId, String commandName, boolean isLockRequired) { + if (!Boolean.TRUE.equals(isLockRequired)) { + return Mono.just(Boolean.TRUE); + } + + return addFileLock(baseArtifactId, commandName); + } + + /** + * This is a wrapper method for releasing git lock, since multiple ops are used in sequence + * for a complete composite operation not all ops require to acquire the lock hence a dummy flag is sent back for + * operations in that is getting executed in between + * @param baseArtifactId : id of the base artifact for which ops would be locked + * @param isLockRequired : is lock really required or is it a proxy function + * @return : Boolean for whether the lock is released + */ + // TODO @Manish add artifactType reference in incoming prs + public Mono releaseFileLock(String baseArtifactId, boolean isLockRequired) { + if (!Boolean.TRUE.equals(isLockRequired)) { + return Mono.just(Boolean.TRUE); + } + + return releaseFileLock(baseArtifactId); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/autocommit/AutoCommitEventHandlerCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/autocommit/AutoCommitEventHandlerCEImpl.java index 8e9313b90cb..bfb09ac1e5c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/autocommit/AutoCommitEventHandlerCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/autocommit/AutoCommitEventHandlerCEImpl.java @@ -4,12 +4,14 @@ import com.appsmith.external.dtos.ModifiedResources; import com.appsmith.external.git.GitExecutor; import com.appsmith.external.git.constants.GitConstants.GitCommandConstants; +import com.appsmith.external.git.models.GitResourceType; import com.appsmith.server.configurations.ProjectProperties; import com.appsmith.server.constants.ArtifactType; import com.appsmith.server.constants.FieldName; import com.appsmith.server.domains.Layout; import com.appsmith.server.domains.NewPage; import com.appsmith.server.dtos.ApplicationJson; +import com.appsmith.server.dtos.PageDTO; import com.appsmith.server.events.AutoCommitEvent; import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; @@ -28,14 +30,16 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; import java.nio.file.Path; import java.nio.file.Paths; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import static com.appsmith.external.git.constants.GitConstants.PAGE_LIST; import static java.lang.Boolean.TRUE; @@ -212,9 +216,17 @@ private Mono migrateUnpublishedPageDSLs( ApplicationJson to file system conversion will use this field to decide which pages need to be written back to file system. */ - Set pageNamesSet = new HashSet<>(updatedPageNamesList); + + Set pageNamesSet = + updatedPageNamesList.stream().map(Tuple2::getT1).collect(Collectors.toSet()); + Set pageIdentifiersSet = + updatedPageNamesList.stream().map(Tuple2::getT2).collect(Collectors.toSet()); ModifiedResources modifiedResources = new ModifiedResources(); modifiedResources.putResource(PAGE_LIST, pageNamesSet); + modifiedResources + .getModifiedResourceIdentifiers() + .get(GitResourceType.CONTEXT_CONFIG) + .addAll(pageIdentifiersSet); modifiedResources.setAllModified(true); applicationJson.setModifiedResources(modifiedResources); return applicationJson; @@ -236,7 +248,7 @@ private Mono migrateUnpublishedPageDSLs( * @param latestSchemaVersion latest dsl schema version obtained from RTS * @return list of names of the pages that have been migrated. */ - private Mono> migratePageDsl(List newPageList, Integer latestSchemaVersion) { + private Mono>> migratePageDsl(List newPageList, Integer latestSchemaVersion) { return Flux.fromIterable(newPageList) .filter(newPage -> { // filter the pages which have unpublished page with layouts and where dsl version is not latest @@ -249,8 +261,8 @@ private Mono> migratePageDsl(List newPageList, Integer lat } return false; }) - .map(NewPage::getUnpublishedPage) - .flatMap(pageDTO -> { + .flatMap(newPage -> { + PageDTO pageDTO = newPage.getUnpublishedPage(); Layout layout = pageDTO.getLayouts().get(0); return dslMigrationUtils .migratePageDsl(layout.getDsl()) @@ -258,7 +270,7 @@ private Mono> migratePageDsl(List newPageList, Integer lat layout.setDsl(migratedDsl); return migratedDsl; }) - .thenReturn(pageDTO.getName()); + .thenReturn(Tuples.of(pageDTO.getName(), newPage.getGitSyncId())); }) .collectList(); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCE.java index acc212ab68a..27768442c07 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCE.java @@ -1,5 +1,6 @@ package com.appsmith.server.git.central; +import com.appsmith.git.dto.CommitDTO; import com.appsmith.server.constants.ArtifactType; import com.appsmith.server.domains.Artifact; import com.appsmith.server.dtos.ArtifactImportDTO; @@ -17,4 +18,9 @@ Mono connectArtifactToGit( String originHeader, ArtifactType artifactType, GitType gitType); + + Mono commitArtifact( + CommitDTO commitDTO, String branchedArtifactId, ArtifactType artifactType, GitType gitType); + + Mono detachRemote(String branchedArtifactId, ArtifactType artifactType, GitType gitType); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCECompatibleImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCECompatibleImpl.java index 909f7ffee58..b996e584912 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCECompatibleImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCECompatibleImpl.java @@ -2,6 +2,7 @@ import com.appsmith.server.datasources.base.DatasourceService; import com.appsmith.server.exports.internal.ExportService; +import com.appsmith.server.git.GitRedisUtils; import com.appsmith.server.git.resolver.GitArtifactHelperResolver; import com.appsmith.server.git.resolver.GitHandlingServiceResolver; import com.appsmith.server.git.utils.GitAnalyticsUtils; @@ -12,6 +13,7 @@ import com.appsmith.server.services.UserDataService; import com.appsmith.server.services.WorkspaceService; import com.appsmith.server.solutions.DatasourcePermission; +import io.micrometer.observation.ObservationRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -32,7 +34,9 @@ public CentralGitServiceCECompatibleImpl( WorkspaceService workspaceService, PluginService pluginService, ImportService importService, - ExportService exportService) { + ExportService exportService, + GitRedisUtils gitRedisUtils, + ObservationRegistry observationRegistry) { super( gitProfileUtils, gitAnalyticsUtils, @@ -45,6 +49,8 @@ public CentralGitServiceCECompatibleImpl( workspaceService, pluginService, importService, - exportService); + exportService, + gitRedisUtils, + observationRegistry); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCEImpl.java index b075fc7048f..9af8b9ce9ee 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCEImpl.java @@ -1,6 +1,8 @@ package com.appsmith.server.git.central; import com.appsmith.external.constants.AnalyticsEvents; +import com.appsmith.external.git.constants.GitConstants; +import com.appsmith.external.git.constants.GitSpan; import com.appsmith.external.models.Datasource; import com.appsmith.external.models.DatasourceStorage; import com.appsmith.git.dto.CommitDTO; @@ -24,6 +26,7 @@ import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.exports.internal.ExportService; +import com.appsmith.server.git.GitRedisUtils; import com.appsmith.server.git.dtos.ArtifactJsonTransformationDTO; import com.appsmith.server.git.resolver.GitArtifactHelperResolver; import com.appsmith.server.git.resolver.GitHandlingServiceResolver; @@ -36,6 +39,7 @@ import com.appsmith.server.services.UserDataService; import com.appsmith.server.services.WorkspaceService; import com.appsmith.server.solutions.DatasourcePermission; +import io.micrometer.observation.ObservationRegistry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.eclipse.jgit.api.errors.InvalidRemoteException; @@ -43,8 +47,11 @@ import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import reactor.core.observability.micrometer.Micrometer; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.function.Tuple2; +import reactor.util.function.Tuple3; import java.io.IOException; import java.time.Instant; @@ -54,10 +61,15 @@ import java.util.concurrent.TimeoutException; import static com.appsmith.external.git.constants.ce.GitConstantsCE.DEFAULT_COMMIT_MESSAGE; +import static com.appsmith.external.git.constants.ce.GitConstantsCE.GIT_CONFIG_ERROR; import static com.appsmith.external.git.constants.ce.GitConstantsCE.GIT_PROFILE_ERROR; +import static com.appsmith.external.git.constants.ce.GitSpanCE.OPS_COMMIT; +import static com.appsmith.external.helpers.AppsmithBeanUtils.copyNestedNonNullProperties; import static com.appsmith.server.constants.FieldName.DEFAULT; import static com.appsmith.server.constants.SerialiseArtifactObjective.VERSION_CONTROL; import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; +import static org.springframework.util.StringUtils.hasText; @Slf4j @Service @@ -82,6 +94,9 @@ public class CentralGitServiceCEImpl implements CentralGitServiceCE { private final ImportService importService; private final ExportService exportService; + private final GitRedisUtils gitRedisUtils; + private final ObservationRegistry observationRegistry; + protected Mono isRepositoryLimitReachedForWorkspace(String workspaceId, Boolean isRepositoryPrivate) { if (!isRepositoryPrivate) { return Mono.just(FALSE); @@ -195,7 +210,7 @@ public Mono importArtifactFromGit( ArtifactJsonTransformationDTO jsonMorphDTO = new ArtifactJsonTransformationDTO(); jsonMorphDTO.setWorkspaceId(workspaceId); - jsonMorphDTO.setArtifactId(artifact.getId()); + jsonMorphDTO.setBaseArtifactId(artifact.getId()); jsonMorphDTO.setArtifactType(artifactType); jsonMorphDTO.setRepoName(gitArtifactMetadata.getRepoName()); jsonMorphDTO.setRefType(RefType.BRANCH); @@ -274,7 +289,7 @@ private Mono deleteArtifactCreatedFromGitImport( return gitHandlingService .removeRepository(artifactJsonTransformationDTO) - .zipWith(gitArtifactHelper.deleteArtifact(artifactJsonTransformationDTO.getArtifactId())) + .zipWith(gitArtifactHelper.deleteArtifact(artifactJsonTransformationDTO.getBaseArtifactId())) .map(Tuple2::getT2); } @@ -443,7 +458,7 @@ public Mono connectArtifactToGit( ArtifactJsonTransformationDTO jsonTransformationDTO = new ArtifactJsonTransformationDTO(); jsonTransformationDTO.setWorkspaceId(artifact.getWorkspaceId()); - jsonTransformationDTO.setArtifactId(artifact.getId()); + jsonTransformationDTO.setBaseArtifactId(artifact.getId()); jsonTransformationDTO.setRepoName(repoName); jsonTransformationDTO.setArtifactType(artifactType); @@ -468,7 +483,7 @@ public Mono connectArtifactToGit( ArtifactJsonTransformationDTO jsonTransformationDTO = new ArtifactJsonTransformationDTO(); jsonTransformationDTO.setWorkspaceId(artifact.getWorkspaceId()); - jsonTransformationDTO.setArtifactId(artifact.getId()); + jsonTransformationDTO.setBaseArtifactId(artifact.getId()); jsonTransformationDTO.setRepoName(repoName); jsonTransformationDTO.setArtifactType(artifactType); @@ -524,7 +539,7 @@ public Mono connectArtifactToGit( .flatMap(artifact -> { ArtifactJsonTransformationDTO jsonTransformationDTO = new ArtifactJsonTransformationDTO(); jsonTransformationDTO.setWorkspaceId(artifact.getWorkspaceId()); - jsonTransformationDTO.setArtifactId(artifact.getId()); + jsonTransformationDTO.setBaseArtifactId(artifact.getId()); jsonTransformationDTO.setArtifactType(artifactType); jsonTransformationDTO.setRepoName(repoName); @@ -556,10 +571,10 @@ public Mono connectArtifactToGit( commitDTO.setIsAmendCommit(FALSE); commitDTO.setMessage(commitMessage); - return this.commitArtifact(baseArtifactId, commitDTO, artifactType, gitType) + return this.commitArtifact(commitDTO, artifact.getId(), artifactType, gitType) .onErrorResume(error -> // If the push fails remove all the cloned files from local repo - this.detachRemote(baseArtifactId, artifactType) + this.detachRemote(baseArtifactId, artifactType, gitType) .flatMap(isDeleted -> { if (error instanceof TransportException) { return gitAnalyticsUtils @@ -592,23 +607,329 @@ public Mono connectArtifactToGit( sink -> connectedArtifactMono.subscribe(sink::success, sink::error, null, sink.currentContext())); } - /** - * TODO: commit artifact - * @return - */ - public Mono commitArtifact( - String baseArtifactId, CommitDTO commitDTO, ArtifactType artifactType, GitType gitType) { - return null; + @Override + public Mono commitArtifact( + CommitDTO commitDTO, String branchedArtifactId, ArtifactType artifactType, GitType gitType) { + return commitArtifact(commitDTO, branchedArtifactId, artifactType, gitType, TRUE); + } + + public Mono commitArtifact( + CommitDTO commitDTO, + String branchedArtifactId, + ArtifactType artifactType, + GitType gitType, + Boolean isFileLock) { + /* + 1. Check if application exists and user have sufficient permissions + 2. Check if branch name exists in git metadata + 3. Save application to the existing local repo + 4. Commit application : git add, git commit (Also check if git init required) + */ + + String commitMessage = commitDTO.getMessage(); + + if (commitMessage == null || commitMessage.isEmpty()) { + commitDTO.setMessage(DEFAULT_COMMIT_MESSAGE + GitDefaultCommitMessage.CONNECT_FLOW.getReason()); + } + + GitArtifactHelper gitArtifactHelper = gitArtifactHelperResolver.getArtifactHelper(artifactType); + AclPermission artifactEditPermission = gitArtifactHelper.getArtifactEditPermission(); + Mono> baseAndBranchedArtifactMono = getBaseAndBranchedArtifacts( + branchedArtifactId, artifactType, artifactEditPermission) + .cache(); + + return baseAndBranchedArtifactMono.flatMap(artifactTuples -> { + Artifact baseArtifact = artifactTuples.getT1(); + Artifact branchedArtifact = artifactTuples.getT2(); + + GitUser author = commitDTO.getAuthor(); + Mono gitUserMono = Mono.justOrEmpty(author) + .flatMap(gitUser -> { + if (author == null + || !StringUtils.hasText(author.getEmail()) + || !StringUtils.hasText(author.getName())) { + return getGitUserForArtifactId(baseArtifact.getId()); + } + + return Mono.just(gitUser); + }) + .switchIfEmpty(getGitUserForArtifactId(baseArtifact.getId())); + + return gitUserMono.flatMap(gitUser -> { + commitDTO.setAuthor(gitUser); + commitDTO.setCommitter(gitUser); + return commitArtifact(commitDTO, baseArtifact, branchedArtifact, gitType, isFileLock); + }); + }); + } + + private Mono commitArtifact( + CommitDTO commitDTO, + Artifact baseArtifact, + Artifact branchedArtifact, + GitType gitType, + boolean isFileLock) { + + String commitMessage = commitDTO.getMessage(); + + if (commitMessage == null || commitMessage.isEmpty()) { + commitDTO.setMessage(DEFAULT_COMMIT_MESSAGE + GitDefaultCommitMessage.CONNECT_FLOW.getReason()); + } + + GitUser author = commitDTO.getAuthor(); + if (author == null || !StringUtils.hasText(author.getEmail()) || !StringUtils.hasText(author.getName())) { + + String errorMessage = "Unable to find git author configuration for logged-in user. You can set " + + "up a git profile from the user profile section."; + + return gitAnalyticsUtils + .addAnalyticsForGitOperation( + AnalyticsEvents.GIT_COMMIT, + branchedArtifact, + AppsmithError.INVALID_GIT_CONFIGURATION.getErrorType(), + AppsmithError.INVALID_GIT_CONFIGURATION.getMessage(errorMessage), + branchedArtifact.getGitArtifactMetadata().getIsRepoPrivate()) + .then(Mono.error(new AppsmithException(AppsmithError.INVALID_GIT_CONFIGURATION, errorMessage))); + } + + boolean isSystemGenerated = commitDTO.getMessage().contains(DEFAULT_COMMIT_MESSAGE); + + GitArtifactHelper gitArtifactHelper = + gitArtifactHelperResolver.getArtifactHelper(baseArtifact.getArtifactType()); + GitHandlingService gitHandlingService = gitHandlingServiceResolver.getGitHandlingService(gitType); + GitArtifactMetadata baseGitMetadata = baseArtifact.getGitArtifactMetadata(); + GitArtifactMetadata branchedGitMetadata = branchedArtifact.getGitArtifactMetadata(); + + if (isBaseGitMetadataInvalid(baseGitMetadata, gitType)) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_GIT_CONFIGURATION, GIT_CONFIG_ERROR)); + } + + if (branchedGitMetadata == null) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_GIT_CONFIGURATION, GIT_CONFIG_ERROR)); + } + + final String branchName = branchedGitMetadata.getBranchName(); + if (!hasText(branchName)) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, FieldName.BRANCH_NAME)); + } + + Mono isBranchProtectedMono = gitPrivateRepoHelper.isBranchProtected(baseGitMetadata, branchName); + Mono commitMono = isBranchProtectedMono + .flatMap(isBranchProtected -> { + if (!TRUE.equals(isBranchProtected)) { + return gitRedisUtils.acquireGitLock( + baseGitMetadata.getDefaultArtifactId(), + GitConstants.GitCommandConstants.COMMIT, + isFileLock); + } + + return Mono.error(new AppsmithException( + AppsmithError.GIT_ACTION_FAILED, + "commit", + "Cannot commit to protected branch " + branchName)); + }) + .flatMap(fileLocked -> { + // Check if the repo is public for current artifact and if the user have changed the access after + // the connection + + return gitHandlingService.isRepoPrivate(baseGitMetadata).flatMap(isPrivate -> { + // Check the repo limit if the visibility status is updated, or it is private + // TODO: split both of these conditions @Manish + if (isPrivate.equals(baseGitMetadata.getIsRepoPrivate() && !Boolean.TRUE.equals(isPrivate))) { + return Mono.just(baseArtifact); + } + + baseGitMetadata.setIsRepoPrivate(isPrivate); + baseArtifact.setGitArtifactMetadata(baseGitMetadata); + + /** + * A separate GitAuth object has been created in which the private key for + * authentication is held. It's done to avoid getting the encrypted value back + * for private key after mongo save. + * + * When an object having an encrypted attribute is saved, the response is still encrypted. + * The value in db would be corrupted if it's saved again, + * as it would encrypt and already encrypted field + * Private key is using encrypted annotation, which means that it's encrypted before + * being persisted in the db. When it's fetched from db, the listener decrypts it. + */ + GitAuth copiedGitAuth = new GitAuth(); + copyNestedNonNullProperties(baseGitMetadata.getGitAuth(), copiedGitAuth); + + return gitArtifactHelper + .saveArtifact(baseArtifact) + .map(artifact -> { + baseArtifact.getGitArtifactMetadata().setGitAuth(copiedGitAuth); + return artifact; + }) + .then(Mono.defer( + () -> gitArtifactHelper.isPrivateRepoLimitReached(baseArtifact, false))); + }); + }) + .flatMap(artifact -> { + String errorEntity = ""; + if (!StringUtils.hasText(branchedGitMetadata.getBranchName())) { + errorEntity = "branch name"; + } else if (!StringUtils.hasText(branchedGitMetadata.getDefaultArtifactId())) { + errorEntity = "default artifact"; + } else if (!StringUtils.hasText(branchedGitMetadata.getRepoName())) { + errorEntity = "repository name"; + } + + if (!errorEntity.isEmpty()) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_GIT_CONFIGURATION, "Unable to find " + errorEntity)); + } + + return exportService.exportByArtifactId( + branchedArtifact.getId(), VERSION_CONTROL, branchedArtifact.getArtifactType()); + }) + .flatMap(artifactExchangeJson -> { + ArtifactJsonTransformationDTO jsonTransformationDTO = new ArtifactJsonTransformationDTO(); + jsonTransformationDTO.setRefType(RefType.BRANCH); + jsonTransformationDTO.setWorkspaceId(baseArtifact.getWorkspaceId()); + jsonTransformationDTO.setBaseArtifactId(baseArtifact.getId()); + jsonTransformationDTO.setRepoName( + branchedArtifact.getGitArtifactMetadata().getRepoName()); + jsonTransformationDTO.setArtifactType(artifactExchangeJson.getArtifactJsonType()); + jsonTransformationDTO.setRefName( + branchedArtifact.getGitArtifactMetadata().getBranchName()); + + return gitHandlingService + .prepareChangesToBeCommitted(jsonTransformationDTO, artifactExchangeJson) + .then(updateArtifactWithGitMetadataGivenPermission(branchedArtifact, branchedGitMetadata)); + }) + .flatMap(updatedBranchedArtifact -> { + GitArtifactMetadata gitArtifactMetadata = updatedBranchedArtifact.getGitArtifactMetadata(); + ArtifactJsonTransformationDTO jsonTransformationDTO = new ArtifactJsonTransformationDTO(); + jsonTransformationDTO.setRefType(RefType.BRANCH); + jsonTransformationDTO.setWorkspaceId(updatedBranchedArtifact.getWorkspaceId()); + jsonTransformationDTO.setBaseArtifactId(gitArtifactMetadata.getDefaultArtifactId()); + jsonTransformationDTO.setRepoName(gitArtifactMetadata.getRepoName()); + jsonTransformationDTO.setArtifactType(branchedArtifact.getArtifactType()); + jsonTransformationDTO.setRefName(gitArtifactMetadata.getBranchName()); + + return gitHandlingService + .commitArtifact(updatedBranchedArtifact, commitDTO, jsonTransformationDTO) + .onErrorResume(error -> { + return gitAnalyticsUtils + .addAnalyticsForGitOperation( + AnalyticsEvents.GIT_COMMIT, + updatedBranchedArtifact, + error.getClass().getName(), + error.getMessage(), + gitArtifactMetadata.getIsRepoPrivate()) + .then(Mono.error(new AppsmithException( + AppsmithError.GIT_ACTION_FAILED, "commit", error.getMessage()))); + }); + }) + .flatMap(tuple2 -> { + return Mono.zip( + Mono.just(tuple2.getT2()), gitArtifactHelper.publishArtifactPostCommit(tuple2.getT1())); + }) + .flatMap(tuple -> { + String status = tuple.getT1(); + Artifact artifactFromBranch = tuple.getT2(); + Mono releaseFileLockMono = gitRedisUtils.releaseFileLock( + artifactFromBranch.getGitArtifactMetadata().getDefaultArtifactId(), isFileLock); + + Mono updatedArtifactMono = + gitArtifactHelper.updateArtifactWithSchemaVersions(artifactFromBranch); + + return Mono.zip(updatedArtifactMono, releaseFileLockMono) + .then(gitAnalyticsUtils.addAnalyticsForGitOperation( + AnalyticsEvents.GIT_COMMIT, + artifactFromBranch, + "", + "", + artifactFromBranch.getGitArtifactMetadata().getIsRepoPrivate(), + isSystemGenerated)) + .thenReturn(status) + .name(OPS_COMMIT) + .tap(Micrometer.observation(observationRegistry)); + }); + + return Mono.create(sink -> { + commitMono.subscribe(sink::success, sink::error, null, sink.currentContext()); + }); } /** - * TODO: implementation quite similar to the disconnectGitRepo - * @param baseArtifactId - * @param artifactType - * @return + * Method to remove all the git metadata for the artifact and connected resources. This will remove: + * - local repo + * - all the branched applications present in DB except for default application + * + * @param branchedArtifactId : id of any branched artifact for the given repo + * @param artifactType : type of artifact + * @return : the base artifact after removal of git flow. */ - protected Mono detachRemote(String baseArtifactId, ArtifactType artifactType) { - return null; + public Mono detachRemote( + String branchedArtifactId, ArtifactType artifactType, GitType gitType) { + GitHandlingService gitHandlingService = gitHandlingServiceResolver.getGitHandlingService(gitType); + GitArtifactHelper gitArtifactHelper = gitArtifactHelperResolver.getArtifactHelper(artifactType); + AclPermission gitConnectPermission = gitArtifactHelper.getArtifactGitConnectPermission(); + + Mono> baseAndBranchedArtifactMono = + getBaseAndBranchedArtifacts(branchedArtifactId, artifactType, gitConnectPermission); + + Mono disconnectMono = baseAndBranchedArtifactMono + .flatMap(artifactTuples -> { + Artifact baseArtifact = artifactTuples.getT1(); + + if (isBaseGitMetadataInvalid(baseArtifact.getGitArtifactMetadata(), gitType)) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_GIT_CONFIGURATION, + "Please reconfigure the artifact to connect to git repo")); + } + + GitArtifactMetadata gitArtifactMetadata = baseArtifact.getGitArtifactMetadata(); + ArtifactJsonTransformationDTO jsonTransformationDTO = new ArtifactJsonTransformationDTO(); + jsonTransformationDTO.setRefType(RefType.BRANCH); + jsonTransformationDTO.setWorkspaceId(baseArtifact.getWorkspaceId()); + jsonTransformationDTO.setBaseArtifactId(gitArtifactMetadata.getDefaultArtifactId()); + jsonTransformationDTO.setRepoName(gitArtifactMetadata.getRepoName()); + jsonTransformationDTO.setArtifactType(baseArtifact.getArtifactType()); + jsonTransformationDTO.setRefName(gitArtifactMetadata.getBranchName()); + + // Remove the git contents from file system + return Mono.zip(gitHandlingService.listBranches(jsonTransformationDTO), Mono.just(baseArtifact)); + }) + .flatMap(tuple -> { + List localBranches = tuple.getT1(); + Artifact baseArtifact = tuple.getT2(); + + baseArtifact.setGitArtifactMetadata(null); + gitArtifactHelper.resetAttributeInBaseArtifact(baseArtifact); + + GitArtifactMetadata gitArtifactMetadata = baseArtifact.getGitArtifactMetadata(); + ArtifactJsonTransformationDTO jsonTransformationDTO = new ArtifactJsonTransformationDTO(); + jsonTransformationDTO.setRefType(RefType.BRANCH); + jsonTransformationDTO.setWorkspaceId(baseArtifact.getWorkspaceId()); + jsonTransformationDTO.setBaseArtifactId(gitArtifactMetadata.getDefaultArtifactId()); + jsonTransformationDTO.setRepoName(gitArtifactMetadata.getRepoName()); + jsonTransformationDTO.setArtifactType(baseArtifact.getArtifactType()); + jsonTransformationDTO.setRefName(gitArtifactMetadata.getBranchName()); + + // Remove the parent application branch name from the list + Mono removeRepoMono = gitHandlingService.removeRepository(jsonTransformationDTO); + Mono updatedArtifactMono = gitArtifactHelper.saveArtifact(baseArtifact); + + Flux deleteAllBranchesFlux = + gitArtifactHelper.deleteAllBranches(branchedArtifactId, localBranches); + + return Mono.zip(updatedArtifactMono, removeRepoMono, deleteAllBranchesFlux.collectList()) + .map(Tuple3::getT1); + }) + .flatMap(updatedBaseArtifact -> { + return gitArtifactHelper + .disconnectEntitiesOfBaseArtifact(updatedBaseArtifact) + .then(gitAnalyticsUtils.addAnalyticsForGitOperation( + AnalyticsEvents.GIT_DISCONNECT, updatedBaseArtifact, false)); + }) + .name(GitSpan.OPS_DETACH_REMOTE) + .tap(Micrometer.observation(observationRegistry)); + + return Mono.create(sink -> disconnectMono.subscribe(sink::success, sink::error, null, sink.currentContext())); } private boolean isBaseGitMetadataInvalid(GitArtifactMetadata gitArtifactMetadata, GitType gitType) { @@ -618,4 +939,86 @@ private boolean isBaseGitMetadataInvalid(GitArtifactMetadata gitArtifactMetadata .getGitHandlingService(gitType) .isGitAuthInvalid(gitArtifactMetadata.getGitAuth()); } + + /** + * Returns baseArtifact and branchedArtifact + * This operation is quite frequently used, hence providing the right set + * + * @param branchedArtifactId : id of the branchedArtifactId + * @param artifactPermission : permission required for getting artifact. + * @return : A tuple of Artifacts + */ + protected Mono> getBaseAndBranchedArtifacts( + String branchedArtifactId, ArtifactType artifactType, AclPermission artifactPermission) { + if (!hasText(branchedArtifactId)) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, FieldName.ID)); + } + + GitArtifactHelper artifactGitHelper = gitArtifactHelperResolver.getArtifactHelper(artifactType); + Mono branchedArtifactMono = artifactGitHelper + .getArtifactById(branchedArtifactId, artifactPermission) + .cache(); + + return branchedArtifactMono.flatMap(branchedArtifact -> { + GitArtifactMetadata branchedMetadata = branchedArtifact.getGitArtifactMetadata(); + if (branchedMetadata == null || !hasText(branchedMetadata.getDefaultArtifactId())) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_GIT_CONFIGURATION, GIT_CONFIG_ERROR)); + } + + String baseArtifactId = branchedMetadata.getDefaultArtifactId(); + Mono baseArtifactMono = Mono.just(branchedArtifact); + + if (!baseArtifactId.equals(branchedArtifactId)) { + baseArtifactMono = artifactGitHelper.getArtifactById(baseArtifactId, artifactPermission); + } + + return baseArtifactMono.zipWith(branchedArtifactMono); + }); + } + + protected Mono> getBaseAndBranchedArtifacts( + String branchedArtifactId, ArtifactType artifactType) { + GitArtifactHelper gitArtifactHelper = gitArtifactHelperResolver.getArtifactHelper(artifactType); + AclPermission artifactPermission = gitArtifactHelper.getArtifactEditPermission(); + return getBaseAndBranchedArtifacts(branchedArtifactId, artifactType, artifactPermission); + } + + private Mono getGitUserForArtifactId(String baseArtifactId) { + Mono currentUserMono = userDataService + .getForCurrentUser() + .filter(userData -> !CollectionUtils.isEmpty(userData.getGitProfiles())) + .switchIfEmpty( + Mono.error(new AppsmithException(AppsmithError.INVALID_GIT_CONFIGURATION, GIT_PROFILE_ERROR))); + + return currentUserMono.map(userData -> { + GitProfile profile = userData.getGitProfileByKey(baseArtifactId); + if (profile == null + || Boolean.TRUE.equals(profile.getUseGlobalProfile()) + || !StringUtils.hasText(profile.getAuthorName())) { + profile = userData.getGitProfileByKey(DEFAULT); + } + + GitUser gitUser = new GitUser(); + gitUser.setName(profile.getAuthorName()); + gitUser.setEmail(profile.getAuthorEmail()); + return gitUser; + }); + } + + private Mono updateArtifactWithGitMetadataGivenPermission( + Artifact artifact, GitArtifactMetadata gitMetadata) { + + if (gitMetadata == null) { + return Mono.error( + new AppsmithException(AppsmithError.INVALID_PARAMETER, "Git metadata values cannot be null")); + } + + artifact.setGitArtifactMetadata(gitMetadata); + // For default application we expect a GitAuth to be a part of gitMetadata. We are using save method to leverage + // @Encrypted annotation used for private SSH keys + // applicationService.save sets the transient fields so no need to set it again from this method + return gitArtifactHelperResolver + .getArtifactHelper(artifact.getArtifactType()) + .saveArtifact(artifact); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceImpl.java index 49c110d6dca..7c642d832d0 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceImpl.java @@ -2,6 +2,7 @@ import com.appsmith.server.datasources.base.DatasourceService; import com.appsmith.server.exports.internal.ExportService; +import com.appsmith.server.git.GitRedisUtils; import com.appsmith.server.git.resolver.GitArtifactHelperResolver; import com.appsmith.server.git.resolver.GitHandlingServiceResolver; import com.appsmith.server.git.utils.GitAnalyticsUtils; @@ -12,6 +13,7 @@ import com.appsmith.server.services.UserDataService; import com.appsmith.server.services.WorkspaceService; import com.appsmith.server.solutions.DatasourcePermission; +import io.micrometer.observation.ObservationRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -31,7 +33,9 @@ public CentralGitServiceImpl( WorkspaceService workspaceService, PluginService pluginService, ImportService importService, - ExportService exportService) { + ExportService exportService, + GitRedisUtils gitRedisUtils, + ObservationRegistry observationRegistry) { super( gitProfileUtils, gitAnalyticsUtils, @@ -44,6 +48,8 @@ public CentralGitServiceImpl( workspaceService, pluginService, importService, - exportService); + exportService, + gitRedisUtils, + observationRegistry); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/GitHandlingServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/GitHandlingServiceCE.java index 66fc2dcbffb..3b79a0f1795 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/GitHandlingServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/GitHandlingServiceCE.java @@ -8,7 +8,9 @@ import com.appsmith.server.dtos.GitConnectDTO; import com.appsmith.server.git.dtos.ArtifactJsonTransformationDTO; import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; +import java.util.List; import java.util.Set; public interface GitHandlingServiceCE { @@ -19,6 +21,8 @@ public interface GitHandlingServiceCE { Mono isRepoPrivate(GitConnectDTO gitConnectDTO); + Mono isRepoPrivate(GitArtifactMetadata gitArtifactMetadata); + // TODO: modify git auth class for native implementation Mono getGitAuthForUser(); @@ -35,6 +39,8 @@ void setRepositoryDetailsInGitArtifactMetadata( Mono removeRepository(ArtifactJsonTransformationDTO artifactJsonTransformationDTO); + Mono> listBranches(ArtifactJsonTransformationDTO artifactJsonTransformationDTO); + Mono validateEmptyRepository(ArtifactJsonTransformationDTO artifactJsonTransformationDTO); Mono initialiseReadMe( @@ -44,4 +50,10 @@ Mono initialiseReadMe( String originHeader); Mono createFirstCommit(ArtifactJsonTransformationDTO jsonTransformationDTO, CommitDTO commitDTO); + + Mono prepareChangesToBeCommitted( + ArtifactJsonTransformationDTO jsonTransformationDTO, ArtifactExchangeJson artifactExchangeJson); + + Mono> commitArtifact( + Artifact branchedArtifact, CommitDTO commitDTO, ArtifactJsonTransformationDTO jsonTransformationDTO); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/dtos/ArtifactJsonTransformationDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/dtos/ArtifactJsonTransformationDTO.java index 899a545b28b..8000d539c75 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/dtos/ArtifactJsonTransformationDTO.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/dtos/ArtifactJsonTransformationDTO.java @@ -15,7 +15,7 @@ public class ArtifactJsonTransformationDTO { String workspaceId; - String artifactId; + String baseArtifactId; String repoName; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/fs/GitFSServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/fs/GitFSServiceCEImpl.java index 563a34b15b3..a65c46733cf 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/git/fs/GitFSServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/git/fs/GitFSServiceCEImpl.java @@ -1,10 +1,14 @@ package com.appsmith.server.git.fs; import com.appsmith.external.constants.AnalyticsEvents; +import com.appsmith.external.dtos.GitBranchDTO; +import com.appsmith.external.git.constants.GitConstants; import com.appsmith.external.git.constants.GitSpan; import com.appsmith.external.git.handler.FSGitHandler; import com.appsmith.git.dto.CommitDTO; +import com.appsmith.server.acl.AclPermission; import com.appsmith.server.configurations.EmailConfig; +import com.appsmith.server.constants.ArtifactType; import com.appsmith.server.datasources.base.DatasourceService; import com.appsmith.server.domains.Artifact; import com.appsmith.server.domains.GitArtifactMetadata; @@ -38,20 +42,28 @@ import io.micrometer.observation.ObservationRegistry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.errors.EmptyCommitException; import org.eclipse.jgit.api.errors.InvalidRemoteException; import org.eclipse.jgit.api.errors.TransportException; +import org.eclipse.jgit.errors.RepositoryNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.reactive.TransactionalOperator; import org.springframework.util.StringUtils; import reactor.core.observability.micrometer.Micrometer; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; import java.io.IOException; import java.nio.file.Path; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.concurrent.TimeoutException; +import static com.appsmith.external.git.constants.ce.GitConstantsCE.EMPTY_COMMIT_ERROR_MESSAGE; +import static com.appsmith.external.git.constants.ce.GitConstantsCE.GIT_CONFIG_ERROR; + @Slf4j @Service @RequiredArgsConstructor @@ -140,7 +152,16 @@ public String getRepoName(GitConnectDTO gitConnectDTO) { @Override public Mono isRepoPrivate(GitConnectDTO gitConnectDTO) { - return GitUtils.isRepoPrivate(GitUtils.convertSshUrlToBrowserSupportedUrl(gitConnectDTO.getRemoteUrl())); + return isRepoPrivate(gitConnectDTO.getRemoteUrl()); + } + + @Override + public Mono isRepoPrivate(GitArtifactMetadata gitArtifactMetadata) { + return isRepoPrivate(gitArtifactMetadata.getRemoteUrl()); + } + + private Mono isRepoPrivate(String remoteUrl) { + return GitUtils.isRepoPrivate(GitUtils.convertSshUrlToBrowserSupportedUrl(remoteUrl)); } @Override @@ -213,7 +234,7 @@ public Mono reconstructArtifactJsonFromGitReposi ArtifactJsonTransformationDTO artifactJsonTransformationDTO) { return commonGitFileUtils.reconstructArtifactExchangeJsonFromGitRepoWithAnalytics( artifactJsonTransformationDTO.getWorkspaceId(), - artifactJsonTransformationDTO.getArtifactId(), + artifactJsonTransformationDTO.getBaseArtifactId(), artifactJsonTransformationDTO.getRepoName(), artifactJsonTransformationDTO.getRefName(), artifactJsonTransformationDTO.getArtifactType()); @@ -225,18 +246,44 @@ public Mono removeRepository(ArtifactJsonTransformationDTO artifactJson gitArtifactHelperResolver.getArtifactHelper(artifactJsonTransformationDTO.getArtifactType()); Path repoSuffix = gitArtifactHelper.getRepoSuffixPath( artifactJsonTransformationDTO.getWorkspaceId(), - artifactJsonTransformationDTO.getArtifactId(), + artifactJsonTransformationDTO.getBaseArtifactId(), artifactJsonTransformationDTO.getRepoName()); return commonGitFileUtils.deleteLocalRepo(repoSuffix); } + /** + * List all the local branches present in the file system + * @param artifactJsonTransformationDTO + * @return + */ + @Override + public Mono> listBranches(ArtifactJsonTransformationDTO artifactJsonTransformationDTO) { + GitArtifactHelper gitArtifactHelper = + gitArtifactHelperResolver.getArtifactHelper(artifactJsonTransformationDTO.getArtifactType()); + + Path repoSuffix = gitArtifactHelper.getRepoSuffixPath( + artifactJsonTransformationDTO.getWorkspaceId(), + artifactJsonTransformationDTO.getBaseArtifactId(), + artifactJsonTransformationDTO.getRepoName()); + + return fsGitHandler + .listBranches(repoSuffix) + .flatMapMany(Flux::fromIterable) + .filter(gitBranchDTO -> { + return StringUtils.hasText(gitBranchDTO.getBranchName()) + && !gitBranchDTO.getBranchName().startsWith("origin"); + }) + .map(GitBranchDTO::getBranchName) + .collectList(); + } + @Override public Mono validateEmptyRepository(ArtifactJsonTransformationDTO artifactJsonTransformationDTO) { GitArtifactHelper gitArtifactHelper = gitArtifactHelperResolver.getArtifactHelper(artifactJsonTransformationDTO.getArtifactType()); Path repoSuffix = gitArtifactHelper.getRepoSuffixPath( artifactJsonTransformationDTO.getWorkspaceId(), - artifactJsonTransformationDTO.getArtifactId(), + artifactJsonTransformationDTO.getBaseArtifactId(), artifactJsonTransformationDTO.getRepoName()); try { @@ -257,7 +304,7 @@ public Mono initialiseReadMe( gitArtifactHelperResolver.getArtifactHelper(jsonTransformationDTO.getArtifactType()); Path readmePath = gitArtifactHelper.getRepoSuffixPath( jsonTransformationDTO.getWorkspaceId(), - jsonTransformationDTO.getArtifactId(), + jsonTransformationDTO.getBaseArtifactId(), jsonTransformationDTO.getRepoName()); try { return gitArtifactHelper @@ -275,7 +322,7 @@ public Mono createFirstCommit(ArtifactJsonTransformationDTO jsonTransfor gitArtifactHelperResolver.getArtifactHelper(jsonTransformationDTO.getArtifactType()); Path repoSuffix = gitArtifactHelper.getRepoSuffixPath( jsonTransformationDTO.getWorkspaceId(), - jsonTransformationDTO.getArtifactId(), + jsonTransformationDTO.getBaseArtifactId(), jsonTransformationDTO.getRepoName()); return fsGitHandler.commitArtifact( @@ -286,4 +333,239 @@ public Mono createFirstCommit(ArtifactJsonTransformationDTO jsonTransfor true, commitDTO.getIsAmendCommit()); } + + @Override + public Mono prepareChangesToBeCommitted( + ArtifactJsonTransformationDTO jsonTransformationDTO, ArtifactExchangeJson artifactExchangeJson) { + String workspaceId = jsonTransformationDTO.getWorkspaceId(); + String baseArtifactId = jsonTransformationDTO.getBaseArtifactId(); + String repoName = jsonTransformationDTO.getRepoName(); + String branchName = jsonTransformationDTO.getRefName(); + + ArtifactType artifactType = jsonTransformationDTO.getArtifactType(); + GitArtifactHelper gitArtifactHelper = gitArtifactHelperResolver.getArtifactHelper(artifactType); + Path repoSuffix = gitArtifactHelper.getRepoSuffixPath(workspaceId, baseArtifactId, repoName); + + return commonGitFileUtils + .saveArtifactToLocalRepoWithAnalytics(repoSuffix, artifactExchangeJson, branchName) + .map(ignore -> Boolean.TRUE) + .onErrorResume(e -> { + log.error("Error in commit flow: ", e); + if (e instanceof RepositoryNotFoundException) { + return Mono.error(new AppsmithException(AppsmithError.REPOSITORY_NOT_FOUND, baseArtifactId)); + } else if (e instanceof AppsmithException) { + return Mono.error(e); + } + return Mono.error(new AppsmithException(AppsmithError.GIT_FILE_SYSTEM_ERROR, e.getMessage())); + }); + } + + @Override + public Mono> commitArtifact( + Artifact branchedArtifact, CommitDTO commitDTO, ArtifactJsonTransformationDTO jsonTransformationDTO) { + String workspaceId = jsonTransformationDTO.getWorkspaceId(); + String baseArtifactId = jsonTransformationDTO.getBaseArtifactId(); + String repoName = jsonTransformationDTO.getRepoName(); + + ArtifactType artifactType = jsonTransformationDTO.getArtifactType(); + GitArtifactHelper gitArtifactHelper = gitArtifactHelperResolver.getArtifactHelper(artifactType); + Path repoSuffix = gitArtifactHelper.getRepoSuffixPath(workspaceId, baseArtifactId, repoName); + + StringBuilder result = new StringBuilder(); + result.append("Commit Result : "); + + Mono gitCommitMono = fsGitHandler + .commitArtifact( + repoSuffix, + commitDTO.getMessage(), + commitDTO.getAuthor().getName(), + commitDTO.getAuthor().getEmail(), + true, + false) + .onErrorResume(error -> { + if (error instanceof EmptyCommitException) { + return Mono.just(EMPTY_COMMIT_ERROR_MESSAGE); + } + + return Mono.error(error); + }); + + return Mono.zip(gitCommitMono, gitArtifactHelper.getArtifactById(branchedArtifact.getId(), null)) + .flatMap(tuple -> { + String commitStatus = tuple.getT1(); + result.append(commitStatus); + + result.append(".\nPush Result : "); + return Mono.zip( + Mono.just(tuple.getT2()), + pushArtifact(tuple.getT2(), false) + .map(pushResult -> result.append(pushResult).toString())); + }); + } + + /** + * Used for pushing commits present in the given branched artifact. + * @param branchedArtifactId : id of the branched artifact. + * @param artifactType : type of the artifact + * @return : returns a string which has details of operations + */ + public Mono pushArtifact(String branchedArtifactId, ArtifactType artifactType) { + GitArtifactHelper gitArtifactHelper = gitArtifactHelperResolver.getArtifactHelper(artifactType); + AclPermission artifactEditPermission = gitArtifactHelper.getArtifactEditPermission(); + + return gitArtifactHelper + .getArtifactById(branchedArtifactId, artifactEditPermission) + .flatMap(branchedArtifact -> pushArtifact(branchedArtifact, true)); + } + + /** + * Push flow for dehydrated apps + * + * @param branchedArtifact application which needs to be pushed to remote repo + * @return Success message + */ + protected Mono pushArtifact(Artifact branchedArtifact, boolean isFileLock) { + GitArtifactHelper gitArtifactHelper = + gitArtifactHelperResolver.getArtifactHelper(branchedArtifact.getArtifactType()); + Mono gitArtifactMetadataMono = Mono.just(branchedArtifact.getGitArtifactMetadata()); + + if (!branchedArtifact + .getId() + .equals(branchedArtifact.getGitArtifactMetadata().getDefaultArtifactId())) { + gitArtifactMetadataMono = gitArtifactHelper + .getArtifactById(branchedArtifact.getGitArtifactMetadata().getDefaultArtifactId(), null) + .map(baseArtifact -> { + branchedArtifact + .getGitArtifactMetadata() + .setGitAuth( + baseArtifact.getGitArtifactMetadata().getGitAuth()); + return branchedArtifact.getGitArtifactMetadata(); + }); + } + + // Make sure that ssh Key is unEncrypted for the use. + Mono gitPushResult = gitArtifactMetadataMono + .flatMap(gitMetadata -> { + return gitRedisUtils + .acquireGitLock( + gitMetadata.getDefaultArtifactId(), + GitConstants.GitCommandConstants.PUSH, + isFileLock) + .thenReturn(branchedArtifact); + }) + .flatMap(artifact -> { + GitArtifactMetadata gitData = artifact.getGitArtifactMetadata(); + + if (gitData == null + || !StringUtils.hasText(gitData.getBranchName()) + || !StringUtils.hasText(gitData.getDefaultArtifactId()) + || !StringUtils.hasText(gitData.getGitAuth().getPrivateKey())) { + + return Mono.error( + new AppsmithException(AppsmithError.INVALID_GIT_CONFIGURATION, GIT_CONFIG_ERROR)); + } + + Path baseRepoSuffix = gitArtifactHelper.getRepoSuffixPath( + artifact.getWorkspaceId(), gitData.getDefaultArtifactId(), gitData.getRepoName()); + GitAuth gitAuth = gitData.getGitAuth(); + + return fsGitHandler + .checkoutToBranch( + baseRepoSuffix, + artifact.getGitArtifactMetadata().getBranchName()) + .then(Mono.defer(() -> fsGitHandler + .pushApplication( + baseRepoSuffix, + gitData.getRemoteUrl(), + gitAuth.getPublicKey(), + gitAuth.getPrivateKey(), + gitData.getBranchName()) + .zipWith(Mono.just(artifact)))) + .onErrorResume(error -> gitAnalyticsUtils + .addAnalyticsForGitOperation( + AnalyticsEvents.GIT_PUSH, + artifact, + error.getClass().getName(), + error.getMessage(), + artifact.getGitArtifactMetadata().getIsRepoPrivate()) + .flatMap(application1 -> { + if (error instanceof TransportException) { + return Mono.error( + new AppsmithException(AppsmithError.INVALID_GIT_SSH_CONFIGURATION)); + } + return Mono.error(new AppsmithException( + AppsmithError.GIT_ACTION_FAILED, "push", error.getMessage())); + })); + }) + .flatMap(tuple -> { + String pushResult = tuple.getT1(); + Artifact artifact = tuple.getT2(); + return pushArtifactErrorRecovery(pushResult, artifact).zipWith(Mono.just(artifact)); + }) + // Add BE analytics + .flatMap(tuple2 -> { + String pushStatus = tuple2.getT1(); + Artifact artifact = tuple2.getT2(); + Mono fileLockReleasedMono = Mono.just(Boolean.TRUE).flatMap(flag -> { + if (!Boolean.TRUE.equals(isFileLock)) { + return Mono.just(flag); + } + return Mono.defer(() -> releaseFileLock( + artifact.getGitArtifactMetadata().getDefaultArtifactId())); + }); + + return pushArtifactErrorRecovery(pushStatus, artifact) + .then(fileLockReleasedMono) + .then(gitAnalyticsUtils.addAnalyticsForGitOperation( + AnalyticsEvents.GIT_PUSH, + artifact, + artifact.getGitArtifactMetadata().getIsRepoPrivate())) + .thenReturn(pushStatus); + }) + .name(GitSpan.OPS_PUSH) + .tap(Micrometer.observation(observationRegistry)); + + return Mono.create(sink -> gitPushResult.subscribe(sink::success, sink::error, null, sink.currentContext())); + } + + /** + * This method is used to recover from the errors that can occur during the push operation + * Mostly happens when the remote branch is protected or any specific rules in place on the branch. + * Since the users will be in a bad state where the changes are committed locally, but they are + * not able to push them changes or revert the changes either. + * 1. Push rejected due to branch protection rules on remote, reset hard prev commit + * + * @param pushResult status of git push operation + * @param artifact artifact data to be used for analytics + * @return status of the git push flow + */ + private Mono pushArtifactErrorRecovery(String pushResult, Artifact artifact) { + GitArtifactMetadata gitMetadata = artifact.getGitArtifactMetadata(); + GitArtifactHelper gitArtifactHelper = + gitArtifactHelperResolver.getArtifactHelper(artifact.getArtifactType()); + + if (pushResult.contains("REJECTED_NONFASTFORWARD")) { + return gitAnalyticsUtils + .addAnalyticsForGitOperation( + AnalyticsEvents.GIT_PUSH, + artifact, + AppsmithError.GIT_UPSTREAM_CHANGES.getErrorType(), + AppsmithError.GIT_UPSTREAM_CHANGES.getMessage(), + gitMetadata.getIsRepoPrivate()) + .flatMap(application1 -> Mono.error(new AppsmithException(AppsmithError.GIT_UPSTREAM_CHANGES))); + } else if (pushResult.contains("REJECTED_OTHERREASON") || pushResult.contains("pre-receive hook declined")) { + + Path path = gitArtifactHelper.getRepoSuffixPath( + artifact.getWorkspaceId(), gitMetadata.getDefaultArtifactId(), gitMetadata.getRepoName()); + + return fsGitHandler + .resetHard(path, gitMetadata.getBranchName()) + .then(Mono.error(new AppsmithException( + AppsmithError.GIT_ACTION_FAILED, + "push", + "Unable to push changes as pre-receive hook declined. Please make sure that you don't have any rules enabled on the branch " + + gitMetadata.getBranchName()))); + } + return Mono.just(pushResult); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/CommonGitFileUtils.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/CommonGitFileUtils.java index ecb77dd8711..90c512060d3 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/CommonGitFileUtils.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/CommonGitFileUtils.java @@ -10,6 +10,7 @@ import com.appsmith.server.newactions.base.NewActionService; import com.appsmith.server.services.AnalyticsService; import com.appsmith.server.services.SessionUserService; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Import; import org.springframework.stereotype.Component; @@ -27,7 +28,8 @@ public CommonGitFileUtils( SessionUserService sessionUserService, NewActionService newActionService, ActionCollectionService actionCollectionService, - JsonSchemaVersions jsonSchemaVersions) { + JsonSchemaVersions jsonSchemaVersions, + ObjectMapper objectMapper) { super( applicationGitFileUtils, fileUtils, @@ -36,6 +38,7 @@ public CommonGitFileUtils( sessionUserService, newActionService, actionCollectionService, - jsonSchemaVersions); + jsonSchemaVersions, + objectMapper); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/ArtifactGitFileUtilsCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/ArtifactGitFileUtilsCE.java index 0af8ce93235..f92820c047a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/ArtifactGitFileUtilsCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/ArtifactGitFileUtilsCE.java @@ -13,6 +13,8 @@ public interface ArtifactGitFileUtilsCE { T createArtifactReferenceObject(); + ArtifactExchangeJson createArtifactExchangeJsonObject(); + void setArtifactDependentResources(ArtifactExchangeJson artifactExchangeJson, GitResourceMap gitResourceMap); Mono reconstructArtifactExchangeJsonFromFilesInRepository( @@ -24,4 +26,6 @@ void addArtifactReferenceFromExportedJson( Map getConstantsMap(); Path getRepoSuffixPath(String workspaceId, String artifactId, String repoName, @NonNull String... args); + + void setArtifactDependentPropertiesInJson(GitResourceMap gitResourceMap, ArtifactExchangeJson artifactExchangeJson); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/CommonGitFileUtilsCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/CommonGitFileUtilsCE.java index e0955455d6d..486406d6422 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/CommonGitFileUtilsCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/CommonGitFileUtilsCE.java @@ -7,6 +7,8 @@ import com.appsmith.external.git.models.GitResourceType; import com.appsmith.external.git.operations.FileOperations; import com.appsmith.external.helpers.Stopwatch; +import com.appsmith.external.models.ActionConfiguration; +import com.appsmith.external.models.ActionDTO; import com.appsmith.external.models.ApplicationGitReference; import com.appsmith.external.models.ArtifactGitReference; import com.appsmith.external.models.BaseDomain; @@ -22,6 +24,7 @@ import com.appsmith.server.domains.GitArtifactMetadata; import com.appsmith.server.domains.NewAction; import com.appsmith.server.domains.Theme; +import com.appsmith.server.dtos.ActionCollectionDTO; import com.appsmith.server.dtos.ApplicationJson; import com.appsmith.server.dtos.ArtifactExchangeJson; import com.appsmith.server.dtos.PageDTO; @@ -32,10 +35,12 @@ import com.appsmith.server.newactions.base.NewActionService; import com.appsmith.server.services.AnalyticsService; import com.appsmith.server.services.SessionUserService; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.eclipse.jgit.api.errors.GitAPIException; import org.json.JSONObject; @@ -52,19 +57,30 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collector; +import java.util.stream.Collectors; import static com.appsmith.external.git.constants.ce.GitConstantsCE.GitCommandConstantsCE.CHECKOUT_BRANCH; import static com.appsmith.external.git.constants.ce.GitConstantsCE.RECONSTRUCT_PAGE; import static com.appsmith.git.constants.CommonConstants.CLIENT_SCHEMA_VERSION; +import static com.appsmith.git.constants.CommonConstants.DELIMITER_PATH; import static com.appsmith.git.constants.CommonConstants.FILE_FORMAT_VERSION; import static com.appsmith.git.constants.CommonConstants.JSON_EXTENSION; +import static com.appsmith.git.constants.CommonConstants.JS_EXTENSION; +import static com.appsmith.git.constants.CommonConstants.METADATA; import static com.appsmith.git.constants.CommonConstants.SERVER_SCHEMA_VERSION; +import static com.appsmith.git.constants.CommonConstants.TEXT_FILE_EXTENSION; import static com.appsmith.git.constants.CommonConstants.THEME; +import static com.appsmith.git.constants.ce.GitDirectoriesCE.ACTION_COLLECTION_DIRECTORY; +import static com.appsmith.git.constants.ce.GitDirectoriesCE.ACTION_DIRECTORY; +import static com.appsmith.git.constants.ce.GitDirectoriesCE.DATASOURCE_DIRECTORY; +import static com.appsmith.git.constants.ce.GitDirectoriesCE.JS_LIB_DIRECTORY; +import static com.appsmith.git.constants.ce.GitDirectoriesCE.PAGE_DIRECTORY; import static com.appsmith.git.files.FileUtilsCEImpl.getJsLibFileName; import static org.springframework.util.StringUtils.hasText; @Slf4j -@RequiredArgsConstructor @Component @Import({FileUtilsImpl.class}) public class CommonGitFileUtilsCE { @@ -83,6 +99,28 @@ public class CommonGitFileUtilsCE { public final int INDEX_LOCK_FILE_STALE_TIME = 300; private final JsonSchemaVersions jsonSchemaVersions; + protected final ObjectMapper objectMapper; + + public CommonGitFileUtilsCE( + ArtifactGitFileUtils applicationGitFileUtils, + FileInterface fileUtils, + FileOperations fileOperations, + AnalyticsService analyticsService, + SessionUserService sessionUserService, + NewActionService newActionService, + ActionCollectionService actionCollectionService, + JsonSchemaVersions jsonSchemaVersions, + ObjectMapper objectMapper) { + this.applicationGitFileUtils = applicationGitFileUtils; + this.fileUtils = fileUtils; + this.fileOperations = fileOperations; + this.analyticsService = analyticsService; + this.sessionUserService = sessionUserService; + this.newActionService = newActionService; + this.actionCollectionService = actionCollectionService; + this.jsonSchemaVersions = jsonSchemaVersions; + this.objectMapper = objectMapper.copy().disable(MapperFeature.USE_ANNOTATIONS); + } private ArtifactGitFileUtils getArtifactBasedFileHelper(ArtifactType artifactType) { if (ArtifactType.APPLICATION.equals(artifactType)) { @@ -120,6 +158,19 @@ public Mono saveArtifactToLocalRepo( } } + public Mono saveArtifactToLocalRepoNew( + Path baseRepoSuffix, ArtifactExchangeJson artifactExchangeJson, String branchName) + throws IOException, GitAPIException { + + // this should come from the specific files + GitResourceMap gitResourceMap = createGitResourceMap(artifactExchangeJson); + + // Save application to git repo + return fileUtils + .saveArtifactToGitRepo(baseRepoSuffix, gitResourceMap, branchName) + .subscribeOn(Schedulers.boundedElastic()); + } + public Mono saveArtifactToLocalRepoWithAnalytics( Path baseRepoSuffix, ArtifactExchangeJson artifactExchangeJson, String branchName) { @@ -216,8 +267,9 @@ protected void setArtifactIndependentResources( if (datasourceList != null) { datasourceList.forEach(datasource -> { removeUnwantedFieldsFromDatasource(datasource); + final String filePath = DATASOURCE_DIRECTORY + DELIMITER_PATH + datasource.getName() + JSON_EXTENSION; GitResourceIdentity identity = - new GitResourceIdentity(GitResourceType.DATASOURCE_CONFIG, datasource.getGitSyncId()); + new GitResourceIdentity(GitResourceType.DATASOURCE_CONFIG, datasource.getGitSyncId(), filePath); resourceMap.put(identity, datasource); }); } @@ -230,7 +282,8 @@ protected void setArtifactIndependentResources( artifactExchangeJson.setThemes(theme, null); // Remove internal fields from the themes removeUnwantedFieldsFromBaseDomain(theme); - GitResourceIdentity identity = new GitResourceIdentity(GitResourceType.ROOT_CONFIG, THEME + JSON_EXTENSION); + final String filePath = THEME + JSON_EXTENSION; + GitResourceIdentity identity = new GitResourceIdentity(GitResourceType.ROOT_CONFIG, filePath, filePath); resourceMap.put(identity, theme); } @@ -240,7 +293,9 @@ protected void setArtifactIndependentResources( customJSLibList.forEach(jsLib -> { removeUnwantedFieldsFromBaseDomain(jsLib); String jsLibFileName = getJsLibFileName(jsLib.getUidString()); - GitResourceIdentity identity = new GitResourceIdentity(GitResourceType.JSLIB_CONFIG, jsLibFileName); + final String filePath = JS_LIB_DIRECTORY + DELIMITER_PATH + jsLibFileName + JSON_EXTENSION; + GitResourceIdentity identity = + new GitResourceIdentity(GitResourceType.JSLIB_CONFIG, jsLibFileName, filePath); resourceMap.put(identity, jsLib); }); } @@ -265,58 +320,47 @@ protected void setNewActionsInResourceMap( .peek(newAction -> newActionService.generateActionByViewMode(newAction, false)) .forEach(newAction -> { removeUnwantedFieldFromAction(newAction); - String body = newAction.getUnpublishedAction().getActionConfiguration() != null - && newAction - .getUnpublishedAction() - .getActionConfiguration() - .getBody() - != null - ? newAction - .getUnpublishedAction() - .getActionConfiguration() - .getBody() - : ""; + ActionDTO action = newAction.getUnpublishedAction(); + final String actionFileName = action.getValidName().replace(".", "-"); + final String filePathPrefix = PAGE_DIRECTORY + + DELIMITER_PATH + + action.calculateContextId() + + DELIMITER_PATH + + ACTION_DIRECTORY + + DELIMITER_PATH + + actionFileName + + DELIMITER_PATH; + String body = action.getActionConfiguration() != null + && action.getActionConfiguration().getBody() != null + ? action.getActionConfiguration().getBody() + : null; // This is a special case where we are handling REMOTE type plugins based actions such as Twilio // The user configured values are stored in an attribute called formData which is a map unlike the // body if (PluginType.REMOTE.equals(newAction.getPluginType()) - && newAction.getUnpublishedAction().getActionConfiguration() != null - && newAction - .getUnpublishedAction() - .getActionConfiguration() - .getFormData() - != null) { - body = new Gson() - .toJson( - newAction - .getUnpublishedAction() - .getActionConfiguration() - .getFormData(), - Map.class); - newAction - .getUnpublishedAction() - .getActionConfiguration() - .setFormData(null); + && action.getActionConfiguration() != null + && action.getActionConfiguration().getFormData() != null) { + body = new Gson().toJson(action.getActionConfiguration().getFormData(), Map.class); + action.getActionConfiguration().setFormData(null); } // This is a special case where we are handling JS actions as we don't want to commit the body of JS // actions if (PluginType.JS.equals(newAction.getPluginType())) { - if (newAction.getUnpublishedAction().getActionConfiguration() != null) { - newAction - .getUnpublishedAction() - .getActionConfiguration() - .setBody(null); - newAction.getUnpublishedAction().setJsonPathKeys(null); + if (action.getActionConfiguration() != null) { + action.getActionConfiguration().setBody(null); + action.setJsonPathKeys(null); } - } else { + } else if (body != null) { // For the regular actions we save the body field to git repo + final String filePath = filePathPrefix + actionFileName + TEXT_FILE_EXTENSION; GitResourceIdentity actionDataIdentity = - new GitResourceIdentity(GitResourceType.QUERY_DATA, newAction.getGitSyncId()); + new GitResourceIdentity(GitResourceType.QUERY_DATA, newAction.getGitSyncId(), filePath); resourceMap.put(actionDataIdentity, body); } + final String filePath = filePathPrefix + METADATA + JSON_EXTENSION; GitResourceIdentity actionConfigIdentity = - new GitResourceIdentity(GitResourceType.QUERY_CONFIG, newAction.getGitSyncId()); + new GitResourceIdentity(GitResourceType.QUERY_CONFIG, newAction.getGitSyncId(), filePath); resourceMap.put(actionConfigIdentity, newAction); }); } @@ -335,18 +379,29 @@ protected void setActionCollectionsInResourceMap( actionCollectionService.generateActionCollectionByViewMode(actionCollection, false)) .forEach(actionCollection -> { removeUnwantedFieldFromActionCollection(actionCollection); - String body = actionCollection.getUnpublishedCollection().getBody() != null - ? actionCollection.getUnpublishedCollection().getBody() - : ""; - actionCollection.getUnpublishedCollection().setBody(null); - - GitResourceIdentity collectionConfigIdentity = - new GitResourceIdentity(GitResourceType.JSOBJECT_CONFIG, actionCollection.getGitSyncId()); + ActionCollectionDTO collection = actionCollection.getUnpublishedCollection(); + final String filePathPrefix = PAGE_DIRECTORY + + DELIMITER_PATH + + collection.calculateContextId() + + DELIMITER_PATH + + ACTION_COLLECTION_DIRECTORY + + DELIMITER_PATH + + collection.getName() + + DELIMITER_PATH; + String body = collection.getBody(); + collection.setBody(null); + + String configFilePath = filePathPrefix + METADATA + JSON_EXTENSION; + GitResourceIdentity collectionConfigIdentity = new GitResourceIdentity( + GitResourceType.JSOBJECT_CONFIG, actionCollection.getGitSyncId(), configFilePath); resourceMap.put(collectionConfigIdentity, actionCollection); - GitResourceIdentity collectionDataIdentity = - new GitResourceIdentity(GitResourceType.JSOBJECT_DATA, actionCollection.getGitSyncId()); - resourceMap.put(collectionDataIdentity, body); + if (body != null) { + String dataFilePath = filePathPrefix + collection.getName() + JS_EXTENSION; + GitResourceIdentity collectionDataIdentity = new GitResourceIdentity( + GitResourceType.JSOBJECT_DATA, actionCollection.getGitSyncId(), dataFilePath); + resourceMap.put(collectionDataIdentity, body); + } }); } @@ -366,6 +421,125 @@ private void removeUnwantedFieldFromActionCollection(ActionCollection actionColl removeUnwantedFieldsFromBaseDomain(actionCollection); } + public ArtifactExchangeJson createArtifactExchangeJson(GitResourceMap gitResourceMap, ArtifactType artifactType) { + ArtifactGitFileUtils artifactGitFileUtils = getArtifactBasedFileHelper(artifactType); + + ArtifactExchangeJson artifactExchangeJson = artifactGitFileUtils.createArtifactExchangeJsonObject(); + + artifactGitFileUtils.setArtifactDependentPropertiesInJson(gitResourceMap, artifactExchangeJson); + + setArtifactIndependentPropertiesInJson(gitResourceMap, artifactExchangeJson); + + return artifactExchangeJson; + } + + protected void setArtifactIndependentPropertiesInJson( + GitResourceMap gitResourceMap, ArtifactExchangeJson artifactExchangeJson) { + Map resourceMap = gitResourceMap.getGitResourceMap(); + + // datasources + List datasourceList = resourceMap.entrySet().stream() + .filter(entry -> { + GitResourceIdentity key = entry.getKey(); + return GitResourceType.DATASOURCE_CONFIG.equals(key.getResourceType()); + }) + .map(Map.Entry::getValue) + .map(value -> objectMapper.convertValue(value, DatasourceStorage.class)) + .collect(Collectors.toList()); + artifactExchangeJson.setDatasourceList(datasourceList); + + // themes + final String themeFilePath = THEME + JSON_EXTENSION; + GitResourceIdentity themeIdentity = + new GitResourceIdentity(GitResourceType.ROOT_CONFIG, themeFilePath, themeFilePath); + Object themeObject = resourceMap.get(themeIdentity); + Theme theme = objectMapper.convertValue(themeObject, Theme.class); + artifactExchangeJson.setThemes(theme, null); + + // custom js libs + List jsLibList = resourceMap.entrySet().stream() + .filter(entry -> { + GitResourceIdentity key = entry.getKey(); + return GitResourceType.JSLIB_CONFIG.equals(key.getResourceType()); + }) + .map(Map.Entry::getValue) + .map(value -> objectMapper.convertValue(value, CustomJSLib.class)) + .collect(Collectors.toList()); + artifactExchangeJson.setCustomJSLibList(jsLibList); + + // actions + final Set queryTypes = Set.of(GitResourceType.QUERY_CONFIG, GitResourceType.QUERY_DATA); + List actionList = resourceMap.entrySet().stream() + .filter(entry -> { + GitResourceIdentity key = entry.getKey(); + return queryTypes.contains(key.getResourceType()); + }) + .collect(collectByGitSyncId()) + .entrySet() + .parallelStream() + .map(entry -> { + Object config = entry.getValue().get(GitResourceType.QUERY_CONFIG); + NewAction newAction = objectMapper.convertValue(config, NewAction.class); + ActionDTO actionDTO = newAction.getUnpublishedAction(); + Object data = entry.getValue().get(GitResourceType.QUERY_DATA); + ActionConfiguration actionConfiguration = actionDTO.getActionConfiguration(); + if (actionConfiguration == null) { + // This shouldn't happen but safe-guarding just in case + actionConfiguration = new ActionConfiguration(); + } + + if (PluginType.REMOTE.equals(newAction.getPluginType())) { + Map formData = objectMapper.convertValue(data, new TypeReference<>() {}); + actionConfiguration.setFormData(formData); + } else if (data != null) { + String body = String.valueOf(data); + actionConfiguration.setBody(body); + } + + return newAction; + }) + .collect(Collectors.toList()); + artifactExchangeJson.setActionList(actionList); + + // action collections + final Set jsObjectTypes = + Set.of(GitResourceType.JSOBJECT_CONFIG, GitResourceType.JSOBJECT_DATA); + List collectionList = resourceMap.entrySet().stream() + .filter(entry -> { + GitResourceIdentity key = entry.getKey(); + return jsObjectTypes.contains(key.getResourceType()); + }) + .collect(collectByGitSyncId()) + .entrySet() + .parallelStream() + .map(entry -> { + Object config = entry.getValue().get(GitResourceType.JSOBJECT_CONFIG); + ActionCollection actionCollection = objectMapper.convertValue(config, ActionCollection.class); + Object data = entry.getValue().get(GitResourceType.JSOBJECT_DATA); + String body = String.valueOf(data); + actionCollection.getUnpublishedCollection().setBody(body); + + return actionCollection; + }) + .collect(Collectors.toList()); + artifactExchangeJson.setActionCollectionList(collectionList); + } + + private Collector, ?, Map>> + collectByGitSyncId() { + return Collectors.toMap( + entry -> entry.getKey().getResourceIdentifier(), + entry -> { + HashMap map = new HashMap<>(); + map.put(entry.getKey().getResourceType(), entry.getValue()); + return map; + }, + (x, y) -> { + x.putAll(y); + return x; + }); + } + private void setDatasourcesInArtifactReference( ArtifactExchangeJson artifactExchangeJson, ArtifactGitReference artifactGitReference) { Map resourceMap = new HashMap<>(); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/jslibs/exportable/CustomJSLibExportableServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/jslibs/exportable/CustomJSLibExportableServiceCEImpl.java index dde27edae16..8df63d51380 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/jslibs/exportable/CustomJSLibExportableServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/jslibs/exportable/CustomJSLibExportableServiceCEImpl.java @@ -1,5 +1,6 @@ package com.appsmith.server.jslibs.exportable; +import com.appsmith.external.git.models.GitResourceType; import com.appsmith.server.constants.FieldName; import com.appsmith.server.domains.Application; import com.appsmith.server.domains.Artifact; @@ -77,6 +78,11 @@ public Mono getExportableEntities( artifactExchangeJson .getModifiedResources() .putResource(FieldName.CUSTOM_JS_LIB_LIST, updatedCustomJSLibSet); + artifactExchangeJson + .getModifiedResources() + .getModifiedResourceIdentifiers() + .get(GitResourceType.JSLIB_CONFIG) + .addAll(updatedCustomJSLibSet); /** * Previously it was a Set and as Set is an unordered collection of elements that diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/exportable/NewActionExportableServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/exportable/NewActionExportableServiceCEImpl.java index 0a315b05afa..395e9c808af 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/exportable/NewActionExportableServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/exportable/NewActionExportableServiceCEImpl.java @@ -1,5 +1,6 @@ package com.appsmith.server.newactions.exportable; +import com.appsmith.external.git.models.GitResourceType; import com.appsmith.external.models.ActionDTO; import com.appsmith.server.acl.AclPermission; import com.appsmith.server.constants.FieldName; @@ -70,6 +71,7 @@ public Mono getExportableEntities( List actionList = tuple.getT1(); Set dbNamesUsedInActions = tuple.getT2(); Set updatedActionSet = new HashSet<>(); + Set updatedIdentities = new HashSet<>(); actionList.forEach(newAction -> { ActionDTO unpublishedActionDTO = newAction.getUnpublishedAction(); ActionDTO publishedActionDTO = newAction.getPublishedAction(); @@ -85,9 +87,12 @@ public Mono getExportableEntities( actionDTO, exportingMetaDTO.getArtifactLastCommittedAt()); - String contextListPath = artifactBasedExportableService.getContextListPath(); - boolean isContextUpdated = ImportExportUtils.isContextNameInUpdatedList( - artifactExchangeJson, contextNameAtIdReference, contextListPath); + String contextGitSyncId = mappedExportableResourcesDTO + .getContextNameToGitSyncIdMap() + .get(contextNameAtIdReference); + boolean isContextUpdated = artifactExchangeJson + .getModifiedResources() + .isResourceUpdatedNew(GitResourceType.CONTEXT_CONFIG, contextGitSyncId); Instant newActionUpdatedAt = newAction.getUpdatedAt(); boolean isNewActionUpdated = exportingMetaDTO.isClientSchemaMigrated() || exportingMetaDTO.isServerSchemaMigrated() @@ -98,10 +103,16 @@ public Mono getExportableEntities( || exportingMetaDTO.getArtifactLastCommittedAt().isBefore(newActionUpdatedAt); if (isNewActionUpdated && newActionName != null) { updatedActionSet.add(newActionName); + updatedIdentities.add(newAction.getGitSyncId()); } newAction.sanitiseToExportDBObject(); }); artifactExchangeJson.getModifiedResources().putResource(FieldName.ACTION_LIST, updatedActionSet); + artifactExchangeJson + .getModifiedResources() + .getModifiedResourceIdentifiers() + .get(GitResourceType.QUERY_CONFIG) + .addAll(updatedIdentities); artifactExchangeJson.setActionList(actionList); // This is where we're removing global datasources that are unused in this application diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/exportable/NewPageExportableServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/exportable/NewPageExportableServiceCEImpl.java index da7ecf8a6d4..0066a6be65c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/exportable/NewPageExportableServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/exportable/NewPageExportableServiceCEImpl.java @@ -1,5 +1,6 @@ package com.appsmith.server.newpages.exportable; +import com.appsmith.external.git.models.GitResourceType; import com.appsmith.server.acl.AclPermission; import com.appsmith.server.constants.FieldName; import com.appsmith.server.constants.SerialiseArtifactObjective; @@ -66,7 +67,8 @@ public Mono getExportableEntities( // Extract mongoEscapedWidgets from pages and save it to applicationJson object as this // field is JsonIgnored. Also remove any ids those are present in the page objects - Set updatedPageSet = new HashSet(); + Set updatedPageSet = new HashSet<>(); + Set updatedIdentities = new HashSet<>(); // check the application object for the page reference in the page list // Exclude the deleted pages that are present in view mode because the app is not @@ -79,6 +81,9 @@ public Mono getExportableEntities( .put( newPage.getId() + EDIT, newPage.getUnpublishedPage().getName()); + mappedExportableResourcesDTO + .getContextNameToGitSyncIdMap() + .put(newPage.getUnpublishedPage().getName(), newPage.getGitSyncId()); PageDTO unpublishedPageDTO = newPage.getUnpublishedPage(); if (!CollectionUtils.isEmpty(unpublishedPageDTO.getLayouts())) { unpublishedPageDTO.getLayouts().forEach(layout -> { @@ -114,11 +119,17 @@ public Mono getExportableEntities( : null; if (isNewPageUpdated && newPageName != null) { updatedPageSet.add(newPageName); + updatedIdentities.add(newPage.getGitSyncId()); } newPage.sanitiseToExportDBObject(); }); applicationJson.setPageList(newPageList); applicationJson.getModifiedResources().putResource(FieldName.PAGE_LIST, updatedPageSet); + applicationJson + .getModifiedResources() + .getModifiedResourceIdentifiers() + .get(GitResourceType.CONTEXT_CONFIG) + .addAll(updatedIdentities); return newPageList; }) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/HealthCheckServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/HealthCheckServiceImpl.java index 64a6614d4a0..b83bc7d4653 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/HealthCheckServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/HealthCheckServiceImpl.java @@ -1,6 +1,7 @@ package com.appsmith.server.services; import com.appsmith.server.services.ce.HealthCheckServiceCEImpl; +import io.micrometer.observation.ObservationRegistry; import org.springframework.data.mongodb.core.ReactiveMongoTemplate; import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; import org.springframework.stereotype.Component; @@ -9,7 +10,8 @@ public class HealthCheckServiceImpl extends HealthCheckServiceCEImpl implements HealthCheckService { public HealthCheckServiceImpl( ReactiveRedisConnectionFactory reactiveRedisConnectionFactory, - ReactiveMongoTemplate reactiveMongoTemplate) { - super(reactiveRedisConnectionFactory, reactiveMongoTemplate); + ReactiveMongoTemplate reactiveMongoTemplate, + ObservationRegistry observationRegistry) { + super(reactiveRedisConnectionFactory, reactiveMongoTemplate, observationRegistry); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AstServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AstServiceCEImpl.java index cd5b32be74a..950cc8578ca 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AstServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AstServiceCEImpl.java @@ -144,6 +144,15 @@ public Mono> refactorNameInDynamicBindings( return Flux.fromIterable(bindingValues) .flatMap(bindingValue -> { + if (!bindingValue.getValue().contains(oldName)) { + // This case is not handled in RTS either, so skipping the RTS call here will not affect the + // behavior. + // Example: + // - Old name: foo.bar + // - New name: foo.baz + // - Binding: "foo['bar']" + return Mono.just(Tuples.of(bindingValue, bindingValue.getValue())); + } EntityRefactorRequest entityRefactorRequest = new EntityRefactorRequest( bindingValue.getValue(), oldName, newName, evalVersion, isJSObject); return rtsCaller diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ConsolidatedAPIServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ConsolidatedAPIServiceCEImpl.java index 71c417afaf1..18e0b23294e 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ConsolidatedAPIServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ConsolidatedAPIServiceCEImpl.java @@ -106,11 +106,11 @@ public class ConsolidatedAPIServiceCEImpl implements ConsolidatedAPIServiceCE { private final ObservationRegistry observationRegistry; private final CacheableRepositoryHelper cacheableRepositoryHelper; - ResponseDTO getSuccessResponse(T data) { + protected ResponseDTO getSuccessResponse(T data) { return new ResponseDTO<>(HttpStatus.OK.value(), data, null); } - private Mono> getErrorResponseMono(Throwable error) { + protected Mono> getErrorResponseMono(Throwable error) { if (error instanceof AppsmithException appsmithException) { return Mono.just(new ResponseDTO<>( appsmithException.getHttpStatus(), @@ -125,7 +125,7 @@ private Mono> getErrorResponseMono(Throwable error) { INTERNAL_SERVER_ERROR_STATUS, new ErrorDTO(INTERNAL_SERVER_ERROR_CODE, error.getMessage()))); } - private Mono> toResponseDTO(Mono mono) { + protected Mono> toResponseDTO(Mono mono) { return mono.map(this::getSuccessResponse).onErrorResume(this::getErrorResponseMono); } @@ -148,6 +148,18 @@ public Mono getConsolidatedInfoForPageLoad( /* This object will serve as a container to hold the response of this method*/ ConsolidatedAPIResponseDTO consolidatedAPIResponseDTO = new ConsolidatedAPIResponseDTO(); + List> fetches = + getAllFetchableMonos(consolidatedAPIResponseDTO, basePageId, baseApplicationId, branchName, mode); + + return Mono.when(fetches).thenReturn(consolidatedAPIResponseDTO); + } + + protected List> getAllFetchableMonos( + ConsolidatedAPIResponseDTO consolidatedAPIResponseDTO, + String basePageId, + String baseApplicationId, + String branchName, + ApplicationMode mode) { final List> fetches = new ArrayList<>(); /* Get user profile data */ @@ -196,163 +208,26 @@ public Mono getConsolidatedInfoForPageLoad( .tap(Micrometer.observation(observationRegistry))); if (isBlank(basePageId) && isBlank(baseApplicationId)) { - return Mono.when(fetches).thenReturn(consolidatedAPIResponseDTO); + return fetches; } /* Get view mode - EDIT or PUBLISHED */ - boolean isViewMode = ApplicationMode.PUBLISHED.equals(mode); + boolean isViewMode = isViewMode(mode); /* Fetch default application id if not provided */ if (isBlank(basePageId)) { - return Mono.when(fetches).thenReturn(consolidatedAPIResponseDTO); + return fetches; } - Mono baseApplicationIdMono = Mono.just(""); - if (isViewMode) { - // Attempt to retrieve the application ID associated with the given base page ID from the cache. - baseApplicationIdMono = cacheableRepositoryHelper - .fetchBaseApplicationId(basePageId, baseApplicationId) - .switchIfEmpty(Mono.just("")) - .cast(String.class); - } - - baseApplicationIdMono = baseApplicationIdMono - .name(getQualifiedSpanName(APPLICATION_ID_FETCH_REDIS_SPAN, mode)) - .tap(Micrometer.observation(observationRegistry)) - .cache(); - - Mono> applicationAndPageTupleMono = baseApplicationIdMono - .flatMap(cachedBaseApplicationId -> { - Mono applicationMono; - Mono branchedPageMonoCached; - - branchedPageMonoCached = newPageService - .findByBranchNameAndBasePageIdAndApplicationMode(branchName, basePageId, mode) - .cache(); - - if (StringUtils.hasText(cachedBaseApplicationId)) { - // Handle non-empty baseApplicationId - applicationMono = applicationService.findByBaseIdBranchNameAndApplicationMode( - cachedBaseApplicationId, branchName, mode); - } else { - // Handle empty or null baseApplicationId - applicationMono = branchedPageMonoCached.flatMap(branchedPage -> - // Use the application ID to find the complete application details. - applicationService - .findByBranchedApplicationIdAndApplicationMode( - branchedPage.getApplicationId(), mode) - .flatMap(application -> { - if (isViewMode) { - // Update the cache with the new application’s base ID for future - // queries. - return cacheableRepositoryHelper - .fetchBaseApplicationId(basePageId, application.getBaseId()) - .thenReturn(application) - .name(getQualifiedSpanName( - APPLICATION_ID_UPDATE_REDIS_SPAN, mode)) - .tap(Micrometer.observation(observationRegistry)); - } - return Mono.just(application); - })); - } - - if (StringUtils.hasText(branchName)) { + Mono baseApplicationIdMono = getBaseApplicationIdMono(basePageId, baseApplicationId, mode, isViewMode); - // If in case the application is a non git connected application and the branch name url param - // is present, then we must default to the app without any branches. - return applicationMono.zipWith(branchedPageMonoCached).onErrorResume(error -> { - // This situation would arise if page or application is not returned. - // here we would land on error instead of empty because both apis which are being - // called errors out on empty returns. - - log.info( - "application or page has for base pageId {} and branchName {} has not been found.", - basePageId, - branchName); - if (error instanceof AppsmithException) { - Mono basePageMono = - newPageService.findByBranchNameAndBasePageIdAndApplicationMode( - null, basePageId, mode); - - return basePageMono.flatMap(basePage -> { - if (StringUtils.hasText(basePage.getBranchName())) { - // If the branch name is present then the application is git connected - // the error should be thrown. - // TODO: verify if branch name could be residue from old git connection - // Application metadata is absolute check for the same. - return Mono.error(error); - } - - return applicationService - .findByBranchedApplicationIdAndApplicationMode( - basePage.getApplicationId(), mode) - .zipWith(basePageMono) - .map(tuple2 -> { - log.info( - "The branchName url param should not be associated with application {} as this is not a git connected application", - tuple2.getT1().getId()); - return tuple2; - }); - }); - } - - return Mono.error(error); - }); - } - - return applicationMono.zipWith(branchedPageMonoCached).flatMap(tuple2 -> { - Application application = tuple2.getT1(); - NewPage branchedPage = tuple2.getT2(); - - GitArtifactMetadata gitMetadata = application.getGitArtifactMetadata(); - - boolean isNotAGitApp = gitMetadata == null; - boolean isDefaultBranchNameAbsent = - isNotAGitApp || !StringUtils.hasText(gitMetadata.getDefaultBranchName()); - boolean isBranchDefault = !isDefaultBranchNameAbsent - && gitMetadata.getDefaultBranchName().equals(gitMetadata.getBranchName()); - - // This last check is specially for view mode, when a queried page which is not present - // in default branch, and cacheable repository refers to the base application - // from given page id. then the branched page may not belong to the base application - // hence a validation is required. - // This condition is always true for a non git app - boolean isPageFromSameApplication = application.getId().equals(branchedPage.getApplicationId()); - - if ((isNotAGitApp || isDefaultBranchNameAbsent || isBranchDefault) - && (!isViewMode || isPageFromSameApplication)) { - return applicationMono.zipWith(branchedPageMonoCached); - } - - log.info( - "ConsolidatedApi for page id {}, and application id {} has been queried without a branch url param", - branchedPage.getId(), - application.getId()); - - // The git connected application has not been queried with branch param, - // and the base branch is not same as the default branch. - // we need to find return the default branch from here. - - String defaultBranchName = gitMetadata.getDefaultBranchName(); - - return applicationService - .findByBaseIdBranchNameAndApplicationMode(application.getId(), defaultBranchName, mode) - .zipWith(newPageService.findByBranchNameAndBasePageIdAndApplicationMode( - defaultBranchName, basePageId, mode)); - }); - }) - .cache(); + Mono> applicationAndPageTupleMono = + getApplicationAndPageTupleMono(basePageId, branchName, mode, baseApplicationIdMono, isViewMode); Mono branchedPageMonoCached = applicationAndPageTupleMono.map(Tuple2::getT2).cache(); - Mono branchedApplicationMonoCached = - applicationAndPageTupleMono.map(Tuple2::getT1).cache(); - - branchedApplicationMonoCached = branchedApplicationMonoCached - .name(getQualifiedSpanName(APPLICATION_ID_SPAN, mode)) - .tap(Micrometer.observation(observationRegistry)) - .cache(); + Mono branchedApplicationMonoCached = getBranchedApplicationMono(mode, applicationAndPageTupleMono); Mono> pagesFromCurrentApplicationMonoCached = branchedApplicationMonoCached .flatMap(branchedApplication -> @@ -585,8 +460,171 @@ public Mono getConsolidatedInfoForPageLoad( .name(getQualifiedSpanName(MOCK_DATASOURCES_SPAN, mode)) .tap(Micrometer.observation(observationRegistry))); } + return fetches; + } - return Mono.when(fetches).thenReturn(consolidatedAPIResponseDTO); + protected Mono getBaseApplicationIdMono( + String basePageId, String baseApplicationId, ApplicationMode mode, boolean isViewMode) { + Mono baseApplicationIdMono = Mono.just(""); + if (isViewMode) { + // Attempt to retrieve the application ID associated with the given base page ID from the cache. + baseApplicationIdMono = cacheableRepositoryHelper + .fetchBaseApplicationId(basePageId, baseApplicationId) + .switchIfEmpty(Mono.just("")) + .cast(String.class); + } + + baseApplicationIdMono = baseApplicationIdMono + .name(getQualifiedSpanName(APPLICATION_ID_FETCH_REDIS_SPAN, mode)) + .tap(Micrometer.observation(observationRegistry)) + .cache(); + return baseApplicationIdMono; + } + + protected boolean isViewMode(ApplicationMode mode) { + return ApplicationMode.PUBLISHED.equals(mode); + } + + protected Mono getBranchedApplicationMono( + ApplicationMode mode, Mono> applicationAndPageTupleMono) { + Mono branchedApplicationMonoCached = + applicationAndPageTupleMono.map(Tuple2::getT1).cache(); + + branchedApplicationMonoCached = branchedApplicationMonoCached + .name(getQualifiedSpanName(APPLICATION_ID_SPAN, mode)) + .tap(Micrometer.observation(observationRegistry)) + .cache(); + return branchedApplicationMonoCached; + } + + protected Mono> getApplicationAndPageTupleMono( + String basePageId, + String branchName, + ApplicationMode mode, + Mono baseApplicationIdMono, + boolean isViewMode) { + Mono> applicationAndPageTupleMono = baseApplicationIdMono + .flatMap(cachedBaseApplicationId -> { + Mono applicationMono; + Mono branchedPageMonoCached; + + branchedPageMonoCached = newPageService + .findByBranchNameAndBasePageIdAndApplicationMode(branchName, basePageId, mode) + .cache(); + + if (StringUtils.hasText(cachedBaseApplicationId)) { + // Handle non-empty baseApplicationId + applicationMono = applicationService.findByBaseIdBranchNameAndApplicationMode( + cachedBaseApplicationId, branchName, mode); + } else { + // Handle empty or null baseApplicationId + applicationMono = branchedPageMonoCached.flatMap(branchedPage -> + // Use the application ID to find the complete application details. + applicationService + .findByBranchedApplicationIdAndApplicationMode( + branchedPage.getApplicationId(), mode) + .flatMap(application -> { + if (isViewMode) { + // Update the cache with the new application’s base ID for future + // queries. + return cacheableRepositoryHelper + .fetchBaseApplicationId(basePageId, application.getBaseId()) + .thenReturn(application) + .name(getQualifiedSpanName( + APPLICATION_ID_UPDATE_REDIS_SPAN, mode)) + .tap(Micrometer.observation(observationRegistry)); + } + return Mono.just(application); + })); + } + + if (StringUtils.hasText(branchName)) { + + // If in case the application is a non git connected application and the branch name url param + // is present, then we must default to the app without any branches. + return applicationMono.zipWith(branchedPageMonoCached).onErrorResume(error -> { + // This situation would arise if page or application is not returned. + // here we would land on error instead of empty because both apis which are being + // called errors out on empty returns. + + log.info( + "application or page has for base pageId {} and branchName {} has not been found.", + basePageId, + branchName); + if (error instanceof AppsmithException) { + Mono basePageMono = + newPageService.findByBranchNameAndBasePageIdAndApplicationMode( + null, basePageId, mode); + + return basePageMono.flatMap(basePage -> { + if (StringUtils.hasText(basePage.getBranchName())) { + // If the branch name is present then the application is git connected + // the error should be thrown. + // TODO: verify if branch name could be residue from old git connection + // Application metadata is absolute check for the same. + return Mono.error(error); + } + + return applicationService + .findByBranchedApplicationIdAndApplicationMode( + basePage.getApplicationId(), mode) + .zipWith(basePageMono) + .map(tuple2 -> { + log.info( + "The branchName url param should not be associated with application {} as this is not a git connected application", + tuple2.getT1().getId()); + return tuple2; + }); + }); + } + + return Mono.error(error); + }); + } + + return applicationMono.zipWith(branchedPageMonoCached).flatMap(tuple2 -> { + Application application = tuple2.getT1(); + NewPage branchedPage = tuple2.getT2(); + + GitArtifactMetadata gitMetadata = application.getGitArtifactMetadata(); + + boolean isNotAGitApp = gitMetadata == null; + boolean isDefaultBranchNameAbsent = + isNotAGitApp || !StringUtils.hasText(gitMetadata.getDefaultBranchName()); + boolean isBranchDefault = !isDefaultBranchNameAbsent + && gitMetadata.getDefaultBranchName().equals(gitMetadata.getBranchName()); + + // This last check is specially for view mode, when a queried page which is not present + // in default branch, and cacheable repository refers to the base application + // from given page id. then the branched page may not belong to the base application + // hence a validation is required. + // This condition is always true for a non git app + boolean isPageFromSameApplication = application.getId().equals(branchedPage.getApplicationId()); + + if ((isNotAGitApp || isDefaultBranchNameAbsent || isBranchDefault) + && (!isViewMode || isPageFromSameApplication)) { + return applicationMono.zipWith(branchedPageMonoCached); + } + + log.info( + "ConsolidatedApi for page id {}, and application id {} has been queried without a branch url param", + branchedPage.getId(), + application.getId()); + + // The git connected application has not been queried with branch param, + // and the base branch is not same as the default branch. + // we need to find return the default branch from here. + + String defaultBranchName = gitMetadata.getDefaultBranchName(); + + return applicationService + .findByBaseIdBranchNameAndApplicationMode(application.getId(), defaultBranchName, mode) + .zipWith(newPageService.findByBranchNameAndBasePageIdAndApplicationMode( + defaultBranchName, basePageId, mode)); + }); + }) + .cache(); + return applicationAndPageTupleMono; } private boolean isPossibleToCreateQueryWithoutDatasource(Plugin plugin) { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/GitArtifactHelperCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/GitArtifactHelperCE.java index 434f3bf4baa..346c0149b4f 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/GitArtifactHelperCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/GitArtifactHelperCE.java @@ -66,4 +66,6 @@ public interface GitArtifactHelperCE { Boolean isContextInArtifactEmpty(ArtifactExchangeJson artifactExchangeJson); T getNewArtifact(String workspaceId, String repoName); + + Mono publishArtifactPostCommit(Artifact committedArtifact); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/HealthCheckServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/HealthCheckServiceCEImpl.java index e1059fd389b..f18dcd7acfa 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/HealthCheckServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/HealthCheckServiceCEImpl.java @@ -2,29 +2,37 @@ import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; +import io.micrometer.observation.ObservationRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.actuate.data.mongo.MongoReactiveHealthIndicator; import org.springframework.boot.actuate.data.redis.RedisReactiveHealthIndicator; import org.springframework.boot.actuate.health.Health; import org.springframework.data.mongodb.core.ReactiveMongoTemplate; import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import reactor.core.observability.micrometer.Micrometer; import reactor.core.publisher.Mono; import java.time.Duration; import java.util.concurrent.TimeoutException; import java.util.function.Function; +import static com.appsmith.external.constants.spans.ce.HealthSpanCE.MONGO_HEALTH; +import static com.appsmith.external.constants.spans.ce.HealthSpanCE.REDIS_HEALTH; + @Slf4j public class HealthCheckServiceCEImpl implements HealthCheckServiceCE { private final ReactiveRedisConnectionFactory reactiveRedisConnectionFactory; private final ReactiveMongoTemplate reactiveMongoTemplate; + private final ObservationRegistry observationRegistry; public HealthCheckServiceCEImpl( ReactiveRedisConnectionFactory reactiveRedisConnectionFactory, - ReactiveMongoTemplate reactiveMongoTemplate) { + ReactiveMongoTemplate reactiveMongoTemplate, + ObservationRegistry observationRegistry) { this.reactiveRedisConnectionFactory = reactiveRedisConnectionFactory; this.reactiveMongoTemplate = reactiveMongoTemplate; + this.observationRegistry = observationRegistry; } @Override @@ -42,7 +50,9 @@ private Mono getRedisHealth() { return redisReactiveHealthIndicator .health() .timeout(Duration.ofSeconds(3)) - .onErrorMap(TimeoutException.class, healthTimeout); + .onErrorMap(TimeoutException.class, healthTimeout) + .name(REDIS_HEALTH) + .tap(Micrometer.observation(observationRegistry)); } private Mono getMongoHealth() { @@ -55,6 +65,8 @@ private Mono getMongoHealth() { return mongoReactiveHealthIndicator .health() .timeout(Duration.ofSeconds(1)) - .onErrorMap(TimeoutException.class, healthTimeout); + .onErrorMap(TimeoutException.class, healthTimeout) + .name(MONGO_HEALTH) + .tap(Micrometer.observation(observationRegistry)); } } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/git/autocommit/AutoCommitEventHandlerImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/git/autocommit/AutoCommitEventHandlerImplTest.java index b5697526d8b..1afa9d79a01 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/git/autocommit/AutoCommitEventHandlerImplTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/git/autocommit/AutoCommitEventHandlerImplTest.java @@ -161,6 +161,7 @@ private ApplicationJson createApplicationJson() { pageDTO.setName("Page 1"); pageDTO.setLayouts(List.of(layout)); NewPage newPage = new NewPage(); + newPage.setGitSyncId("p1"); newPage.setUnpublishedPage(pageDTO); ApplicationJson applicationJson = new ApplicationJson(); applicationJson.setPageList(List.of(newPage)); diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/git/resourcemap/ExchangeJsonConversionTests.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/git/resourcemap/ExchangeJsonConversionTests.java index c1df11a4be1..56de587dcb8 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/git/resourcemap/ExchangeJsonConversionTests.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/git/resourcemap/ExchangeJsonConversionTests.java @@ -1,18 +1,26 @@ package com.appsmith.server.git.resourcemap; +import com.appsmith.external.git.GitExecutor; import com.appsmith.external.git.models.GitResourceIdentity; import com.appsmith.external.git.models.GitResourceMap; +import com.appsmith.server.constants.ArtifactType; import com.appsmith.server.dtos.ArtifactExchangeJson; import com.appsmith.server.git.resourcemap.templates.contexts.ExchangeJsonContext; import com.appsmith.server.git.resourcemap.templates.providers.ExchangeJsonTestTemplateProvider; import com.appsmith.server.helpers.CommonGitFileUtils; import com.appsmith.server.migrations.JsonSchemaMigration; -import com.google.gson.Gson; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.io.FileUtils; +import org.assertj.core.api.Assertions; +import org.eclipse.jgit.api.errors.GitAPIException; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.core.io.ClassPathResource; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -20,6 +28,8 @@ import java.io.IOException; import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -33,7 +43,7 @@ public class ExchangeJsonConversionTests { public ExchangeJsonTestTemplateProvider templateProvider; @Autowired - Gson gson; + ObjectMapper objectMapper; @Autowired JsonSchemaMigration jsonSchemaMigration; @@ -41,16 +51,20 @@ public class ExchangeJsonConversionTests { @Autowired CommonGitFileUtils commonGitFileUtils; + @SpyBean + GitExecutor gitExecutor; + @TestTemplate public void testConvertArtifactJsonToGitResourceMap_whenArtifactIsFullyPopulated_returnsCorrespondingResourceMap( ExchangeJsonContext context) throws IOException { - Mono artifactJsonMono = - createArtifactJson(context).cache(); + Mono artifactJsonMono = createArtifactJson(context); + + Mono artifactJsonCloneMono = createArtifactJson(context); Mono> gitResourceMapAndArtifactJsonMono = artifactJsonMono .map(artifactJson -> commonGitFileUtils.createGitResourceMap(artifactJson)) - .zipWith(artifactJsonMono); + .zipWith(artifactJsonCloneMono); StepVerifier.create(gitResourceMapAndArtifactJsonMono) .assertNext(tuple2 -> { @@ -87,8 +101,46 @@ private Mono createArtifactJson(ExchangeJsonCont Class exchangeJsonType = context.getArtifactExchangeJsonType(); - ArtifactExchangeJson artifactExchangeJson = gson.fromJson(artifactJson, exchangeJsonType); + ArtifactExchangeJson artifactExchangeJson = + objectMapper.copy().disable(MapperFeature.USE_ANNOTATIONS).readValue(artifactJson, exchangeJsonType); return jsonSchemaMigration.migrateArtifactExchangeJsonToLatestSchema(artifactExchangeJson, null, null); } + + @TestTemplate + public void testConvertGitResourceMapToArtifactExchangeJson_whenArtifactIsFullyPopulated_returnsCorrespondingJson( + ExchangeJsonContext context) throws IOException { + ArtifactExchangeJson originalArtifactJson = createArtifactJson(context).block(); + + GitResourceMap gitResourceMap = commonGitFileUtils.createGitResourceMap(originalArtifactJson); + + ArtifactExchangeJson artifactExchangeJson = + commonGitFileUtils.createArtifactExchangeJson(gitResourceMap, ArtifactType.APPLICATION); + + assertThat(artifactExchangeJson).isNotNull(); + + templateProvider.assertResourceComparisons(originalArtifactJson, artifactExchangeJson); + } + + @TestTemplate + public void testSerializeArtifactExchangeJson_whenArtifactIsFullyPopulated_returnsCorrespondingBaseRepoPath( + ExchangeJsonContext context) throws IOException, GitAPIException { + ArtifactExchangeJson originalArtifactJson = createArtifactJson(context).block(); + + Mockito.doReturn(Mono.just(true)).when(gitExecutor).resetToLastCommit(Mockito.any(), Mockito.anyString()); + + Files.createDirectories(Path.of("./container-volumes/git-storage/test123")); + + Mono responseMono = + commonGitFileUtils.saveArtifactToLocalRepoNew(Path.of("test123"), originalArtifactJson, "irrelevant"); + + StepVerifier.create(responseMono) + .assertNext(response -> { + Assertions.assertThat(response).isNotNull(); + }) + .verifyComplete(); + + FileUtils.deleteDirectory( + Path.of("./container-volumes/git-storage/test123").toFile()); + } } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/git/resourcemap/templates/providers/ce/ExchangeJsonTestTemplateProviderCE.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/git/resourcemap/templates/providers/ce/ExchangeJsonTestTemplateProviderCE.java index 0724d60170d..bbf07f5f62d 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/git/resourcemap/templates/providers/ce/ExchangeJsonTestTemplateProviderCE.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/git/resourcemap/templates/providers/ce/ExchangeJsonTestTemplateProviderCE.java @@ -2,7 +2,12 @@ import com.appsmith.external.git.models.GitResourceIdentity; import com.appsmith.external.git.models.GitResourceType; +import com.appsmith.external.models.DatasourceStorage; import com.appsmith.external.models.PluginType; +import com.appsmith.server.domains.ActionCollection; +import com.appsmith.server.domains.Context; +import com.appsmith.server.domains.CustomJSLib; +import com.appsmith.server.domains.NewAction; import com.appsmith.server.dtos.ApplicationJson; import com.appsmith.server.dtos.ArtifactExchangeJson; import com.appsmith.server.git.resourcemap.templates.contexts.ExchangeJsonContext; @@ -27,7 +32,7 @@ public boolean supportsTestTemplate(ExtensionContext extensionContext) { @Override public Stream provideTestTemplateInvocationContexts( ExtensionContext extensionContext) { - ExchangeJsonContext context = new ExchangeJsonContext("valid-application.json", ApplicationJson.class, 23); + ExchangeJsonContext context = new ExchangeJsonContext("valid-application.json", ApplicationJson.class, 22); return Stream.of(context); } @@ -68,7 +73,14 @@ public long assertResourceComparisons( List jsObjectDataResources = getResourceListByType(resourceMap, GitResourceType.JSOBJECT_DATA); long resourceMapJsObjectDataCount = jsObjectDataResources.size(); - assertThat(resourceMapJsObjectDataCount).isEqualTo(jsonJsObjectCount); + int jsonJsObjectDataCount = exchangeJson.getActionCollectionList() != null + ? exchangeJson.getActionCollectionList().parallelStream() + .filter(collection -> + collection.getUnpublishedCollection().getBody() != null) + .collect(Collectors.toList()) + .size() + : 0; + assertThat(resourceMapJsObjectDataCount).isEqualTo(jsonJsObjectDataCount); List actionConfigResources = getResourceListByType(resourceMap, GitResourceType.QUERY_CONFIG); long resourceMapActionConfigCount = actionConfigResources.size(); @@ -82,7 +94,17 @@ public long assertResourceComparisons( long jsonActionDataCount = 0; if (exchangeJson.getActionList() != null) { jsonActionDataCount = exchangeJson.getActionList().stream() - .filter(action -> !PluginType.JS.equals(action.getPluginType())) + .filter(action -> !PluginType.JS.equals(action.getPluginType()) + && action.getUnpublishedAction().getActionConfiguration() != null + && !(action.getUnpublishedAction() + .getActionConfiguration() + .getBody() + == null + || (action.getPluginType().equals(PluginType.REMOTE) + && action.getUnpublishedAction() + .getActionConfiguration() + .getFormData() + == null))) .count(); } assertThat(resourceMapActionDataCount).isEqualTo(jsonActionDataCount); @@ -111,4 +133,42 @@ protected List getResourceListByType( .map(Map.Entry::getValue) .collect(Collectors.toList()); } + + public void assertResourceComparisons( + ArtifactExchangeJson originalExchangeJson, ArtifactExchangeJson convertedExchangeJson) { + List datasourceResources = convertedExchangeJson.getDatasourceList(); + long convertedDatasourceCount = datasourceResources.size(); + int jsonDatasourceCount = originalExchangeJson.getDatasourceList() != null + ? originalExchangeJson.getDatasourceList().size() + : 0; + assertThat(convertedDatasourceCount).isEqualTo(jsonDatasourceCount); + + List jsLibResources = convertedExchangeJson.getCustomJSLibList(); + long convertedJsLibCount = jsLibResources.size(); + int jsonJsLibCount = originalExchangeJson.getCustomJSLibList() != null + ? originalExchangeJson.getCustomJSLibList().size() + : 0; + assertThat(convertedJsLibCount).isEqualTo(jsonJsLibCount); + + List contextResources = convertedExchangeJson.getContextList(); + long convertedContextCount = contextResources.size(); + int jsonContextCount = originalExchangeJson.getContextList() != null + ? originalExchangeJson.getContextList().size() + : 0; + assertThat(convertedContextCount).isEqualTo(jsonContextCount); + + List jsObjectResources = convertedExchangeJson.getActionCollectionList(); + long convertedJsObjectCount = jsObjectResources.size(); + int jsonJsObjectCount = originalExchangeJson.getActionCollectionList() != null + ? originalExchangeJson.getActionCollectionList().size() + : 0; + assertThat(convertedJsObjectCount).isEqualTo(jsonJsObjectCount); + + List actionResources = convertedExchangeJson.getActionList(); + long convertedActionCount = actionResources.size(); + int jsonActionCount = originalExchangeJson.getActionList() != null + ? originalExchangeJson.getActionList().size() + : 0; + assertThat(convertedActionCount).isEqualTo(jsonActionCount); + } } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ASTServiceCETest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ASTServiceCETest.java new file mode 100644 index 00000000000..09f91cba617 --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ASTServiceCETest.java @@ -0,0 +1,154 @@ +package com.appsmith.server.services.ce; + +import com.appsmith.external.models.MustacheBindingToken; +import com.appsmith.server.services.AstService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; + +@SpringBootTest +@Slf4j +public class ASTServiceCETest { + @SpyBean + AstService astService; + + @Test + void refactorNameInDynamicBindings_nullOrEmptyBindings_returnsEmptyMono() { + Mono> result = + astService.refactorNameInDynamicBindings(null, "abc", "xyz", 2, false); + + StepVerifier.create(result).verifyComplete(); + + result = astService.refactorNameInDynamicBindings(Collections.emptySet(), "abc", "xyz", 2, false); + + StepVerifier.create(result).verifyComplete(); + } + + @Test + void refactorNameInDynamicBindings_bindingWithoutOldName_returnsUnchangedMap() { + MustacheBindingToken token1 = new MustacheBindingToken(); + token1.setValue("foo.bar"); + MustacheBindingToken token2 = new MustacheBindingToken(); + token2.setValue("baz.qux"); + Set bindings = Set.of(token1, token2); + + Mono> result = + astService.refactorNameInDynamicBindings(bindings, "abc", "xyz", 2, false); + + StepVerifier.create(result) + .assertNext(map -> { + assertThat(map).hasSize(2); + assertThat(map.get(token1)).isEqualTo("foo.bar"); + assertThat(map.get(token2)).isEqualTo("baz.qux"); + }) + .verifyComplete(); + } + + @Test + void refactorNameInDynamicBindings_validBindings_returnsUpdatedBindings() { + MustacheBindingToken token1 = new MustacheBindingToken(); + token1.setValue("abc['foo']"); + MustacheBindingToken token2 = new MustacheBindingToken(); + token2.setValue("xyz['bar']"); + Set bindings = Set.of(token1, token2); + + String refactoredScript1 = "xyz['foo']"; + String refactoredScript2 = "xyz['bar']"; + + Map responseMap1 = Map.of( + token1, refactoredScript1, + token2, refactoredScript2); + + doReturn(Mono.just(responseMap1)) + .when(astService) + .refactorNameInDynamicBindings(Set.of(token1, token2), "abc", "xyz", 2, false); + + Mono> result = + astService.refactorNameInDynamicBindings(bindings, "abc", "xyz", 2, false); + + StepVerifier.create(result) + .assertNext(map -> { + assertThat(map).hasSize(2); // Only one binding refactored + assertThat(map.get(token1)).isEqualTo(refactoredScript1); + assertThat(map.get(token2)).isEqualTo(refactoredScript2); + }) + .verifyComplete(); + } + + @Test + void refactorNameInDynamicBindings_whenValidJSObjectRequest_thenReturnUpdatedScript() { + MustacheBindingToken token = new MustacheBindingToken(); + token.setValue("export default { myFun1() { Api1.run(); return Api1.data;}}"); + Set bindingValues = Set.of(token); + String oldName = "Api1"; + String newName = "GetUsers"; + int evalVersion = 2; + boolean isJSObject = true; + + String refactoredScript = "export default { myFun1() { GetUsers.run(); return GetUsers.data;}}"; + + Map responseMap = Map.of(token, refactoredScript); + + doReturn(Mono.just(responseMap)) + .when(astService) + .refactorNameInDynamicBindings(bindingValues, oldName, newName, evalVersion, isJSObject); + + Mono> resultMono = + astService.refactorNameInDynamicBindings(bindingValues, oldName, newName, evalVersion, isJSObject); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertThat(result).hasSize(1); + + MustacheBindingToken key = bindingValues.iterator().next(); + + assertThat(result.containsKey(key)).isTrue(); + assertThat(result.get(key)).isEqualTo(refactoredScript); + }) + .verifyComplete(); + } + + @Test + void refactorNameInDynamicBindings_whenNoMatchingOldNameInJSObject_thenReturnOriginalScript() { + MustacheBindingToken token = new MustacheBindingToken(); + token.setValue("export default { myFun1() { GetUsers.run(); return GetUsers.data;}}"); + Set bindingValues = Set.of(token); + + String oldName = "Api1"; // oldName is not present in the script + String newName = "GetUsers"; + int evalVersion = 2; + boolean isJSObject = true; + + String refactoredScript = "export default { myFun1() { GetUsers.run(); return GetUsers.data;}}"; + + Map responseMap = Map.of(token, refactoredScript); + + doReturn(Mono.just(responseMap)) + .when(astService) + .refactorNameInDynamicBindings(bindingValues, oldName, newName, evalVersion, isJSObject); + + Mono> resultMono = + astService.refactorNameInDynamicBindings(bindingValues, oldName, newName, evalVersion, isJSObject); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertThat(result).hasSize(1); + + MustacheBindingToken key = bindingValues.iterator().next(); + + assertThat(result.containsKey(key)).isTrue(); + assertThat(result.get(key)).isEqualTo(token.getValue()); // Script remains unchanged + }) + .verifyComplete(); + } +} diff --git a/deploy/docker/fs/opt/appsmith/entrypoint.sh b/deploy/docker/fs/opt/appsmith/entrypoint.sh index ce39e43598f..77ab4cd5de8 100644 --- a/deploy/docker/fs/opt/appsmith/entrypoint.sh +++ b/deploy/docker/fs/opt/appsmith/entrypoint.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash +# Source the helper script +source pg-utils.sh + set -e tlog "Running as: $(id)" @@ -440,6 +443,12 @@ init_postgres() { tlog "Initializing local Postgres data folder" su postgres -c "env PATH='$PATH' initdb -D $POSTGRES_DB_PATH" fi + cp /opt/appsmith/postgres/appsmith_hba.conf "$POSTGRES_DB_PATH/pg_hba.conf" + # PostgreSQL requires strict file permissions for the pg_hba.conf file. Add file permission settings after copying the configuration file. + # 600 is the recommended permission for pg_hba.conf file for read and write access to the owner only. + chown postgres:postgres "$POSTGRES_DB_PATH/pg_hba.conf" + chmod 600 "$POSTGRES_DB_PATH/pg_hba.conf" + create_appsmith_pg_db "$POSTGRES_DB_PATH" else runEmbeddedPostgres=0 @@ -477,7 +486,9 @@ create_appsmith_pg_db() { local max_attempts=300 local attempt=0 - until su postgres -c "env PATH='$PATH' pg_isready -h 127.0.0.1"; do + local unix_socket_directory=$(get_unix_socket_directory "$POSTGRES_DB_PATH") + echo "Unix socket directory is $unix_socket_directory" + until su postgres -c "env PATH='$PATH' pg_isready -h $unix_socket_directory"; do if (( attempt >= max_attempts )); then echo "Postgres failed to start within 300 seconds." return 1 diff --git a/deploy/docker/fs/opt/appsmith/pg-utils.sh b/deploy/docker/fs/opt/appsmith/pg-utils.sh index 27ad26efdcc..4bb5dec16ff 100755 --- a/deploy/docker/fs/opt/appsmith/pg-utils.sh +++ b/deploy/docker/fs/opt/appsmith/pg-utils.sh @@ -6,7 +6,8 @@ DB_HOST="127.0.0.1" DB_PORT="5432" DB_SCHEMA="appsmith" DB_NAME="appsmith" -postgres_admin_user="postgres" +POSTGRES_ADMIN_USER="postgres" +POSTGRES_DB_PATH="/appsmith-stacks/data/postgres/main" waitForPostgresAvailability() { if [ -z "$PG_DB_HOST" ]; then @@ -17,8 +18,9 @@ waitForPostgresAvailability() { MAX_RETRIES=50 RETRYSECONDS=10 retry_count=0 + local unix_socket_directory=$(get_unix_socket_directory "$POSTGRES_DB_PATH") while true; do - su postgres -c "pg_isready -h '${PG_DB_HOST}' -p '${PG_DB_PORT}'" + su postgres -c "pg_isready -h $unix_socket_directory -p '${PG_DB_PORT}'" status=$? case $status in @@ -106,31 +108,34 @@ init_pg_db() { # Check if the DB_HOST is local (localhost or 127.0.0.1) if [[ "$PG_DB_HOST" == "localhost" || "$PG_DB_HOST" == "127.0.0.1" ]]; then + local unix_socket_directory=$(get_unix_socket_directory "$POSTGRES_DB_PATH") # Check if the database exists - DB_CHECK=$(psql -h "$PG_DB_HOST" -p "$PG_DB_PORT" -U postgres -d "postgres" -tAc "SELECT 1 FROM pg_database WHERE datname='$PG_DB_NAME'") + DB_CHECK=$(psql -h "$unix_socket_directory" -p "$PG_DB_PORT" -U "$POSTGRES_ADMIN_USER" -tAc "SELECT 1 FROM pg_database WHERE datname='$PG_DB_NAME'") if [ "$DB_CHECK" != "1" ]; then echo "Database $PG_DB_NAME does not exist. Creating database..." - psql -h "$PG_DB_HOST" -p "$PG_DB_PORT" -U postgres -d "postgres" -c "CREATE DATABASE $PG_DB_NAME;" + psql -h "$unix_socket_directory" -p "$PG_DB_PORT" -U "$POSTGRES_ADMIN_USER" -c "CREATE DATABASE $PG_DB_NAME;" else echo "Database $PG_DB_NAME already exists." fi # Check if the schema exists - SCHEMA_CHECK=$(psql -h "$PG_DB_HOST" -p "$PG_DB_PORT" -U postgres -d "$PG_DB_NAME" -tAc "SELECT 1 FROM information_schema.schemata WHERE schema_name='appsmith'") + SCHEMA_CHECK=$(psql -h "$unix_socket_directory" -p "$PG_DB_PORT" -U "$POSTGRES_ADMIN_USER" -d "$PG_DB_NAME" -tAc "SELECT 1 FROM information_schema.schemata WHERE schema_name='appsmith'") # Create schema and user if not exists if [ "$SCHEMA_CHECK" != "1" ]; then echo "Creating user '$PG_DB_USER' with password " - psql -h "$PG_DB_HOST" -p "$PG_DB_PORT" -U postgres -d "$PG_DB_NAME" -c "CREATE USER \"$PG_DB_USER\" WITH PASSWORD '$PG_DB_PASSWORD';" + psql -h "$unix_socket_directory" -p "$PG_DB_PORT" -U "$POSTGRES_ADMIN_USER" -d "$PG_DB_NAME" -c "CREATE USER \"$PG_DB_USER\" WITH PASSWORD '$PG_DB_PASSWORD';" echo "Schema 'appsmith' does not exist. Creating schema..." - psql -h "$PG_DB_HOST" -p "$PG_DB_PORT" -U postgres -d "$PG_DB_NAME" -c "CREATE SCHEMA appsmith;" + psql -h "$unix_socket_directory" -p "$PG_DB_PORT" -U "$POSTGRES_ADMIN_USER" -d "$PG_DB_NAME" -c "CREATE SCHEMA appsmith;" fi - USER=$PG_DB_USER SCHEMA="appsmith" DB=$PG_DB_NAME HOST=$PG_DB_HOST PORT=$PG_DB_PORT grant_permissions_for_schema - echo "Creating pg_trgm extension..." - psql -h "$PG_DB_HOST" -p "$PG_DB_PORT" -U postgres -d "$PG_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;" + psql -h "$unix_socket_directory" -p "$PG_DB_PORT" -U "$POSTGRES_ADMIN_USER" -d "$PG_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;" + + # Grant permissions to the user on the schema + USER=$PG_DB_USER SCHEMA="appsmith" DB=$PG_DB_NAME HOST=$PG_DB_HOST PORT=$PG_DB_PORT grant_permissions_for_local_db_schema + else echo "Remote PostgreSQL detected, running as current user." PGPASSWORD=$PG_DB_PASSWORD psql -h "$PG_DB_HOST" -p "$PG_DB_PORT" -U "$PG_DB_USER" -d "$PG_DB_NAME" -c "CREATE SCHEMA IF NOT EXISTS appsmith;" @@ -160,18 +165,31 @@ init_pg_db() { # Returns: # None # Example: -# USER="user" SCHEMA="schema" DB="db" HOST="host" PORT="port" grant_permissions_for_schema -grant_permissions_for_schema() { +# USER="user" SCHEMA="schema" DB="db" HOST="host" PORT="port" grant_permissions_for_local_db_schema +grant_permissions_for_local_db_schema() { local user=${USER-$DB_USER} schema=${SCHEMA-$DB_SCHEMA} db=${DB-$DB_NAME} host=${HOST-$DB_HOST} port=${PORT-$DB_PORT} + local unix_socket_directory=$(get_unix_socket_directory "$POSTGRES_DB_PATH") tlog "Granting permissions to user '${user}' on schema '$schema' in database '$db' on host '$host' and port '$port'..." - psql -h ${host} -p ${port} -U ${postgres_admin_user} -d ${db} -c "GRANT ALL PRIVILEGES ON SCHEMA ${schema} TO ${user};" - psql -h ${host} -p ${port} -U ${postgres_admin_user} -d ${db} -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA ${schema} TO ${user};" - psql -h ${host} -p ${port} -U ${postgres_admin_user} -d ${db} -c "ALTER DEFAULT PRIVILEGES IN SCHEMA ${schema} GRANT ALL PRIVILEGES ON TABLES TO ${user};" - psql -h ${host} -p ${port} -U ${postgres_admin_user} -d ${db} -c "GRANT CONNECT ON DATABASE ${db} TO ${user};" + psql -h "$unix_socket_directory" -p "$port" -U "$POSTGRES_ADMIN_USER" -d "$db" -c "GRANT ALL PRIVILEGES ON SCHEMA ${schema} TO ${user};" + psql -h "$unix_socket_directory" -p "$port" -U "$POSTGRES_ADMIN_USER" -d "$db" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA ${schema} TO ${user};" + psql -h "$unix_socket_directory" -p "$port" -U "$POSTGRES_ADMIN_USER" -d "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA ${schema} GRANT ALL PRIVILEGES ON TABLES TO ${user};" + psql -h "$unix_socket_directory" -p "$port" -U "$POSTGRES_ADMIN_USER" -d "$db" -c "GRANT CONNECT ON DATABASE ${db} TO ${user};" +} + +get_unix_socket_directory() { + local postgres_db_path=${1:-"$POSTGRES_DB_PATH"} + local unix_socket_directory + unix_socket_directory=$(grep -E "^unix_socket_directories" "$postgres_db_path/postgresql.conf" | sed -E "s/.*= (.*).*/\1/" | cut -d',' -f1) + # If unix_socket_directory is empty, default to /var/run/postgresql + if [ -z "$unix_socket_directory" ]; then + unix_socket_directory="/var/run/postgresql" + fi + echo "$unix_socket_directory" } # Example usage of the functions # waitForPostgresAvailability # extract_postgres_db_params "postgresql://user:password@localhost:5432/dbname" # init_pg_db -# USER="user" SCHEMA="schema" DB="db" HOST="host" PORT="port" grant_permissions_for_schema +# USER="user" SCHEMA="schema" DB="db" HOST="host" PORT="port" grant_permissions_for_local_db_schema +# get_unix_socket_directory "/var/lib/postgresql/12/main" \ No newline at end of file diff --git a/deploy/docker/fs/opt/appsmith/postgres/appsmith_hba.conf b/deploy/docker/fs/opt/appsmith/postgres/appsmith_hba.conf new file mode 100644 index 00000000000..6112c796d0c --- /dev/null +++ b/deploy/docker/fs/opt/appsmith/postgres/appsmith_hba.conf @@ -0,0 +1,24 @@ +# This is a custom configuration for Embedded PostgreSQL for Appsmith. +# This file will be used to override the default pg_hba.conf file on restart. + +# What is the meaning of this configuration? +# This configuration changes how PostgreSQL authenticates users +# connecting to the database. For user "postgres", we are allowing +# all connections from all addresses without any password on the unix +# socket. For all other users, we are allowing connections from IPv4 +# and IPv6 with a password. + +# TYPE DATABASE USER ADDRESS METHOD +# "local" is for Unix domain socket connections only +local all postgres trust +# IPv4 local connections: +host appsmith appsmith 127.0.0.1/32 scram-sha-256 +host postgres appsmith 127.0.0.1/32 scram-sha-256 +# IPv6 local connections: +host appsmith appsmith ::1/128 scram-sha-256 +host postgres appsmith ::1/128 scram-sha-256 +# Allow replication connections from localhost, by a user with the +# replication privilege. +local replication all scram-sha-256 +host replication all 127.0.0.1/32 scram-sha-256 +host replication all ::1/128 scram-sha-256 \ No newline at end of file diff --git a/deploy/docker/tests/.gitignore b/deploy/docker/tests/.gitignore new file mode 100644 index 00000000000..412c2574750 --- /dev/null +++ b/deploy/docker/tests/.gitignore @@ -0,0 +1 @@ +docker-compose.yml \ No newline at end of file diff --git a/deploy/docker/tests/composes.sh b/deploy/docker/tests/composes.sh new file mode 100644 index 00000000000..c1d1b190776 --- /dev/null +++ b/deploy/docker/tests/composes.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# set -o errexit +# set -x + +generate_compose_file() { + local version=$1 + check_appsmith_edition + cat <${docker_compose_path} +services: + appsmith: + image: index.docker.io/appsmith/appsmith-$edition:$version + container_name: $container_name + ports: + - "80:80" + - "443:443" + volumes: + - ${stacks_path}:/appsmith-stacks + environment: + - APPSMITH_CLOUD_SERVICES_BASE_URL=https://release-cs.appsmith.com + restart: unless-stopped +EOF +} + +compose_appsmith_version() { + local version=$1 + generate_compose_file $version + docker compose up -d +} + +compose_appsmith_latest() { + local version=latest + check_appsmith_edition + + generate_compose_file $version + docker compose pull && + docker compose up -d +} + +compose_appsmith_local() { + local version=latest + check_appsmith_edition + + cat <${docker_compose_path} +services: + appsmith: + image: appsmith/appsmith-local-$edition:$version + container_name: $container_name + ports: + - "80:80" + - "443:443" + volumes: + - ${stacks_path}:/appsmith-stacks + environment: + - APPSMITH_CLOUD_SERVICES_BASE_URL=https://release-cs.appsmith.com + restart: unless-stopped +EOF + + docker compose up -d + + # return container name + echo "$container_name" +} + +cleanup() { + echo "Starting fresh. Cleaning up the environment." + docker rm -f $container_name || true + sudo rm -rf ${stacks_path} || true +} + +check_appsmith_edition() { + export edition=ce + if [[ "$(git remote get-url origin)" == *appsmithorg/appsmith-ee* ]]; then + export edition=ee + fi + echo "Edition: $edition" +} + +container_name="appsmith-docker-test" +# mkdir -p /tmp/$container_name +stacks_path="/tmp/$container_name-stacks" +docker_compose_path="docker-compose.yml" \ No newline at end of file diff --git a/deploy/docker/tests/test-pg-auth.sh b/deploy/docker/tests/test-pg-auth.sh new file mode 100755 index 00000000000..854de8a962e --- /dev/null +++ b/deploy/docker/tests/test-pg-auth.sh @@ -0,0 +1,279 @@ +#!/bin/bash +set -o errexit +# set -x + +source ./composes.sh + + +# Function to update the APPSMITH_DB_URL in docker.env +# Once postgres is the default db, the APPSMITH_POSTGRES_DB_URL will be removed and this step won't be required anymore +# Check run-java.sh for more details why we need to update the APPSMITH_DB_URL to point to postgres +update_db_url() { + docker exec "${container_name}" bash -c "sed -i 's|^APPSMITH_DB_URL=mongodb|# &|' /appsmith-stacks/configuration/docker.env" + docker exec "${container_name}" bash -c "sed -i 's|^APPSMITH_POSTGRES_DB_URL=|APPSMITH_DB_URL=|' /appsmith-stacks/configuration/docker.env" +} + +# Function to check if the Appsmith instance is up +is_appsmith_instance_ready() { + local max_retries=200 + local retry_count=0 + local response_code + + while [ $retry_count -lt $max_retries ]; do + response_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/health) + if [[ $response_code -eq 200 ]]; then + echo "Appsmith instance is ready." + return 0 + fi + echo "Waiting for Appsmith instance to be ready... (Attempt: $((retry_count + 1)))" + retry_count=$((retry_count + 1)) + sleep 2 + done + return 1 +} + +# Function to wait until the postgres is ready +wait_for_postgres() { + local max_retries=200 + local retry_count=0 + + while [ $retry_count -lt $max_retries ]; do + if docker exec "${container_name}" pg_isready; then + echo "Postgres is ready." + return 0 + fi + echo "Waiting for Postgres to be ready... (Attempt: $((retry_count + 1)))" + retry_count=$((retry_count + 1)) + sleep 2 + done +} + +# Function to read the password from the PostgreSQL URL in docker.env.sh +get_appsmith_password() { + local password + password=$(docker exec "${container_name}" bash -c "grep -i 'APPSMITH_DB_URL' /appsmith-stacks/configuration/docker.env | sed -n 's/^.*\/\/appsmith:\([^@]*\)@.*$/\1/p'") + printf "%s" "$password" +} + +# Function to check the read access to databases +check_user_datasource_access_with_auth() { + local password + local appsmith_user_local_access + local appsmith_user_remote_access + password=$(get_appsmith_password) + docker exec -i "${container_name}" bash -c "psql -h 127.0.0.1 -p 5432 -U appsmith -c '\l'" <