import { useState, type FormEvent } from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { SettingsInput } from "#/components/features/settings/settings-input"; import { SettingsSwitch } from "#/components/features/settings/settings-switch"; import { BrandButton } from "#/components/features/settings/brand-button"; import { SectionCard } from "#/components/features/automations/detail/section-card"; import CogIcon from "#/icons/cog.svg?react"; import { displayErrorToast, displaySuccessToast, } from "#/utils/custom-toast-handlers"; import { getApiErrorMessage } from "#/utils/api-error-message"; import { getErrorStatus } from "#/hooks/query/use-settings"; import { useCheckGitSyncConfig, useUpdateGitSyncConfig, } from "#/hooks/query/use-git-sync"; import type { GitSyncConfigUpdateRequest, GitSyncStatus, } from "#/types/git-sync"; /** * Identifies the save-and-sync button in the submit event. Read from the * event's `submitter` rather than tracked on click: native field validation * (the author email, the interval) can swallow a submit after the click has * already happened, and a flag set there would still be armed when Save is * pressed next -- syncing off a button that never asked for it. */ const SAVE_AND_SYNC = "git-sync-save-and-sync"; interface GitSyncConfigFormProps { status: GitSyncStatus; canManage: boolean; /** * Runs a sync cycle. The same handler the overview's Sync now button uses, * so a save-and-sync is followed by the one activity row rather than * starting a cycle nothing on the page is watching. */ onSyncNow: () => void; } export function GitSyncConfigForm({ status, canManage, onSyncNow, }: GitSyncConfigFormProps) { const { t } = useTranslation("openhands"); const { mutate: updateConfig, isPending } = useUpdateGitSyncConfig(); const { mutateAsync: checkConfig, isPending: isChecking } = useCheckGitSyncConfig(); // The settings that failed their reachability check, so the same submit // pressed a second time saves them anyway. Keyed by the values that were // checked rather than a plain flag: editing any of them changes the key, // which re-arms the check without the field handlers having to clear it. const [failedCheck, setFailedCheck] = useState<{ key: string; detail: string; } | null>(null); const [intervalHasChanged, setIntervalHasChanged] = useState(false); const [repoUrlHasChanged, setRepoUrlHasChanged] = useState(false); const [branchHasChanged, setBranchHasChanged] = useState(false); const [pathHasChanged, setPathHasChanged] = useState(false); const [authorNameHasChanged, setAuthorNameHasChanged] = useState(false); const [authorEmailHasChanged, setAuthorEmailHasChanged] = useState(false); // `null` means untouched, so the switch keeps following the server state // (including a status refetch) until the operator actually flips it. const [enabledOverride, setEnabledOverride] = useState(null); const enabled = enabledOverride ?? status.enabled; const enabledHasChanged = enabledOverride !== null && enabled !== status.enabled; const [tokenText, setTokenText] = useState(""); const [clearToken, setClearToken] = useState(false); const [encryptionKeyText, setEncryptionKeyText] = useState(""); const [clearEncryptionKey, setClearEncryptionKey] = useState(false); const tokenHasChanged = tokenText.trim().length > 0 || clearToken; const encryptionKeyHasChanged = encryptionKeyText.trim().length > 0 || clearEncryptionKey; const formIsClean = !enabledHasChanged && !intervalHasChanged && !repoUrlHasChanged && !branchHasChanged && !pathHasChanged && !authorNameHasChanged && !authorEmailHasChanged && !tokenHasChanged && !encryptionKeyHasChanged; const resetChangeFlags = () => { setEnabledOverride(null); setIntervalHasChanged(false); setRepoUrlHasChanged(false); setBranchHasChanged(false); setPathHasChanged(false); setAuthorNameHasChanged(false); setAuthorEmailHasChanged(false); setTokenText(""); setClearToken(false); setEncryptionKeyText(""); setClearEncryptionKey(false); setFailedCheck(null); }; // The secret inputs are remounted -- and therefore emptied -- whenever the // matching clear switch flips, so drop the typed value with them. Keeping it // in state would leave the field marked dirty against an empty input and // submit `token: ""`, an override that fails every later push with // `fatal: Authentication failed`. const toggleClearToken = (isToggled: boolean) => { setClearToken(isToggled); setTokenText(""); }; const toggleClearEncryptionKey = (isToggled: boolean) => { setClearEncryptionKey(isToggled); setEncryptionKeyText(""); }; // A cleared field posts `null` -- clear the override and fall back to the // environment default -- rather than `""`, which the backend stored as a // literal empty override. An empty branch or path then made the next git // command fatal (`git checkout -B ""`, `git add -A -- ""`), wedging every // subsequent sync cycle with that error in the status banner. const clearedFieldAsNull = (formData: FormData, name: string) => formData.get(name)?.toString().trim() || null; const save = (body: GitSyncConfigUpdateRequest, thenSync: boolean) => { updateConfig(body, { onSuccess: () => { displaySuccessToast(t(I18nKey.AUTOMATIONS$GIT_SYNC$CONFIG_SAVED)); // Only on success: clearing the flags after a failure would disable // Save and silently un-toggle the clear switches, leaving no way to // retry the change that was just rejected. resetChangeFlags(); // A cycle runs off the stored configuration, so it can only be // triggered once the save has landed -- and a rejected save leaves // nothing new to sync. if (thenSync) onSyncNow(); }, onError: (error) => { displayErrorToast( // 409 is the backend refusing to enable sync in a deployment that // booted with it off -- a restart with the env var set, not a // transient failure the operator should retry. getErrorStatus(error) === 409 ? t(I18nKey.AUTOMATIONS$GIT_SYNC$ENABLE_BLOCKED_ERROR) : getApiErrorMessage(error, t(I18nKey.ERROR$GENERIC)), ); }, }); }; // What the reachability check actually depends on. A save that touches only // the interval or the author has nothing for the remote to reject, so it // shouldn't cost a round trip to the repo. const remoteFieldsOf = (body: GitSyncConfigUpdateRequest) => (["repo_url", "branch", "token"] as const) .filter((field) => field in body) .map((field) => `${field}=${body[field]}`) .join("&"); /** The reason these settings can't reach their repo, or null to go ahead. */ const reachabilityFailure = async (body: GitSyncConfigUpdateRequest) => { try { const result = await checkConfig(body); return result.ok ? null : (result.detail ?? t(I18nKey.AUTOMATIONS$GIT_SYNC$CHECK_FAILED_TITLE)); } catch { // A check that couldn't run says nothing about the configuration -- // an automation backend that predates the endpoint answers 404 -- and // must never be what stands between an operator and saving. return null; } }; // A plain submit handler rather than `
`: React resets an // uncontrolled form as soon as the action returns, which wiped every edit // while the save was still in flight -- so a rejected save left the operator // with the old values and nothing to retry. const handleSubmit = async (event: FormEvent) => { event.preventDefault(); const thenSync = (event.nativeEvent as SubmitEvent).submitter?.getAttribute("name") === SAVE_AND_SYNC; const formData = new FormData(event.currentTarget); const body: GitSyncConfigUpdateRequest = {}; if (enabledHasChanged) { body.enabled = enabled; } if (intervalHasChanged) { // Blank reads as manual-only rather than clearing the override, so an // emptied field can never be mistaken for "keep syncing on a timer". const raw = formData.get("git-sync-interval-input")?.toString().trim(); const parsed = Number(raw); body.interval_seconds = raw && Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : 0; } if (repoUrlHasChanged) { body.repo_url = clearedFieldAsNull(formData, "git-sync-repo-url-input"); } if (branchHasChanged) { body.branch = clearedFieldAsNull(formData, "git-sync-branch-input"); } if (pathHasChanged) { body.path = clearedFieldAsNull(formData, "git-sync-path-input"); } if (authorNameHasChanged) { body.author_name = clearedFieldAsNull( formData, "git-sync-author-name-input", ); } if (authorEmailHasChanged) { body.author_email = clearedFieldAsNull( formData, "git-sync-author-email-input", ); } // The secrets come from state rather than FormData: the clear switch // remounts (and empties) their inputs, so the two disagree for a render // and only state knows what the operator actually typed. const token = tokenText.trim(); const encryptionKey = encryptionKeyText.trim(); if (clearToken) { body.token = null; } else if (token) { body.token = token; } if (clearEncryptionKey) { body.encryption_key = null; } else if (encryptionKey) { body.encryption_key = encryptionKey; } // Test the settings before storing them, so a mistyped repo URL or a // rejected token is caught here instead of by the next sync cycle. The // same values submitted twice save anyway: an operator who disagrees with // the check -- or whose remote refuses `ls-remote` but accepts a push -- // must not be locked out of their own configuration. const remoteFields = remoteFieldsOf(body); if (remoteFields && failedCheck?.key !== remoteFields) { const detail = await reachabilityFailure(body); if (detail) { setFailedCheck({ key: remoteFields, detail }); return; } } setFailedCheck(null); save(body, thenSync); }; return ( } title={t(I18nKey.AUTOMATIONS$GIT_SYNC$CONFIG_TITLE)} >
{t(I18nKey.AUTOMATIONS$GIT_SYNC$FIELD_ENABLED)}

{t(I18nKey.AUTOMATIONS$GIT_SYNC$ENABLED_HELP)}

setIntervalHasChanged( value.trim() !== String(status.interval_seconds), ) } />

{t(I18nKey.AUTOMATIONS$GIT_SYNC$INTERVAL_HELP)}

setRepoUrlHasChanged(value.trim() !== status.repo_url) } /> setBranchHasChanged(value.trim() !== status.branch) } /> setPathHasChanged(value.trim() !== status.path)} />
setAuthorNameHasChanged(true)} /> setAuthorEmailHasChanged(true)} />

{t(I18nKey.AUTOMATIONS$GIT_SYNC$AUTHOR_HELP)}

{t(I18nKey.AUTOMATIONS$GIT_SYNC$TOKEN_HELP)}

{t(I18nKey.AUTOMATIONS$GIT_SYNC$CLEAR_TOKEN)}

{t(I18nKey.AUTOMATIONS$GIT_SYNC$ENCRYPTION_KEY_HELP)}

{t(I18nKey.AUTOMATIONS$GIT_SYNC$CLEAR_ENCRYPTION_KEY)}
{failedCheck && (

{t(I18nKey.AUTOMATIONS$GIT_SYNC$CHECK_FAILED_TITLE)}

{failedCheck.detail}

{t(I18nKey.AUTOMATIONS$GIT_SYNC$CHECK_FAILED_HINT)}

)}
{!isPending && !isChecking && t(I18nKey.SETTINGS$SAVE_CHANGES)} {isChecking && t(I18nKey.AUTOMATIONS$GIT_SYNC$CHECKING)} {isPending && t(I18nKey.SETTINGS$SAVING)} {t(I18nKey.AUTOMATIONS$GIT_SYNC$SAVE_AND_SYNC)}
); }