mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
GitLab Synchronization: Initial commit
This commit is contained in:
@@ -418,6 +418,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
cioSiteId: Settings.analytics?.cio?.siteId,
|
||||
linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId,
|
||||
githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret,
|
||||
gitlabSyncEnabled: !!Settings.gitlabSync?.clientID && !!Settings.gitlabSync?.clientSecret,
|
||||
zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret,
|
||||
enablePandocConversions: Settings.enablePandocConversions,
|
||||
mixpanelLabsToken:
|
||||
|
||||
@@ -16,6 +16,7 @@ const trackChangesModuleAvailable =
|
||||
* @property {Object | undefined} apis.linkedUrlProxy
|
||||
* @property {string | undefined} apis.linkedUrlProxy.url
|
||||
* @property {boolean | undefined} enableGithubSync
|
||||
* @property {boolean | undefined} enableGitLabSync
|
||||
* @property {boolean | undefined} enableGitBridge
|
||||
* @property {boolean | undefined} enableHomepage
|
||||
* @property {boolean | undefined} enableSaml
|
||||
@@ -61,6 +62,8 @@ const Features = {
|
||||
return Boolean(Settings.disableLinkSharing) === false
|
||||
case 'github-sync':
|
||||
return Boolean(Settings.enableGithubSync)
|
||||
case 'gitlab-sync':
|
||||
return Boolean(Settings.enableGitLabSync)
|
||||
case 'git-bridge':
|
||||
return Boolean(Settings.enableGitBridge)
|
||||
case 'oauth':
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const thirdPartyIcons = [
|
||||
{ file: 'git.svg', label: 'Git' },
|
||||
{ file: 'github.svg', label: 'GitHub' },
|
||||
{ file: 'gitlab.svg', label: 'GitLab' },
|
||||
{ file: 'dropbox.svg', label: 'Dropbox' },
|
||||
{ file: 'zotero_64x64x32.png', label: 'Zotero' },
|
||||
{ file: 'mendeley.svg', label: 'Mendeley' },
|
||||
|
||||
@@ -1098,6 +1098,10 @@ module.exports = {
|
||||
__dirname,
|
||||
'../modules/github-sync/frontend/js/components/github-sync-widget.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/gitlab-sync/frontend/js/components/gitlab-sync-widget.tsx'
|
||||
),
|
||||
],
|
||||
referenceLinkingWidgets: [
|
||||
Path.resolve(
|
||||
@@ -1117,6 +1121,18 @@ module.exports = {
|
||||
'../modules/github-sync/frontend/js/components/import-from-github-menu.tsx'
|
||||
),
|
||||
],
|
||||
importProjectFromGitLabModalWrapper: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/gitlab-sync/frontend/js/components/import-from-gitlab-modal-wrapper.tsx'
|
||||
),
|
||||
],
|
||||
importProjectFromGitLabMenu: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/gitlab-sync/frontend/js/components/import-from-gitlab-menu.tsx'
|
||||
),
|
||||
],
|
||||
editorLeftMenuSync: [],
|
||||
editorLeftMenuManageTemplate: [],
|
||||
menubarExtraComponents: [
|
||||
@@ -1178,6 +1194,10 @@ module.exports = {
|
||||
__dirname,
|
||||
'../modules/github-sync/frontend/js/components/github-integration-card.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/gitlab-sync/frontend/js/components/gitlab-integration-card.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx'
|
||||
@@ -1220,6 +1240,7 @@ module.exports = {
|
||||
'template-gallery',
|
||||
'git-bridge',
|
||||
'github-sync',
|
||||
'gitlab-sync',
|
||||
'zotero',
|
||||
],
|
||||
viewIncludes: {},
|
||||
|
||||
@@ -9,6 +9,7 @@ function Origin({ origin }: Pick<LoadedUpdate['meta'], 'origin'>) {
|
||||
if (origin?.kind === 'upload') result = t('history_entry_origin_upload')
|
||||
if (origin?.kind === 'git-bridge') result = t('history_entry_origin_git')
|
||||
if (origin?.kind === 'github') result = t('history_entry_origin_github')
|
||||
if (origin?.kind === 'gitlab') result = t('history_entry_origin_gitlab')
|
||||
|
||||
if (result) {
|
||||
return <span className="history-version-origin">({result})</span>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Meta {
|
||||
| 'upload'
|
||||
| 'git-bridge'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'history-resync'
|
||||
| 'history-migration'
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const RailLayout = () => {
|
||||
const { features } = useProjectContext()
|
||||
const { isRestrictedTokenMember } = useEditorContext()
|
||||
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
||||
const { isOverleaf, githubSyncEnabled, zoteroEnabled } = getMeta('ol-ExposedSettings')
|
||||
const { isOverleaf, githubSyncEnabled, gitlabSyncEnabled, zoteroEnabled } = getMeta('ol-ExposedSettings')
|
||||
|
||||
const { view, setSettingsShown, focusMode } = useLayoutContext()
|
||||
|
||||
@@ -119,7 +119,7 @@ export const RailLayout = () => {
|
||||
icon: 'integration_instructions',
|
||||
title: t('integrations'),
|
||||
component: <IntegrationsPanel />,
|
||||
hide: !isOverleaf && !gitBridgeEnabled && !githubSyncEnabled && !zoteroEnabled,
|
||||
hide: !isOverleaf && !gitBridgeEnabled && !githubSyncEnabled && !gitlabSyncEnabled && !zoteroEnabled,
|
||||
},
|
||||
{
|
||||
key: 'review-panel',
|
||||
@@ -149,6 +149,7 @@ export const RailLayout = () => {
|
||||
isOverleaf,
|
||||
gitBridgeEnabled,
|
||||
githubSyncEnabled,
|
||||
gitlabSyncEnabled,
|
||||
zoteroEnabled,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -235,7 +235,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
|
||||
}
|
||||
if (
|
||||
update.meta.type === 'external' &&
|
||||
(update.meta.source === 'git-bridge' || update.meta.source === 'github')
|
||||
(update.meta.source === 'git-bridge' || update.meta.source === 'github' || update.meta.source === 'gitlab')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const integrationPanelComponents = allIntegrationPanelComponents.filter(
|
||||
({ path }) =>
|
||||
(getMeta('ol-gitBridgeEnabled') || !path.includes('git-bridge')) &&
|
||||
(getMeta('ol-ExposedSettings').githubSyncEnabled || !path.includes('github-sync')) &&
|
||||
(getMeta('ol-ExposedSettings').gitlabSyncEnabled || !path.includes('gitlab-sync')) &&
|
||||
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero'))
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ function NewProjectButton({
|
||||
align,
|
||||
}: NewProjectButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { templateLinks, githubSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
const { templateLinks, githubSyncEnabled, gitlabSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
const [modal, setModal] =
|
||||
useState<Nullable<NewProjectButtonModalVariant>>(null)
|
||||
const portalTemplates = getMeta('ol-portalTemplates') || []
|
||||
@@ -169,11 +169,19 @@ function NewProjectButton({
|
||||
const [importProjectFromGithubMenu] = !githubSyncEnabled ? [] : importOverleafModules(
|
||||
'importProjectFromGithubMenu'
|
||||
)
|
||||
|
||||
const [importProjectFromGitLabMenu] = !gitlabSyncEnabled ? [] : importOverleafModules(
|
||||
'importProjectFromGitLabMenu'
|
||||
)
|
||||
|
||||
const ImportProjectFromGithubMenu: JSXElementConstructor<{
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}> = importProjectFromGithubMenu?.import.default
|
||||
|
||||
const ImportProjectFromGitLabMenu: JSXElementConstructor<{
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}> = importProjectFromGitLabMenu?.import.default
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
@@ -258,8 +266,8 @@ function NewProjectButton({
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
<li role="none">
|
||||
{ImportProjectFromGithubMenu && (
|
||||
{ImportProjectFromGithubMenu && (
|
||||
<li role="none">
|
||||
<ImportProjectFromGithubMenu
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
@@ -268,8 +276,20 @@ function NewProjectButton({
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
</li>
|
||||
)}
|
||||
{ImportProjectFromGitLabMenu && (
|
||||
<li role="none">
|
||||
<ImportProjectFromGitLabMenu
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
modalVariant: 'import_from_gitlab',
|
||||
dropdownMenuEvent: 'import-from-gitlab',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{portalTemplates.length > 0 ? (
|
||||
<>
|
||||
<DropdownDivider />
|
||||
|
||||
+11
@@ -15,6 +15,7 @@ export type NewProjectButtonModalVariant =
|
||||
| 'example_project'
|
||||
| 'upload_project'
|
||||
| 'import_from_github'
|
||||
| 'import_from_gitlab'
|
||||
| 'import_docx'
|
||||
| 'import_markdown'
|
||||
|
||||
@@ -36,6 +37,14 @@ function NewProjectButtonModal({
|
||||
onHide: () => void
|
||||
}> = importProjectFromGithubModalWrapper?.import.default
|
||||
|
||||
const [importProjectFromGitLabModalWrapper] = importOverleafModules(
|
||||
'importProjectFromGitLabModalWrapper'
|
||||
)
|
||||
const ImportProjectFromGitLabModalWrapper: JSXElementConstructor<{
|
||||
onHide: () => void
|
||||
}> = importProjectFromGitLabModalWrapper?.import.default
|
||||
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
const openProject = useCallback(
|
||||
@@ -82,6 +91,8 @@ function NewProjectButtonModal({
|
||||
)
|
||||
case 'import_from_github':
|
||||
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
|
||||
case 'import_from_gitlab':
|
||||
return <ImportProjectFromGitLabModalWrapper onHide={onHide} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
+18
-1
@@ -68,7 +68,7 @@ function WelcomeMessageCreateNewProjectDropdown({
|
||||
useFeatureFlag('import-markdown') &&
|
||||
getMeta('ol-ExposedSettings').enablePandocConversions
|
||||
|
||||
const { isOverleaf, githubSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
const { isOverleaf, githubSyncEnabled, gitlabSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
|
||||
const handleDropdownItemClick = useCallback(
|
||||
(
|
||||
@@ -187,6 +187,23 @@ function WelcomeMessageCreateNewProjectDropdown({
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
{(isOverleaf || gitlabSyncEnabled) && (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={e =>
|
||||
handleDropdownItemClick(
|
||||
e,
|
||||
'import_from_gitlab',
|
||||
'import-from-gitlab'
|
||||
)
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('import_from_gitlab')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
{(portalTemplates?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
<DropdownDivider />
|
||||
|
||||
@@ -19,11 +19,12 @@ const availableLangFeedbackLinkingWidgets = importOverleafModules(
|
||||
|
||||
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
||||
const githubSyncEnabled = getMeta('ol-ExposedSettings').githubSyncEnabled
|
||||
const gitlabSyncEnabled = getMeta('ol-ExposedSettings').gitlabSyncEnabled
|
||||
const zoteroEnabled = getMeta('ol-ExposedSettings').zoteroEnabled
|
||||
|
||||
const availableIntegrationLinkingWidgets = allAvailableIntegrationLinkingWidgets.filter(
|
||||
({ path }) =>
|
||||
(githubSyncEnabled || !path.includes('github-sync'))
|
||||
(githubSyncEnabled || !path.includes('github-sync')) && (gitlabSyncEnabled || !path.includes('gitlab-sync'))
|
||||
)
|
||||
|
||||
function LinkingSection() {
|
||||
@@ -51,7 +52,7 @@ function LinkingSection() {
|
||||
}[]
|
||||
|
||||
const renderSyncSection =
|
||||
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled
|
||||
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled || gitlabSyncEnabled
|
||||
|
||||
const allIntegrationLinkingWidgets = integrationLinkingWidgets.concat(
|
||||
oauth2ServerComponents
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
function GitLabLogoBlack({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 380 380"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M282.83,170.73l-.27-.69-26.14-68.22a6.81,6.81,0,0,0-2.69-3.24,7,7,0,0,0-8,.43,7,7,0,0,0-2.32,3.52l-17.65,54H154.29l-17.65-54A6.86,6.86,0,0,0,134.32,99a7,7,0,0,0-8-.43,6.87,6.87,0,0,0-2.69,3.24L97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82,19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91,40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitLabLogoBlack
|
||||
@@ -0,0 +1,32 @@
|
||||
function GitLabLogo({ size = 40 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 200 200"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g transform="translate(-90 -90)">
|
||||
<path
|
||||
d="m265.26416 174.37243-.2134-.55822-21.19899-55.30908c-.4236-1.08359-1.18542-1.99642-2.17699-2.62689-.98837-.63373-2.14749-.93253-3.32305-.87014-1.1689.0624-2.29195.48925-3.20809 1.21821-.90957.73554-1.56629 1.73047-1.87493 2.85346l-14.31327 43.80662h-57.90965l-14.31327-43.80662c-.30864-1.12299-.96536-2.11791-1.87493-2.85346-.91614-.72895-2.03911-1.15582-3.20809-1.21821-1.17548-.0624-2.33468.23641-3.32297.87014-.99166.63047-1.75348 1.5433-2.17707 2.62689l-21.19891 55.31237-.21348.55493c-6.28158 16.38521-.92929 34.90803 13.05891 45.48782.0262.0164.0492.0361.0755.0558l.18719.14119 32.29094 24.17392 15.97151 12.09024 9.71951 7.34871c2.34117 1.77316 5.57877 1.77316 7.92002 0l9.71943-7.34871 15.96822-12.09024 32.48142-24.31511c.0296-.023.0559-.0427.0854-.0657 13.97834-10.57977 19.32735-29.09604 13.04905-45.47796z"
|
||||
fill="#E24329"
|
||||
/>
|
||||
<path
|
||||
d="m265.26416 174.37243-.2134-.55822c-10.5174 2.16062-20.20405 6.6099-28.49844 12.81593-.1346.0985-25.20497 19.05805-46.55171 35.19699 15.84998 11.98517 29.6477 22.40405 29.6477 22.40405l32.48142-24.31511c.0296-.023.0559-.0427.0854-.0657 13.97834-10.57977 19.32735-29.09604 13.04905-45.47796z"
|
||||
fill="#FC6D26"
|
||||
/>
|
||||
<path
|
||||
d="m160.34962 244.23117 15.97151 12.09024 9.71951 7.34871c2.34117 1.77316 5.57877 1.77316 7.92002 0l9.71943-7.34871 15.96822-12.09024s-13.79772-10.41888-29.6477-22.40405c-15.85327 11.98517-29.65099 22.40405-29.65099 22.40405z"
|
||||
fill="#FCA326"
|
||||
/>
|
||||
<path
|
||||
d="m143.44561 186.63014c-8.29111-6.20274-17.97446-10.65531-28.49507-12.81264l-.21348.55493c-6.28158 16.38521-.92929 34.90803 13.05891 45.48782.0262.0164.0492.0361.0755.0558l.18719.14119 32.29094 24.17392s13.79772-10.41888 29.65099-22.40405c-21.34673-16.13894-46.42031-35.09848-46.55499-35.19699z"
|
||||
fill="#FC6D26"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitLabLogo
|
||||
@@ -16,4 +16,5 @@
|
||||
@import '../../../modules/admin-tools/frontend/styles/manage-users-page';
|
||||
@import '../../../modules/admin-tools/frontend/styles/pagination-dark';
|
||||
@import '../../../modules/github-sync/frontend/styles/github-sync';
|
||||
@import '../../../modules/gitlab-sync/frontend/styles/gitlab-sync';
|
||||
@import '../../../modules/reference-picker/frontend/styles/reference-picker';
|
||||
|
||||
@@ -246,6 +246,11 @@
|
||||
"ask_proj_owner_to_upgrade_for_full_history": "Please ask the project owner to upgrade to access this project’s full history.",
|
||||
"ask_repo_owner_to_reconnect": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
|
||||
"ask_repo_owner_to_renew_overleaf_subscription": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
|
||||
"ask_proj_owner_to_add_you_as_gitlab_collaborator": "You don’t have permission to push to the linked repository <0>__repoFullName__</0>. Ask the owner of the project (<1>__projectOwnerEmail__</1>) to add you as a collaborator to this repository.",
|
||||
"ask_proj_owner_to_export_to_gitlab": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to export the project to a Git repository and add you as a collaborator.",
|
||||
"ask_proj_owner_to_unlink_from_current_gitlab": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to unlink the project from the current git repository and create a connection to a different repository.",
|
||||
"ask_gitlab_repo_owner_to_reconnect": "Ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
|
||||
"ask_gitlab_repo_owner_to_renew_overleaf_subscription": "Ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
|
||||
"at_most_x_libraries_can_be_selected": "At most __maxCount__ libraries can be selected",
|
||||
"attach_image_or_pdf": "Attach image or PDF",
|
||||
"audit_logging": "Audit logging",
|
||||
@@ -386,6 +391,7 @@
|
||||
"checking": "Checking",
|
||||
"checking_dropbox_status": "Checking Dropbox status",
|
||||
"checking_project_github_status": "Checking project status in GitHub",
|
||||
"checking_project_gitlab_status": "Checking project status in GitLab",
|
||||
"choose_a_custom_color": "Choose a custom color",
|
||||
"choose_a_new_password": "Choose a new password",
|
||||
"choose_from_group_members": "Choose from group members",
|
||||
@@ -510,6 +516,7 @@
|
||||
"contacting_the_sales_team": "Contacting the Sales team",
|
||||
"continue": "Continue",
|
||||
"continue_github_merge": "I have manually merged. Continue",
|
||||
"continue_gitlab_merge": "I have manually merged. Continue",
|
||||
"continue_to": "Continue to __appName__",
|
||||
"continue_using_free_features": "Continue using our free features",
|
||||
"continue_with_free_plan": "Continue with free plan",
|
||||
@@ -544,6 +551,7 @@
|
||||
"create_new_tag": "Create new tag",
|
||||
"create_password": "Create password",
|
||||
"create_project_in_github": "Create a GitHub repository",
|
||||
"create_project_in_gitlab": "Create a GitLab repository",
|
||||
"create_your_account": "Create your account",
|
||||
"created": "Created",
|
||||
"created_at": "Created at",
|
||||
@@ -876,6 +884,7 @@
|
||||
"export_as_markdown": "Export as Markdown (.md)",
|
||||
"export_csv": "Export CSV",
|
||||
"export_project_to_github": "Export Project to GitHub",
|
||||
"export_project_to_gitlab": "Export Project to GitLab",
|
||||
"failed": "Failed",
|
||||
"failed_to_consent_to_workbench_terms": "Failed to consent to workbench terms. Please try again later.",
|
||||
"failed_to_publish_as_a_template": "Failed to publish as a template.",
|
||||
@@ -977,6 +986,7 @@
|
||||
"from_external_url": "From external URL",
|
||||
"from_filename": "From <0>__filename__</0>",
|
||||
"from_github": "From GitHub",
|
||||
"from_gitlab": "From GitLab",
|
||||
"from_project_files": "From project files",
|
||||
"from_provider": "From __provider__",
|
||||
"from_url": "From URL",
|
||||
@@ -1072,6 +1082,41 @@
|
||||
"github_workflow_files_delete_github_repo": "The repository has been created on GitHub but linking was unsuccessful. You will have to delete GitHub repository or choose a new name.",
|
||||
"github_workflow_files_error": "The __appName__ GitHub sync service couldn’t sync GitHub Workflow files (in .github/workflows/). Please authorize __appName__ to edit your GitHub workflow files and try again.",
|
||||
"github_workflow_files_error_non_owner": "The __appName__ GitHub sync service couldn’t sync GitHub Workflow files (in .github/workflows/). Please ensure the project owner has authorized __appName__ to edit this project’s GitHub workflow files and try again.",
|
||||
"gitlab": "GitLab",
|
||||
"gitlab_commit_message_placeholder": "Commit message for changes made in __appName__...",
|
||||
"gitlab_credentials_expired": "Your GitLab authorization credentials have expired",
|
||||
"gitlab_empty_repository_error": "It looks like your GitLab repository is empty or not yet available. Create a new file on GitLab.com then try again.",
|
||||
"gitlab_file_name_error": "This repository cannot be imported, because it contains file(s) with an invalid filename:",
|
||||
"gitlab_file_sync_error": "We are unable to sync the following files:",
|
||||
"gitlab_git_and_dropbox_integrations": "<0>GitLab</0>, <0>Git</0> and <0>Dropbox</0> integrations",
|
||||
"gitlab_git_and_reference_manager_integrations": "GitLab, Git and reference manager integrations",
|
||||
"gitlab_git_folder_error": "This project contains a .git folder at the top level, indicating that it is already a git repository. The Overleaf GitLab sync service cannot sync git histories. Please remove the .git folder and try again.",
|
||||
"gitlab_integration": "GitLab integration",
|
||||
"gitlab_integration_lowercase": "Git and GitLab integration",
|
||||
"gitlab_is_no_longer_connected": "GitLab is no longer connected to this project.",
|
||||
"gitlab_is_premium": "GitLab Sync is a premium feature",
|
||||
"gitlab_large_files_error": "Merge failed: your GitLab repository contains files over the 50mb file size limit ",
|
||||
"gitlab_manual_merge_user_prompt": "Please manually resolve the merge request associated with the branch <0>__sharelatex_branch__</0>. Click below to continue, after you have manually merged.",
|
||||
"gitlab_merge_failed_error": "Your changes in __appName__ and GitLab could not be automatically merged.",
|
||||
"gitlab_no_master_branch_error": "This repository cannot be imported as it is missing a default branch. Please make sure the project has a default branch",
|
||||
"gitlab_private_description": "You choose who can see and commit to this repository.",
|
||||
"gitlab_public_description": "Anyone can see this repository. You choose who can commit.",
|
||||
"gitlab_repository_diverged": "The default branch of the linked repository has been force-pushed. The list of recent commits below may be incomplete due to rewritten history since the last sync.",
|
||||
"gitlab_successfully_linked_description": "Thanks, we’ve successfully linked your GitLab account to __appName__. You can now export your __appName__ projects to GitLab, or import projects from your GitLab repositories.",
|
||||
"gitlab_symlink_error": "Your GitLab repository contains symbolic link files, which are not currently supported by Overleaf. Please remove these and try again.",
|
||||
"gitlab_sync_description": "With GitLab Sync you can link your __appName__ projects to GitLab repositories, create new commits from __appName__, and merge commits from GitLab.",
|
||||
"gitlab_sync_error": "Sorry, there was a problem checking our GitLab service. Please try again in a few moments.",
|
||||
"gitlab_sync_repository_not_found_description": "The linked repository has either been removed, or you no longer have access to it. You can set up sync with a new repository by cloning the project and using the ‘GitLab’ menu item. You can also unlink the repository from this project.",
|
||||
"gitlab_timeout_error": "Syncing your Overleaf project with GitLab has timed out. This may be due to the overall size of your project, or the number of files/changes to sync, being too large.",
|
||||
"gitlab_too_many_files_error": "This repository cannot be imported as it exceeds the maximum number of files allowed",
|
||||
"gitlab_validation_check": "Please check that the repository name is valid, and that you have permission to create the repository.",
|
||||
"gitlab_validation_check_auth": "Please check that you have permission to create the repository.",
|
||||
"gitlab_validation_name_exists": "Repository with this name already exists.",
|
||||
"gitlab_validation_name_invalid": "Repository name is invalid.",
|
||||
"gitlab_workflow_authorize": "Authorize GitLab Workflow files",
|
||||
"gitlab_workflow_files_delete_gitlab_repo": "The repository has been created on GitLab but linking was unsuccessful. You will have to delete GitLab repository or choose a new name.",
|
||||
"gitlab_workflow_files_error": "The __appName__ GitLab sync service couldn’t sync GitLab Workflow files (in .gitlab/workflows/). Please authorize __appName__ to edit your GitLab workflow files and try again.",
|
||||
"gitlab_workflow_files_error_non_owner": "The __appName__ GitLab sync service couldn’t sync GitLab Workflow files (in .gitlab/workflows/). Please ensure the project owner has authorized __appName__ to edit this project’s GitLab workflow files and try again.",
|
||||
"give-ai-consent": "Give AI consent",
|
||||
"give_feedback": "Give feedback",
|
||||
"global": "global",
|
||||
@@ -1153,6 +1198,7 @@
|
||||
"history_entry_origin_dropbox": "via Dropbox",
|
||||
"history_entry_origin_git": "via Git",
|
||||
"history_entry_origin_github": "via GitHub",
|
||||
"history_entry_origin_gitlab": "via GitLab",
|
||||
"history_entry_origin_upload": "upload",
|
||||
"history_label_created_by": "Created by",
|
||||
"history_label_project_current_state": "Current state",
|
||||
@@ -1217,7 +1263,9 @@
|
||||
"import_a_bibtex_file_from_your_provider_account": "Import a BibTeX file from your __provider__ account",
|
||||
"import_document_description": "Content will be imported from the selected document. Formatting may not be reproduced exactly.",
|
||||
"import_existing_projects_from_github": "Import existing projects from GitHub",
|
||||
"import_existing_projects_from_gitlab": "Import existing projects from GitLab",
|
||||
"import_from_github": "Import from GitHub",
|
||||
"import_from_gitlab": "Import from GitLab",
|
||||
"import_idp_metadata": "Import IdP metadata",
|
||||
"import_markdown_file": "Import Markdown file",
|
||||
"import_to_sharelatex": "Import to __appName__",
|
||||
@@ -1232,6 +1280,7 @@
|
||||
"imported_from_zotero_at_date_by": "Imported from Zotero at __formattedDate__ __relativeDate__ by __importedByName__",
|
||||
"importing": "Importing",
|
||||
"importing_and_merging_changes_in_github": "Importing and merging changes in GitHub",
|
||||
"importing_and_merging_changes_in_gitlab": "Importing and merging changes in GitLab",
|
||||
"in_order_to_have_a_secure_account_make_sure_your_password": "To help keep your account secure, make sure your new password:",
|
||||
"in_order_to_match_institutional_metadata_2": "In order to match your institutional metadata, we’ve linked your account using <0>__email__</0>.",
|
||||
"in_order_to_match_institutional_metadata_associated": "In order to match your institutional metadata, your account is associated with the email <b>__email__</b>.",
|
||||
@@ -1298,6 +1347,7 @@
|
||||
"integrations": "Integrations",
|
||||
"integrations_include": "Integrations include:",
|
||||
"integrations_like_github": "Integrations like GitHub Sync",
|
||||
"integrations_like_gitlab": "Integrations like GitLab Sync",
|
||||
"interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <0>__price__</0> Personal plan?",
|
||||
"invalid_certificate": "Invalid certificate. Please check the certificate and try again.",
|
||||
"invalid_confirmation_code": "That didn’t work. Please check the code and try again.",
|
||||
@@ -1480,6 +1530,8 @@
|
||||
"link_sharing_is_on": "Link sharing is on",
|
||||
"link_to_github": "Link to your GitHub account",
|
||||
"link_to_github_description": "You need to authorise __appName__ to access your GitHub account to allow us to sync your projects.",
|
||||
"link_to_gitlab": "Link to your GitLab account",
|
||||
"link_to_gitlab_description": "You need to authorise __appName__ to access your GitLab account to allow us to sync your projects.",
|
||||
"link_to_mendeley": "Link to Mendeley",
|
||||
"link_to_papers": "Link to Papers",
|
||||
"link_to_zotero": "Link to Zotero",
|
||||
@@ -1493,9 +1545,11 @@
|
||||
"loading": "Loading",
|
||||
"loading_content": "Creating Project",
|
||||
"loading_github_repositories": "Loading your GitHub repositories",
|
||||
"loading_gitlab_repositories": "Loading your GitLab repositories",
|
||||
"loading_prices": "loading prices",
|
||||
"loading_python_runtime": "Loading Python runtime...",
|
||||
"loading_recent_github_commits": "Loading recent commits",
|
||||
"loading_recent_gitlab_commits": "Loading recent commits",
|
||||
"loading_references": "Loading references",
|
||||
"local_account": "Local account",
|
||||
"log_entry_description": "Log entry with level: __level__",
|
||||
@@ -1716,6 +1770,7 @@
|
||||
"no_members": "No members",
|
||||
"no_messages_yet": "No messages yet",
|
||||
"no_new_commits_in_github": "No new commits in GitHub since last merge.",
|
||||
"no_new_commits_in_gitlab": "No new commits in GitLab since last merge.",
|
||||
"no_one_has_commented_or_left_any_suggestions_yet": "No one has commented or left any suggestions yet.",
|
||||
"no_one_invited_yet": "No one invited yet",
|
||||
"no_one_sharing_link_is_disabled": "No one—sharing link is disabled",
|
||||
@@ -1792,6 +1847,7 @@
|
||||
"only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.",
|
||||
"only_invited_people": "Only invited people",
|
||||
"only_project_owner_can_link_github": "Only the project owner can link this project to a GitHub repository. Please contact the project owner if you would like to set this up.",
|
||||
"only_project_owner_can_link_gitlab": "Only the project owner can link this project to a GitLab repository. Please contact the project owner if you would like to set this up.",
|
||||
"open_action_menu": "Open __name__ action menu",
|
||||
"open_advanced_reference_search": "Open advanced reference search",
|
||||
"open_as_template": "Open as Template",
|
||||
@@ -2046,6 +2102,7 @@
|
||||
"project_linked_to": "This project is linked to",
|
||||
"project_name": "Project name",
|
||||
"project_not_linked_to_github": "This project is not linked to a GitHub repository. You can create a repository for it in GitHub:",
|
||||
"project_not_linked_to_gitlab": "This project is not linked to a GitLab repository. You can create a repository for it in GitLab:",
|
||||
"project_notifications": "Project notifications",
|
||||
"project_notifications_muted_description": "You are not receiving any notifications, as you have disabled all project notifications.",
|
||||
"project_ownership_transfer_confirmation_1": "Are you sure you want to make <0>__user__</0> the owner of <1>__project__</1>?",
|
||||
@@ -2079,9 +2136,12 @@
|
||||
"publisher_account": "Publisher Account",
|
||||
"publishing": "Publishing",
|
||||
"pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__",
|
||||
"pull_gitlab_changes_into_sharelatex": "Pull GitLab changes into __appName__",
|
||||
"purge": "Purge",
|
||||
"push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub",
|
||||
"push_sharelatex_changes_to_gitlab": "Push __appName__ changes to GitLab",
|
||||
"push_to_github_pull_to_overleaf": "Push to GitHub, pull to __appName__",
|
||||
"push_to_gitlab_pull_to_overleaf": "Push to GitLab, pull to __appName__",
|
||||
"quoted_text": "Quoted text",
|
||||
"raw_logs": "Raw logs",
|
||||
"raw_logs_description": "Raw logs from the LaTeX compiler",
|
||||
@@ -2102,9 +2162,11 @@
|
||||
"real_time_track_changes": "Real-time track-changes",
|
||||
"realtime_track_changes": "Real-time track changes",
|
||||
"reauthorize_github_account": "Reauthorize your GitHub Account",
|
||||
"reauthorize_gitlab_account": "Reauthorize your GitLab Account",
|
||||
"recaptcha_conditions": "The site is protected by reCAPTCHA.",
|
||||
"recent": "Recent",
|
||||
"recent_commits_in_github": "Recent commits in GitHub",
|
||||
"recent_commits_in_gitlab": "Recent commits in GitLab",
|
||||
"recommended": "Recommended",
|
||||
"recompile": "Recompile",
|
||||
"recompile_from_scratch": "Recompile from scratch",
|
||||
@@ -2371,6 +2433,7 @@
|
||||
"select_from_source_files": "select from source files",
|
||||
"select_from_your_computer": "select from your computer",
|
||||
"select_github_repository": "Select a GitHub repository to import into __appName__.",
|
||||
"select_gitlab_repository": "Select a GitLab repository to import into __appName__.",
|
||||
"select_image_from_project_files": "Select image from project files",
|
||||
"select_project": "Select __project__",
|
||||
"select_projects": "Select Projects",
|
||||
@@ -2636,11 +2699,15 @@
|
||||
"sync_all_projects_with_dropbox": "Sync all projects with Dropbox.",
|
||||
"sync_dropbox_github": "Sync with Dropbox and GitHub",
|
||||
"sync_project_to_github_explanation": "Any changes you have made in __appName__ will be committed and merged with any updates in GitHub.",
|
||||
"sync_project_to_gitlab_explanation": "Any changes you have made in __appName__ will be committed and merged with any updates in GitLab.",
|
||||
"sync_to_dropbox": "Sync to Dropbox",
|
||||
"sync_to_github": "Sync to GitHub",
|
||||
"sync_to_gitlab": "Sync to GitLab",
|
||||
"sync_with_a_github_repository": "Sync with a GitHub repository.",
|
||||
"sync_with_a_gitlab_repository": "Sync with a GitLab repository.",
|
||||
"sync_with_dropbox": "Sync with Dropbox",
|
||||
"sync_with_github": "Sync with GitHub",
|
||||
"sync_with_gitlab": "Sync with GitLab",
|
||||
"synctex_error_recompile_and_try_again": "That didn’t work. Recompile and try again.",
|
||||
"synctex_failed": "Couldn’t find the corresponding source file",
|
||||
"syntax_checks": "Syntax checks",
|
||||
@@ -2790,6 +2857,7 @@
|
||||
"to_continue_using_upgrade_or_change_your_browser": "To continue using __appName__ without problems you need to upgrade or change to a <0>supported browser</0>.",
|
||||
"to_fix_this_you_can": "To fix this, you can:",
|
||||
"to_fix_this_you_can_ask_the_github_repository_owner": "To fix this, you can ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
|
||||
"to_fix_this_you_can_ask_the_gitlab_repository_owner": "To fix this, you can ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
|
||||
"to_insert_or_move_a_caption_make_sure_tabular_is_directly_within_table": "To insert or move a caption, make sure \\begin{tabular} is directly within a table environment",
|
||||
"to_keep_edit_access": "To keep edit access, ask the project owner to upgrade their plan or reduce the number of people with edit access.",
|
||||
"to_modify_your_subscription_go_to": "To modify your subscription go to",
|
||||
@@ -2942,6 +3010,8 @@
|
||||
"unlink_from_sso": "Unlink from SSO",
|
||||
"unlink_github_repository": "Unlink GitHub repository",
|
||||
"unlink_github_warning": "Any projects that you have synced with GitHub will be disconnected and no longer kept in sync with GitHub. Are you sure you want to unlink your GitHub account?",
|
||||
"unlink_gitlab_repository": "Unlink GitLab repository",
|
||||
"unlink_gitlab_warning": "Any projects that you have synced with GitLab will be disconnected and no longer kept in sync with GitLab. Are you sure you want to unlink your GitLab account?",
|
||||
"unlink_linked_accounts": "Unlink any linked accounts (such as ORCID ID, IEEE). <0>Remove them in Account settings (under Linked Accounts).</0>",
|
||||
"unlink_linked_google_account": "Unlink your Google account. <0>Remove it in Account settings (under Linked Accounts).</0>",
|
||||
"unlink_provider_account_title": "Unlink __provider__ Account",
|
||||
@@ -2949,6 +3019,7 @@
|
||||
"unlink_reference": "Unlink __provider__ References Provider",
|
||||
"unlink_sso": "Unlink SSO",
|
||||
"unlink_the_project_from_the_current_github_repo": "Unlink the project from the current GitHub repository and create a connection to a repository you own.",
|
||||
"unlink_the_project_from_the_current_gitlab_repo": "Unlink the project from the current GitLab repository and create a connection to a repository you own.",
|
||||
"unlink_user_explanation": "You’re about to remove the SSO login option for <0>__email__</0>. This will force them to reauthenticate their Overleaf account with your IdP. They’ll receive an email asking them to do this.",
|
||||
"unlink_users": "Unlink users",
|
||||
"unlink_warning_reference": "Warning: When you unlink your account from __provider__ you will not be able to import references into your projects.",
|
||||
@@ -3153,6 +3224,7 @@
|
||||
"work_or_university_sso": "Work/university single sign-on",
|
||||
"work_with_non_overleaf_users": "Work with non-Overleaf users",
|
||||
"work_with_other_github_users": "Work with other GitHub users",
|
||||
"work_with_other_gitlab_users": "Work with other GitLab users",
|
||||
"writefull": "Writefull",
|
||||
"x_changes_in": "__count__ change in",
|
||||
"x_changes_in_plural": "__count__ changes in",
|
||||
@@ -3221,6 +3293,7 @@
|
||||
"you_will_be_able_to_reassign_subscription": "You will be able to reassign their subscription membership to another person in your organization",
|
||||
"youll_get_best_results_in_visual_but_can_be_used_in_source": "You’ll get the best results from using this tool in the <0>Visual Editor</0>, although you can still use it to insert tables in the <1>Code Editor</1>. Once you’ve selected the number of rows and columns you need, the table will appear in your document and you can double click in a cell to add contents to it.",
|
||||
"youll_need_to_ask_the_github_repository_owner": "You’ll need to ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
|
||||
"youll_need_to_ask_the_gitlab_repository_owner": "You’ll need to ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
|
||||
"youll_no_longer_need_to_remember_credentials": "You’ll no longer need to remember a separate email address and password. Instead, you will use single-sign on to login to Overleaf. <0>Read more about SSO</0>.",
|
||||
"your_account_is_managed_by_admin_cant_join_additional_group": "Your __appName__ account is managed by your current group admin (__admin__). This means you can’t join additional group subscriptions. <0>Read more about Managed Users.</0>",
|
||||
"your_account_is_managed_by_your_group_admin": "Your account is managed by your group admin. You can’t change or delete your email address.",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
||||
export const GitLabSyncProjectStatesSchema = new Schema(
|
||||
{
|
||||
projectId: { type: ObjectId, ref: 'Project', required: true, unique: true },
|
||||
repoFullName: { type: String, required: true },
|
||||
defaultBranchName: { type: String, default: null },
|
||||
mergeStatus: { type: String, enum: ['clean', 'conflict', 'diverged'], default: 'clean' },
|
||||
lastSyncCommit: { type: String, default: null },
|
||||
lastSyncVersion: { type: Number, default: null },
|
||||
unmergedBranchName: { type: String, default: null },
|
||||
unmergedBranchHead: { type: String, default: null },
|
||||
conflictVersion: { type: Number, default: null },
|
||||
},
|
||||
{ collection: 'gitlabSyncProjectStates', minimize: false }
|
||||
)
|
||||
|
||||
export const GitLabSyncProjectStates = mongoose.model(
|
||||
'GitLabSyncProjectStates',
|
||||
GitLabSyncProjectStatesSchema,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
||||
export const GitLabSyncUserCredentialsSchema = new Schema(
|
||||
{
|
||||
userId: { type: ObjectId, ref: 'User', required: true, unique: true },
|
||||
gitlab: { type: String, required: true },
|
||||
},
|
||||
{ collection: 'gitlabSyncUserCredentials', minimize: false }
|
||||
)
|
||||
|
||||
export const GitLabSyncUserCredentials = mongoose.model(
|
||||
'GitLabSyncUserCredentials',
|
||||
GitLabSyncUserCredentialsSchema,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
import fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import Path from 'node:path'
|
||||
import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor'
|
||||
|
||||
const TOKEN_CIPHER_FILE = '/var/lib/overleaf/data/.token-cipher.json'
|
||||
const TOKEN_CIPHER_LABEL = 'OL_CEP-v3'
|
||||
|
||||
let encryptorInstance = null
|
||||
|
||||
/**
|
||||
* Get or create a stable encryptor data that persists across container
|
||||
* recreations. Priority:
|
||||
* 1. GITLAB_CIPHER_PASSWORD || TOKEN_CIPHER_PASSWORD env var (explicit user config)
|
||||
* 2. JSON file with encryptor data in the persistent volume (/var/lib/overleaf/data/)
|
||||
* — auto-generated on first use, survives container rebuilds
|
||||
*/
|
||||
function _getEncryptorData() {
|
||||
const cipherPassword = process.env.GITLAB_TOKEN_CIPHER_PASSWORD || process.env.TOKEN_CIPHER_PASSWORD
|
||||
const cipherLabel = process.env.GITLAB_TOKEN_CIPHER_LABEL ||
|
||||
process.env.TOKEN_CIPHER_LABEL ||
|
||||
TOKEN_CIPHER_LABEL
|
||||
if (cipherPassword) {
|
||||
return {
|
||||
cipherLabel,
|
||||
cipherPasswords: {
|
||||
[cipherLabel]: cipherPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
// cipherPassword is not set, use file
|
||||
const cipherFile = process.env.GITLAB_TOKEN_CIPHER_FILE ||
|
||||
process.env.TOKEN_CIPHER_FILE ||
|
||||
TOKEN_CIPHER_FILE
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(cipherFile, 'utf8'))
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
logger.info({ cipherFile }, 'Creating new cipherFile')
|
||||
} else {
|
||||
logger.error({ err, cipherFile }, 'Bad cipherFile')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// File doesn't exist yet — generate one
|
||||
try {
|
||||
const encryptorData = {
|
||||
cipherLabel,
|
||||
cipherPasswords: {
|
||||
[cipherLabel]: crypto.randomBytes(32).toString('base64')
|
||||
},
|
||||
}
|
||||
|
||||
const dir = Path.dirname(cipherFile)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(cipherFile, JSON.stringify(encryptorData, null, 2), { mode: 0o600 })
|
||||
return encryptorData
|
||||
|
||||
} catch (err) {
|
||||
logger.error({ err, cipherFile }, 'Cannot create cipherFile')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function _getEncryptor() {
|
||||
if (!encryptorInstance) {
|
||||
const encryptorData = _getEncryptorData()
|
||||
encryptorInstance = new AccessTokenEncryptorClass(encryptorData)
|
||||
}
|
||||
return encryptorInstance
|
||||
}
|
||||
|
||||
export const AccessTokenEncryptor = {
|
||||
async encryptJson(json) {
|
||||
return await _getEncryptor().promises.encryptJson(json)
|
||||
},
|
||||
async decryptToJson(encryptedJson) {
|
||||
return await _getEncryptor().promises.decryptToJson(encryptedJson)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import {
|
||||
fetchJson,
|
||||
fetchJsonWithResponse,
|
||||
fetchNothing,
|
||||
fetchStream,
|
||||
RequestFailedError,
|
||||
} from '@overleaf/fetch-utils'
|
||||
|
||||
import {
|
||||
InvalidTokenError,
|
||||
ExpiredTokenError,
|
||||
NotFoundError,
|
||||
GitConflictError,
|
||||
RateLimitError,
|
||||
ProviderRequestError,
|
||||
PermissionDeniedError,
|
||||
AlreadyExistsError,
|
||||
} from './GitSyncErrors.mjs'
|
||||
|
||||
const GITLAB_URL = process.env.GITLAB_SYNC_URL
|
||||
const GITLAB_API_BASE = `${GITLAB_URL}/api/v4`
|
||||
const GITLAB_DEFAULT_BRANCH = process.env.GITLAB_DEFAULT_BRANCH || 'main'
|
||||
const MAX_PER_PAGE = 100
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 60 * 1000
|
||||
const REQUEST_LONG_TIMEOUT_MS = 600 * 1000
|
||||
|
||||
const MERGE_REQUEST_POLL_INTERVAL_MS = process.env.GITLAB_MERGE_REQUEST_POLL_INTERVAL_MS || 1000
|
||||
const MERGE_REQUEST_TIMEOUT_MS = process.env.GITLAB_MERGE_REQUEST_TIMEOUT_MS || 60_000
|
||||
|
||||
const mergeRequestPendingStates = new Set([
|
||||
'unchecked',
|
||||
'checking',
|
||||
'preparing',
|
||||
'approvals_syncing',
|
||||
])
|
||||
|
||||
const mergeRequestBlockingStates = new Set([
|
||||
'conflict',
|
||||
'ci_must_pass',
|
||||
'ci_still_running',
|
||||
'commits_status',
|
||||
'discussions_not_resolved',
|
||||
'draft_status',
|
||||
'jira_association_missing',
|
||||
'merge_request_blocked',
|
||||
'merge_time',
|
||||
'need_rebase',
|
||||
'not_approved',
|
||||
'not_open',
|
||||
'requested_changes',
|
||||
'security_policy_pipeline_check',
|
||||
'security_policy_violations',
|
||||
'status_checks_must_pass',
|
||||
'locked_paths',
|
||||
'locked_lfs_files',
|
||||
'title_regex',
|
||||
])
|
||||
|
||||
|
||||
|
||||
const maxConcurrency = process.env.GITLAB_API_MAX_CONCURRENCY || 5
|
||||
|
||||
function buildHeaders(token) {
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Overleaf-CEP-GitLab-Sync',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function encodePath(path) {
|
||||
return encodeURIComponent(path)
|
||||
}
|
||||
|
||||
function projectPath(repoFullName) {
|
||||
return encodeURIComponent(repoFullName)
|
||||
}
|
||||
|
||||
function isRateLimitError(response) {
|
||||
return response.headers.get('retry-after') != null
|
||||
}
|
||||
|
||||
function isRepositoryAlreadyExistsError(err) {
|
||||
if (err.response?.status !== 400) {
|
||||
return false
|
||||
}
|
||||
|
||||
const json_data = JSON.parse(err.body)
|
||||
return Object.values(json_data.message).some(value => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.includes("has already been taken");
|
||||
}
|
||||
return value === "has already been taken";
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeGitLabError(err, operation) {
|
||||
const body = err.body || ''
|
||||
logger.error({ operation, err, body }, 'GitLab API request failed')
|
||||
|
||||
if (err.name === 'AbortError') {
|
||||
throw new ProviderRequestError('GitLab request timed out', { status: 504 }, err)
|
||||
}
|
||||
|
||||
if (!(err instanceof RequestFailedError)) {
|
||||
throw new ProviderRequestError('Something wrong with GitLab request', { status: 500 }, err)
|
||||
}
|
||||
|
||||
const status = err.response?.status || 500
|
||||
|
||||
if (status === 401) {
|
||||
const json_body = JSON.parse(err.body) || {}
|
||||
if (json_body && json_body.error && json_body.error.toLowerCase() === 'invalid_token') {
|
||||
throw new ExpiredTokenError('Token expired', { status }, err)
|
||||
}
|
||||
|
||||
throw new InvalidTokenError('Invalid token', { status }, err)
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
throw new NotFoundError('Not found', { status }, err)
|
||||
}
|
||||
|
||||
if (status === 409) {
|
||||
throw new GitConflictError()
|
||||
}
|
||||
|
||||
if (status === 403 || status === 429) {
|
||||
if (isRateLimitError(err.response)) throw new RateLimitError('Rate limit exeeded', { status: 429 }, err)
|
||||
throw new PermissionDeniedError('Permission denied', { status: 403 }, err)
|
||||
}
|
||||
|
||||
if (status === 400) {
|
||||
if (isRepositoryAlreadyExistsError(err)) {
|
||||
throw new AlreadyExistsError('Repository already exists', { status }, err)
|
||||
}
|
||||
}
|
||||
|
||||
throw new ProviderRequestError('GitLab request failed', { status }, err)
|
||||
}
|
||||
|
||||
// wrappers
|
||||
function fetchGitLabJson(url, options, operation) {
|
||||
return fetchJson(url, options).catch(err => {
|
||||
normalizeGitLabError(err, operation)
|
||||
})
|
||||
}
|
||||
|
||||
function fetchGitLabJsonWithResponse(url, options, operation) {
|
||||
return fetchJsonWithResponse(url, options).catch(err => {
|
||||
normalizeGitLabError(err, operation)
|
||||
})
|
||||
}
|
||||
|
||||
function fetchGitLabNothing(url, options, operation) {
|
||||
return fetchNothing(url, options).catch(err => {
|
||||
normalizeGitLabError(err, operation)
|
||||
})
|
||||
}
|
||||
|
||||
function fetchGitLabStream(url, options, operation) {
|
||||
return fetchStream(url, options).catch(err => {
|
||||
normalizeGitLabError(err, operation)
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchAllPages(url, options, operation) {
|
||||
let page = 1
|
||||
let all = []
|
||||
|
||||
while (true) {
|
||||
const pageUrl = new URL(url.toString())
|
||||
pageUrl.searchParams.set('page', page.toString())
|
||||
|
||||
const { json, response } = await fetchGitLabJsonWithResponse(pageUrl.toString(), options, operation)
|
||||
all = all.concat(json)
|
||||
|
||||
const link = response.headers.get('link') || ''
|
||||
if (!link.includes('rel="next"')) break
|
||||
page++
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
function getTokenRefreshTimestamp(token, safetyMarginInSec = 300) {
|
||||
return token.created_at + token.expires_in - safetyMarginInSec;
|
||||
}
|
||||
|
||||
// ---------------------- exports ------------------------------- //
|
||||
|
||||
// OAuth
|
||||
function getOAuth2Url() {
|
||||
const oAuthUrl = new URL(`${GITLAB_URL}/oauth/authorize`)
|
||||
oAuthUrl.searchParams.append('client_id', Settings.gitlabSync.clientID)
|
||||
oAuthUrl.searchParams.append('redirect_uri', Settings.gitlabSync.callbackURL)
|
||||
oAuthUrl.searchParams.append('response_type', 'code')
|
||||
oAuthUrl.searchParams.append('scope', 'api read_user read_repository write_repository')
|
||||
return oAuthUrl
|
||||
}
|
||||
|
||||
function exchangeCodeForToken(code) {
|
||||
return fetchGitLabJson(`${GITLAB_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
json: {
|
||||
client_id: Settings.gitlabSync.clientID,
|
||||
client_secret: Settings.gitlabSync.clientSecret,
|
||||
code: code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: Settings.gitlabSync.callbackURL,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'exchangeCodeForToken').then(r => [r.access_token, r.refresh_token, getTokenRefreshTimestamp(r)] )
|
||||
}
|
||||
|
||||
function refreshToken(refreshToken) {
|
||||
return fetchGitLabJson(`${GITLAB_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
json: {
|
||||
client_id: Settings.gitlabSync.clientID,
|
||||
client_secret: Settings.gitlabSync.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
redirect_uri: Settings.gitlabSync.callbackURL,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'refreshToken').then(r => [r.access_token, r.refresh_token, getTokenRefreshTimestamp(r)])
|
||||
}
|
||||
|
||||
function revokeToken(token) {
|
||||
const { clientID, clientSecret } = Settings.gitlabSync
|
||||
return fetchGitLabNothing(`${GITLAB_URL}/oauth/revoke`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
json: {
|
||||
client_id: clientID,
|
||||
client_secret: clientSecret,
|
||||
token,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'revokeToken')
|
||||
}
|
||||
|
||||
// user, orgs, permissions
|
||||
function getUser(token) {
|
||||
return fetchGitLabJson(`${GITLAB_API_BASE}/user`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'getUser').then(({ id, username, name }) => ({ id, login: username, name }))
|
||||
}
|
||||
|
||||
async function getUserAndOrgs(token) {
|
||||
const user = await fetchGitLabJson(`${GITLAB_API_BASE}/user`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'getUserAndOrgs:user')
|
||||
|
||||
const groupsUrl = new URL(`${GITLAB_API_BASE}/groups`)
|
||||
groupsUrl.searchParams.set('all_available', 'true')
|
||||
groupsUrl.searchParams.set('min_access_level', '30')
|
||||
groupsUrl.searchParams.set('top_level_only', 'true')
|
||||
groupsUrl.searchParams.set('per_page', MAX_PER_PAGE.toString())
|
||||
groupsUrl.searchParams.set('simple', 'true')
|
||||
|
||||
const groups = await fetchAllPages(groupsUrl, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'getUserAndOrgs:groups')
|
||||
|
||||
return {
|
||||
user: user.username,
|
||||
orgs: groups.map(group => group.full_path),
|
||||
}
|
||||
}
|
||||
|
||||
function getPushPermission(token, repoFullName) {
|
||||
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'getPushPermission').then(project => {
|
||||
const projectAccess = project.permissions?.project_access?.access_level || 0
|
||||
const groupAccess = project.permissions?.group_access?.access_level || 0
|
||||
return Math.max(projectAccess, groupAccess) >= 30
|
||||
})
|
||||
}
|
||||
|
||||
// repos
|
||||
function resolveNamespaceId(token, org) {
|
||||
if (org == null) return Promise.resolve(null)
|
||||
if (/^\d+$/.test(org)) return Promise.resolve(Number(org))
|
||||
|
||||
return fetchGitLabJson(`${GITLAB_API_BASE}/groups/${encodePath(org)}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'resolveNamespaceId').then(group => group.id)
|
||||
}
|
||||
|
||||
async function createRepo(token, { name, description, isPublic, org }) {
|
||||
const namespaceId = await resolveNamespaceId(token, org)
|
||||
const payload = {
|
||||
name,
|
||||
description,
|
||||
visibility: isPublic ? 'public' : 'private',
|
||||
initialize_with_readme: false,
|
||||
default_branch: GITLAB_DEFAULT_BRANCH,
|
||||
merge_method: 'merge', // Use merge here to allow for conflict resolution to work properly without the need for a rebase
|
||||
}
|
||||
|
||||
if (namespaceId != null) {
|
||||
payload.namespace_id = namespaceId
|
||||
}
|
||||
|
||||
return fetchGitLabJson(`${GITLAB_API_BASE}/projects`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
json: payload,
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
|
||||
}, 'createRepo')
|
||||
}
|
||||
|
||||
async function listUserRepos(token) {
|
||||
const url = new URL(`${GITLAB_API_BASE}/projects`)
|
||||
url.searchParams.set('membership', 'true')
|
||||
url.searchParams.set('per_page', MAX_PER_PAGE.toString())
|
||||
url.searchParams.set('simple', 'true')
|
||||
url.searchParams.set('order_by', 'last_activity_at')
|
||||
url.searchParams.set('sort', 'desc')
|
||||
url.searchParams.set('active', 'true') // Only list projects that are not archived and not marked for deletion
|
||||
|
||||
const repos = await fetchAllPages(url, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'listUserRepos')
|
||||
|
||||
return repos.map(repo => ({
|
||||
name: repo.name,
|
||||
fullName: repo.path_with_namespace,
|
||||
defaultBranchName: repo.default_branch,
|
||||
}))
|
||||
}
|
||||
|
||||
// Git (blobs / trees / commits)
|
||||
function uploadBlob(token, repoFullName, buffer) {
|
||||
void token
|
||||
void repoFullName
|
||||
|
||||
if (Buffer.isBuffer(buffer)) {
|
||||
return Promise.resolve(buffer.toString('base64'))
|
||||
}
|
||||
|
||||
if (typeof buffer === 'string') {
|
||||
return Promise.resolve(buffer)
|
||||
}
|
||||
|
||||
return Promise.resolve(String(buffer))
|
||||
}
|
||||
|
||||
function getBlobStream(token, repoFullName, ref, path) {
|
||||
const encodedPath = encodePath(path)
|
||||
return fetchGitLabStream(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/files/${encodedPath}/raw?ref=${encodeURIComponent(ref)}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS),
|
||||
}, 'getBlobStream')
|
||||
}
|
||||
|
||||
function createTree(token, repoFullName, entries, baseTree) {
|
||||
void token
|
||||
void repoFullName
|
||||
|
||||
const existingPaths = new Set((baseTree || []).map(entry => entry.path))
|
||||
const actions = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.sha == null) {
|
||||
actions.push({
|
||||
action: 'delete',
|
||||
file_path: entry.path,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
actions.push({
|
||||
action: existingPaths.has(entry.path) ? 'update' : 'create',
|
||||
file_path: entry.path,
|
||||
content: entry.content,
|
||||
encoding: 'base64',
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
baseTree: baseTree || [],
|
||||
entries: actions,
|
||||
})
|
||||
}
|
||||
|
||||
function createCommit(token, repoFullName, { tree, message, branch, start_sha, force = false }) {
|
||||
const actions = Array.isArray(tree?.entries) ? tree.entries : []
|
||||
|
||||
let payload = {
|
||||
branch: branch || GITLAB_DEFAULT_BRANCH,
|
||||
commit_message: message,
|
||||
actions,
|
||||
}
|
||||
|
||||
if (branch && branch != GITLAB_DEFAULT_BRANCH) {
|
||||
payload.start_sha = start_sha
|
||||
}
|
||||
|
||||
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/commits`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
json: payload,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'createCommit').then(r => r.id) // For GitLab id refers to the SHA of the created commit
|
||||
}
|
||||
|
||||
function getCommitTree(token, repoFullName, commit) {
|
||||
return listBlobsAtCommit(token, repoFullName, commit)
|
||||
}
|
||||
|
||||
async function listBlobsAtCommit(token, repoFullName, commit) {
|
||||
const url = new URL(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/tree`)
|
||||
url.searchParams.set('ref', commit)
|
||||
url.searchParams.set('recursive', 'true')
|
||||
url.searchParams.set('per_page', MAX_PER_PAGE.toString())
|
||||
|
||||
const treeEntries = await fetchAllPages(url, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
|
||||
}, 'listBlobsAtCommit')
|
||||
|
||||
return treeEntries
|
||||
.filter(entry => entry.type === 'blob')
|
||||
.map(entry => ({
|
||||
sha: entry.id,
|
||||
path: entry.path,
|
||||
mode: entry.mode,
|
||||
type: 'blob',
|
||||
}))
|
||||
}
|
||||
|
||||
async function getBlobContent(token, repoFullName, sha) {
|
||||
const url = `${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/blobs/${sha}`
|
||||
|
||||
try {
|
||||
const json = await fetchGitLabJson(url, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
|
||||
}, 'getBlobContent')
|
||||
return json.content
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to fetch blob content from GitLab', { repoFullName, sha })
|
||||
}
|
||||
}
|
||||
|
||||
async function listNewCommitsWithStatus(token, fullName, branchName, fromCommit) {
|
||||
const url = new URL(`${GITLAB_API_BASE}/projects/${projectPath(fullName)}/repository/compare`)
|
||||
url.searchParams.set('from', fromCommit)
|
||||
url.searchParams.set('to', branchName)
|
||||
url.searchParams.set('straight', 'true')
|
||||
|
||||
const data = await fetchGitLabJson(url.toString(), {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
|
||||
}, 'listNewCommitsWithStatus')
|
||||
|
||||
const commits = (data.commits || []).map(c => ({
|
||||
message: c.message || c.title || '',
|
||||
author: {
|
||||
name: c.author_name || '',
|
||||
email: c.author_email || '',
|
||||
date: c.created_at || c.committed_date || '',
|
||||
},
|
||||
sha: c.id,
|
||||
}))
|
||||
|
||||
return { commits, diverged: commits.length > 0 }
|
||||
}
|
||||
|
||||
// branches
|
||||
function getBranchHead(token, repoFullName, branchName) {
|
||||
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/branches/${encodePath(branchName)}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'getBranchHead').then(r => r.commit.id)
|
||||
}
|
||||
|
||||
function createBranch(token, repoFullName, branchName, sha) {
|
||||
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/branches`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
json: {
|
||||
branch: branchName,
|
||||
ref: sha,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'createBranch')
|
||||
}
|
||||
|
||||
function updateBranch(token, repoFullName, branchName, sha, force = false) {
|
||||
// Update functionality does not exist in GitLab API as it automatically updates the branch when a commit is made to it
|
||||
}
|
||||
|
||||
function deleteBranch(token, repoFullName, branchName) {
|
||||
return fetchGitLabNothing(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/branches/${encodePath(branchName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'deleteBranch')
|
||||
}
|
||||
|
||||
// merge / compare
|
||||
async function createMergeRequest(token, repoFullName, sourceBranch, targetBranch, title) {
|
||||
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/merge_requests`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
json: {
|
||||
source_branch: sourceBranch,
|
||||
target_branch: targetBranch,
|
||||
title,
|
||||
remove_source_branch: true,
|
||||
squash: false,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'createMergeRequest')
|
||||
}
|
||||
|
||||
async function waitForMergeRequestToBeReady(token, repoFullName, mergeRequestIid) {
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt < MERGE_REQUEST_TIMEOUT_MS) {
|
||||
const mergeRequest = await fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/merge_requests/${mergeRequestIid}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'getMergeRequest')
|
||||
|
||||
const status = mergeRequest.detailed_merge_status
|
||||
|
||||
if (status === 'mergeable') {
|
||||
return {
|
||||
canMerge: true,
|
||||
reason: status,
|
||||
}
|
||||
}
|
||||
|
||||
if (mergeRequestPendingStates.has(status)) {
|
||||
await new Promise(resolve => setTimeout(resolve, MERGE_REQUEST_POLL_INTERVAL_MS))
|
||||
continue
|
||||
}
|
||||
|
||||
if (mergeRequestBlockingStates.has(status)) {
|
||||
return {
|
||||
canMerge: false,
|
||||
reason: status,
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown future GitLab status, treat as unmergeable to be safe
|
||||
return {
|
||||
canMerge: false,
|
||||
reason: `unknown_status:${status}`,
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for merge request ${mergeRequestIid} readiness`)
|
||||
}
|
||||
|
||||
async function mergeOpenMergeRequest(token, repoFullName, mergeRequestIid) {
|
||||
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/merge_requests/${mergeRequestIid}/merge`, {
|
||||
method: 'PUT',
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS),
|
||||
}, 'mergeOpenMergeRequest')
|
||||
}
|
||||
|
||||
function mergeBranch(token, repoFullName, base, head) {
|
||||
return createMergeRequest(token, repoFullName, head, base, `Merge ${head} into ${base}`)
|
||||
.then(async mergeRequest => {
|
||||
const ret = await waitForMergeRequestToBeReady(token, repoFullName, mergeRequest.iid)
|
||||
if (!ret.canMerge) {
|
||||
throw new GitConflictError("Merge request cannot be merged due to conflicts, reason: " + ret.reason)
|
||||
}
|
||||
|
||||
await mergeOpenMergeRequest(token, repoFullName, mergeRequest.iid)
|
||||
return getBranchHead(token, repoFullName, base)
|
||||
})
|
||||
}
|
||||
|
||||
function compareCommits(token, repoFullName, from, to) {
|
||||
const url = new URL(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/compare`)
|
||||
url.searchParams.set('from', from)
|
||||
url.searchParams.set('to', to)
|
||||
url.searchParams.set('straight', 'true')
|
||||
|
||||
return fetchGitLabJson(url.toString(), {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'compareCommits').then(r => r.diffs || [])
|
||||
}
|
||||
|
||||
function getRepoZipball(token, repoFullName, sha) {
|
||||
return fetchGitLabStream(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/archive.zip?sha=${encodeURIComponent(sha)}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS),
|
||||
}, 'getRepoZipball')
|
||||
}
|
||||
|
||||
export default {
|
||||
maxConcurrency,
|
||||
getOAuth2Url,
|
||||
exchangeCodeForToken,
|
||||
refreshToken,
|
||||
revokeToken,
|
||||
getUser,
|
||||
getUserAndOrgs,
|
||||
getPushPermission,
|
||||
createRepo,
|
||||
listUserRepos,
|
||||
uploadBlob,
|
||||
getBlobStream,
|
||||
createTree,
|
||||
createCommit,
|
||||
getCommitTree,
|
||||
listBlobsAtCommit,
|
||||
getBlobContent,
|
||||
listNewCommitsWithStatus,
|
||||
getBranchHead,
|
||||
createBranch,
|
||||
updateBranch,
|
||||
deleteBranch,
|
||||
mergeBranch,
|
||||
compareCommits,
|
||||
getRepoZipball,
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
||||
import Csrf from '../../../../app/src/infrastructure/Csrf.mjs'
|
||||
import GitLabSyncHandler from './GitLabSyncHandler.mjs'
|
||||
import TokenManager from './TokenManager.mjs'
|
||||
import api from './GitLabApiClient.mjs'
|
||||
import { doGitMerge } from './GitMerge.mjs'
|
||||
import { InvalidTokenError, GitNotLinkedError, AlreadyExistsError } from './GitSyncErrors.mjs'
|
||||
|
||||
async function getConnectionStatus(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
try {
|
||||
const isConnected = await GitLabSyncHandler.getGitConnState(userId)
|
||||
res.json(isConnected)
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info }, "failed to check user connection")
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjectState(req, res) {
|
||||
const { project_id: projectId } = req.params
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
try {
|
||||
const pss = await GitLabSyncHandler.getProjectState(userId, projectId)
|
||||
return res.json(pss)
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info }, "failed get project sync state")
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserAndOrgs(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const userAndOrgs = await GitLabSyncHandler.getUserAndOrgs(userId)
|
||||
res.json(userAndOrgs)
|
||||
}
|
||||
async function listUserRepos(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
try {
|
||||
const repos = await GitLabSyncHandler.listUserRepos(userId)
|
||||
res.json({ repos })
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidTokenError) {
|
||||
return res.json(null)
|
||||
}
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ userId, info }, 'Failed to get repositories list')
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function getMergeOverview(req, res) {
|
||||
const { project_id: projectId } = req.params
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
try {
|
||||
const commitsAndStatus = await GitLabSyncHandler.getMergeOverview(userId, projectId)
|
||||
res.json(commitsAndStatus)
|
||||
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, projectId }, 'Error listing commits since last sync')
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Import a GitLab repository as a new project
|
||||
async function importRepo(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { name, fullName, defaultBranchName } = req.body
|
||||
|
||||
try {
|
||||
const projectId = await GitLabSyncHandler.importRepo(userId, name, fullName, defaultBranchName)
|
||||
res.json({ projectId })
|
||||
} catch (error) {
|
||||
logger.error({ error, userId }, 'Failed to import git repository from server')
|
||||
res.status(error.status || 500).json({ message: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect user to Git server OAuth2 authorization URL
|
||||
async function oauth2(req, res) {
|
||||
const oauth2Url = api.getOAuth2Url()
|
||||
oauth2Url.searchParams.append('state', req.csrfToken())
|
||||
res.redirect(oauth2Url.toString())
|
||||
}
|
||||
|
||||
// callback
|
||||
async function oauth2Callback(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { code, state } = req.query
|
||||
|
||||
try {
|
||||
await Csrf.promises.validateToken(state, req.session)
|
||||
} catch {
|
||||
HttpErrorHandler.forbidden(req, res, 'Invalid CSRF token')
|
||||
return
|
||||
}
|
||||
|
||||
let token
|
||||
let refresh_token
|
||||
let refresh_timestamp
|
||||
try {
|
||||
[token, refresh_token, refresh_timestamp] = await api.exchangeCodeForToken(code)
|
||||
if (!token || !refresh_token) {
|
||||
HttpErrorHandler.badRequest(req, res, 'Failed to obtain access token from Git server')
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const err_body = err.body
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, err_body, userId }, 'Failed to obtain access token from Git server')
|
||||
HttpErrorHandler.badRequest(req, res, err.message || 'Bad request')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await TokenManager.saveUserToken(userId, {token, refresh_token, refresh_timestamp})
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, userId }, 'Error saving user token')
|
||||
HttpErrorHandler.handleErrorByStatusCode(req, res, err, errStatus)
|
||||
return
|
||||
}
|
||||
|
||||
// Save success message in session to display on redirect
|
||||
req.session.projectSyncSuccessMessage =
|
||||
req.i18n.translate('gitlab_successfully_linked_description')
|
||||
res.redirect('/user/settings?oauth-complete=gitlab#project-sync')
|
||||
}
|
||||
|
||||
// Unlink user's Git server account
|
||||
async function unlink(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
try {
|
||||
await TokenManager.removeUserToken(userId)
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, projectId }, 'Error removing user credentials')
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// Export project to Git server
|
||||
// Expected req.body:
|
||||
// name (string): repository name
|
||||
// description (string, optional): repository description
|
||||
// isPublic (boolean, optional): if true, the repository is public
|
||||
// org (string, optional): if provided, repository will be created under this organization
|
||||
|
||||
async function exportProject(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { project_id: projectId } = req.params
|
||||
|
||||
try {
|
||||
await GitLabSyncHandler.exportProject(userId, projectId, req.body)
|
||||
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, projectId }, 'Error exporting project')
|
||||
let key = 'gitlab_validation_check'
|
||||
if (err instanceof AlreadyExistsError) key = 'gitlab_validation_name_exists'
|
||||
else if (errStatus === 401 || errStatus === 403) key = 'gitlab_validation_check_auth'
|
||||
return res.status(errStatus).json({ key, message: err.message })
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async function gitMerge(req, res) {
|
||||
const { project_id: projectId } = req.params
|
||||
try {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const commitMessage = req.body?.message || 'Updates from Overleaf'
|
||||
const claimConflictIsResolved = req.body.claimConflictIsResolved
|
||||
|
||||
const mergeResult = await doGitMerge(userId, projectId, commitMessage, claimConflictIsResolved)
|
||||
|
||||
res.json(mergeResult)
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, projectId }, 'Error syncing project to GitLab')
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Unlink user's Git server account
|
||||
async function unlinkRepo(req, res) {
|
||||
const { project_id: projectId } = req.params
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
try {
|
||||
const ownerEmail = await GitLabSyncHandler.unlinkRepo(userId, projectId)
|
||||
if (ownerEmail) return res.status(403).json({ ownerEmail })
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, projectId }, 'Error unlinking repo from project')
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
export default {
|
||||
getConnectionStatus: expressify(getConnectionStatus),
|
||||
getProjectState: expressify(getProjectState),
|
||||
oauth2: expressify(oauth2),
|
||||
unlink: expressify(unlink),
|
||||
getUserAndOrgs: expressify(getUserAndOrgs),
|
||||
oauth2Callback: expressify(oauth2Callback),
|
||||
listUserRepos: expressify(listUserRepos),
|
||||
importRepo: expressify(importRepo),
|
||||
exportProject: expressify(exportProject),
|
||||
getMergeOverview: expressify(getMergeOverview),
|
||||
gitMerge: expressify(gitMerge),
|
||||
unlinkRepo: expressify(unlinkRepo),
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import Path from 'path'
|
||||
import fs from 'fs'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import crypto from 'crypto'
|
||||
import pLimit from 'p-limit'
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs'
|
||||
import ProjectUploadManager from '../../../../app/src/Features/Uploads/ProjectUploadManager.mjs'
|
||||
import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs'
|
||||
import DocumentUpdaterHandler from '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
|
||||
import api from './GitLabApiClient.mjs'
|
||||
import SyncStateManager from './SyncStateManager.mjs'
|
||||
import HistoryManager from './HistoryManager.mjs'
|
||||
import TokenManager from './TokenManager.mjs'
|
||||
import { InvalidTokenError, NotFoundError } from './GitSyncErrors.mjs'
|
||||
|
||||
async function getGitConnState(userId) {
|
||||
try {
|
||||
const tokens = await TokenManager.getUserToken(userId)
|
||||
if (!tokens) return false;
|
||||
const { token } = tokens
|
||||
|
||||
await api.getUser(token)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidTokenError) {
|
||||
logger.debug( { err, userId }, 'token invalid, treating as not connected')
|
||||
return false
|
||||
}
|
||||
throw OError.tag(err, 'failed to validate token', { userId })
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjectState(userId, projectId) {
|
||||
let pss = null
|
||||
const projection = { _id: 0, mergeStatus: 1, repoFullName: 1, unmergedBranchName: 1 }
|
||||
pss = await SyncStateManager.getProjectState(projectId, projection)
|
||||
if (!pss) {
|
||||
pss = { mergeStatus: 'need-export' }
|
||||
try {
|
||||
const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 })
|
||||
if (owner_ref.toString() !== userId) {
|
||||
pss.ownerEmail = await UserGetter.promises.getUserEmail(owner_ref)
|
||||
}
|
||||
} catch (err) {
|
||||
pss.ownerEmail = 'nobody@nowhere'
|
||||
logger.error({ err }, "failed get user email")
|
||||
}
|
||||
return pss
|
||||
}
|
||||
let canPush
|
||||
try {
|
||||
const tokens = await TokenManager.getUserToken(userId)
|
||||
if (!tokens) throw new InvalidTokenError('no user token', { userId, status: 400 });
|
||||
const { token } = tokens
|
||||
canPush = await api.getPushPermission(token, pss.repoFullName)
|
||||
} catch (err) {
|
||||
if ((err instanceof NotFoundError) ||
|
||||
(err instanceof PermissionDeniedError)
|
||||
) canPush = false
|
||||
else throw err
|
||||
}
|
||||
if (!canPush) {
|
||||
pss.mergeStatus = 'need-permission'
|
||||
// send owner's email to collaborator
|
||||
// send nothing to the owner
|
||||
try {
|
||||
const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 })
|
||||
if (owner_ref.toString() !== userId) {
|
||||
pss.ownerEmail = await UserGetter.promises.getUserEmail(owner_ref)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, userId }, "failed get user email")
|
||||
pss.ownerEmail = 'nobody@nowhere'
|
||||
}
|
||||
}
|
||||
return pss
|
||||
}
|
||||
|
||||
async function unlinkRepo(userId, projectId) {
|
||||
const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 })
|
||||
if (owner_ref.toString() !== userId) {
|
||||
let ownerEmail
|
||||
try {
|
||||
ownerEmail = await UserGetter.promises.getUserEmail(owner_ref)
|
||||
} catch (err) {
|
||||
logger.error({ err, userId }, "failed get user email")
|
||||
ownerEmail = 'nobody@nowhere'
|
||||
}
|
||||
return ownerEmail
|
||||
}
|
||||
await SyncStateManager.removeProjectState(projectId)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
async function listUserRepos(userId) {
|
||||
const tokens = await TokenManager.getUserToken(userId)
|
||||
if (!tokens) throw new InvalidTokenError('no user token', { userId, status: 400 });
|
||||
const { token } = tokens
|
||||
return await api.listUserRepos(token)
|
||||
}
|
||||
|
||||
async function getUserAndOrgs(userId) {
|
||||
const tokens = await TokenManager.getUserToken(userId)
|
||||
if (!tokens) throw new InvalidTokenError('no user token', { userId, status: 400 });
|
||||
const { token } = tokens
|
||||
return await api.getUserAndOrgs(token)
|
||||
}
|
||||
|
||||
async function getMergeOverview(userId, projectId) {
|
||||
const projectSyncState = await SyncStateManager.getProjectState(projectId)
|
||||
if (!projectSyncState) throw new GitNotLinkedError(projectId)
|
||||
const { repoFullName, defaultBranchName, lastSyncCommit, lastSyncVersion, mergeStatus } = projectSyncState
|
||||
|
||||
if (mergeStatus === 'conflict') return null
|
||||
|
||||
const tokens = await TokenManager.getUserToken(userId)
|
||||
if (!tokens) throw new InvalidTokenError('no user token', { userId, status: 400 });
|
||||
const { token } = tokens
|
||||
const currentVersion = await HistoryManager.latestVersion(projectId.toString())
|
||||
const isProjectUpdated = currentVersion !== lastSyncVersion
|
||||
try {
|
||||
const commitsAndStatus = await api.listNewCommitsWithStatus(token, repoFullName, defaultBranchName, lastSyncCommit)
|
||||
if(commitsAndStatus.diverged) {
|
||||
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'diverged' } )
|
||||
}
|
||||
return { ...commitsAndStatus, isProjectUpdated }
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
// what is the reason of NotFoundError? is it a missing commit or not?
|
||||
let canPush
|
||||
try {
|
||||
canPush = await api.getPushPermission(token, repoFullName)
|
||||
} catch (e) {
|
||||
logger.error({ error: e }, "failed to check push permission")
|
||||
canPush = false
|
||||
}
|
||||
if (!canPush) throw err
|
||||
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'diverged' } )
|
||||
return { commits: [], diverged: true, isProjectUpdated }
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importRepo(userId, projectName, repoFullName, defaultBranchName) {
|
||||
const tokens = await TokenManager.getUserToken(userId)
|
||||
if (!tokens) throw new InvalidTokenError('no user token', { userId, status: 400 });
|
||||
const { token } = tokens
|
||||
const defaultBranchHead = await api.getBranchHead(token, repoFullName, defaultBranchName)
|
||||
const fsPath = Path.join(Settings.path.dumpFolder, `gitlab_import_${crypto.randomUUID()}`)
|
||||
|
||||
let project_id
|
||||
|
||||
try {
|
||||
const stream = await api.getRepoZipball(token, repoFullName, defaultBranchHead)
|
||||
await pipeline(stream, fs.createWriteStream(fsPath))
|
||||
|
||||
const { project } = await ProjectUploadManager.promises.createProjectFromZipArchiveWithName(
|
||||
userId,
|
||||
projectName,
|
||||
fsPath
|
||||
)
|
||||
|
||||
project_id = project?._id
|
||||
} catch (err) {
|
||||
throw OError.tag(
|
||||
err,
|
||||
'failed importing git repo',
|
||||
{ userId, projectName, repoFullName, defaultBranchName, fsPath }
|
||||
)
|
||||
} finally {
|
||||
fs.promises.rm(fsPath, { force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
const projectVersion = await HistoryManager.latestVersion(project_id.toString())
|
||||
await SyncStateManager.createProjectState(project_id, {
|
||||
repoFullName,
|
||||
mergeStatus: 'clean',
|
||||
lastSyncCommit: defaultBranchHead,
|
||||
defaultBranchName,
|
||||
lastSyncVersion: projectVersion
|
||||
})
|
||||
} catch (err) {
|
||||
// don't throw up
|
||||
logger.error(
|
||||
{ err, userId, projectId: project_id.toString(), repoFullName },
|
||||
'Failed to create state of imported repo'
|
||||
)
|
||||
}
|
||||
|
||||
return project_id
|
||||
}
|
||||
|
||||
async function exportProject(userId, projectId, repoOptions) {
|
||||
const isLinked = await SyncStateManager.getProjectState(projectId)
|
||||
if (isLinked) {
|
||||
throw new OError('Project is already linked to Git server', { projectId })
|
||||
}
|
||||
|
||||
const tokens = await TokenManager.getUserToken(userId)
|
||||
if (!tokens) throw new InvalidTokenError('no user token', { userId, status: 400 });
|
||||
const { token } = tokens
|
||||
|
||||
const { path_with_namespace: repoFullName, default_branch: defaultBranchName } =
|
||||
await api.createRepo(token, repoOptions)
|
||||
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
|
||||
|
||||
const currentVersion = await HistoryManager.latestVersion(projectId)
|
||||
const currentPaths = await HistoryManager.getPathsAtVersion(projectId, currentVersion)
|
||||
|
||||
const limit = pLimit(api.maxConcurrency)
|
||||
const blobData = await Promise.all(
|
||||
currentPaths.paths.map((path) =>
|
||||
limit(async () => {
|
||||
const buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path)
|
||||
const sha = "DON_T_DELETE" // sha needs to be present so that the file is not detected as a deletion
|
||||
return { path, sha, content: buffer }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const tree = await api.createTree(token, repoFullName, blobData)
|
||||
const initialCommit = await api.createCommit(token, repoFullName, {
|
||||
tree,
|
||||
message: 'Initial Overleaf import',
|
||||
})
|
||||
|
||||
const force = true
|
||||
|
||||
return SyncStateManager.createProjectState(projectId, {
|
||||
mergeStatus: 'clean',
|
||||
defaultBranchName,
|
||||
lastSyncCommit: initialCommit,
|
||||
lastSyncVersion: currentVersion,
|
||||
repoFullName,
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
exportProject,
|
||||
getProjectState,
|
||||
unlinkRepo,
|
||||
importRepo,
|
||||
getGitConnState,
|
||||
listUserRepos,
|
||||
getUserAndOrgs,
|
||||
getMergeOverview,
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import logger from '@overleaf/logger'
|
||||
|
||||
import GitLabSyncController from './GitLabSyncController.mjs'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init gitlab-sync router')
|
||||
// start the GitLab OAuth flow by redirecting to GitLab authorization page
|
||||
webRouter.get(
|
||||
'/user/gitlab-sync/oauth2',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitLabSyncController.oauth2
|
||||
)
|
||||
|
||||
// callback for GitLab OAuth flow
|
||||
webRouter.get(
|
||||
'/user/gitlab-sync/oauth2/callback',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitLabSyncController.oauth2Callback
|
||||
)
|
||||
|
||||
// unlink GitLab account
|
||||
webRouter.post(
|
||||
'/user/gitlab-sync/unlink',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitLabSyncController.unlink
|
||||
)
|
||||
|
||||
// get user git connection status
|
||||
webRouter.get(
|
||||
'/user/gitlab-sync/status',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitLabSyncController.getConnectionStatus
|
||||
)
|
||||
|
||||
// get git user name and user's organizations
|
||||
webRouter.get(
|
||||
'/user/gitlab-sync/orgs',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitLabSyncController.getUserAndOrgs
|
||||
)
|
||||
|
||||
// list user's repos
|
||||
webRouter.get(
|
||||
'/user/gitlab-sync/repos',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitLabSyncController.listUserRepos
|
||||
)
|
||||
|
||||
// create a new project from git server repo
|
||||
webRouter.post(
|
||||
'/project/new/gitlab-sync',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitLabSyncController.importRepo
|
||||
)
|
||||
|
||||
// export project to git server
|
||||
webRouter.post(
|
||||
'/project/:project_id/gitlab-sync/export',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
GitLabSyncController.exportProject
|
||||
)
|
||||
|
||||
// get project sync state
|
||||
webRouter.get(
|
||||
'/project/:project_id/gitlab-sync/state',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
GitLabSyncController.getProjectState
|
||||
)
|
||||
|
||||
// get overview of the coming merge:
|
||||
// merge state (clean or diverged), unmerged commits, is OL version changed?
|
||||
webRouter.get(
|
||||
'/project/:project_id/gitlab-sync/merge/overview',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
GitLabSyncController.getMergeOverview
|
||||
)
|
||||
|
||||
// merge OL project with Git server repo
|
||||
webRouter.post(
|
||||
'/project/:project_id/gitlab-sync/merge',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
GitLabSyncController.gitMerge
|
||||
)
|
||||
|
||||
// unlink Git repo from Git server
|
||||
webRouter.delete(
|
||||
'/project/:project_id/gitlab-sync',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
GitLabSyncController.unlinkRepo
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
import { normalize } from 'path/posix'
|
||||
import pLimit from 'p-limit'
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '@overleaf/logger'
|
||||
import SyncStateManager from './SyncStateManager.mjs'
|
||||
import api from './GitLabApiClient.mjs'
|
||||
import HistoryManager from './HistoryManager.mjs'
|
||||
import { GitConflictError } from './GitSyncErrors.mjs'
|
||||
import TokenManager from './TokenManager.mjs'
|
||||
import { ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs'
|
||||
import LockManager from '../../../../app/src/infrastructure/LockManager.mjs'
|
||||
import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs'
|
||||
import UpdateMerger from '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs'
|
||||
import DocumentUpdaterHandler from '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
|
||||
|
||||
const SyncLockManager = LockManager.withTimeout(300) // 5 min
|
||||
|
||||
export async function doGitMerge(userId, projectId, message, claimConflictIsResolved) {
|
||||
return await SyncLockManager.promises.runWithLock(
|
||||
'gitSync',
|
||||
projectId,
|
||||
() => doGitMergeWithoutLock(userId, projectId, message, claimConflictIsResolved)
|
||||
)
|
||||
}
|
||||
|
||||
async function doGitMergeWithoutLock(userId, projectId, message, claimConflictIsResolved) {
|
||||
const projectSyncState = await SyncStateManager.getProjectState(projectId)
|
||||
if (!projectSyncState) throw new GitNotLinkedError(projectId)
|
||||
const {
|
||||
repoFullName,
|
||||
defaultBranchName,
|
||||
mergeStatus,
|
||||
unmergedBranchName
|
||||
} = projectSyncState
|
||||
|
||||
// do nothing if conflict is not yet resolved and 'Sync' button is pushed
|
||||
// do nothing if conflict is already resolved and 'I have manually merged' button is pushed
|
||||
if (
|
||||
!claimConflictIsResolved && mergeStatus === 'conflict' ||
|
||||
claimConflictIsResolved && mergeStatus !== 'conflict'
|
||||
) return { mergeStatus, repoFullName, unmergedBranchName }
|
||||
|
||||
const tokens = await TokenManager.getUserToken(userId)
|
||||
if (!tokens) throw new InvalidTokenError('no user token', { userId, status: 400 });
|
||||
const { token } = tokens
|
||||
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
|
||||
|
||||
const currentVersion = await HistoryManager.latestVersion(projectId)
|
||||
const defaultBranchHead = await api.getBranchHead(token, repoFullName, defaultBranchName)
|
||||
|
||||
let newUnmergedBranchName
|
||||
|
||||
switch (mergeStatus) {
|
||||
|
||||
case 'clean':
|
||||
newUnmergedBranchName = await resolveCleanSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead,
|
||||
message,
|
||||
)
|
||||
break
|
||||
|
||||
case 'conflict':
|
||||
newUnmergedBranchName = await resolveConflictSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead
|
||||
)
|
||||
break
|
||||
|
||||
case 'diverged':
|
||||
newUnmergedBranchName = await resolveDetachedSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead,
|
||||
message,
|
||||
)
|
||||
break
|
||||
|
||||
default:
|
||||
throw new Error('Bad mergeStatus')
|
||||
}
|
||||
|
||||
const newMergeStatus = newUnmergedBranchName ? 'conflict' : 'clean'
|
||||
return { mergeStatus: newMergeStatus, repoFullName, unmergedBranchName: newUnmergedBranchName }
|
||||
}
|
||||
|
||||
// ----------- clean flow
|
||||
async function resolveCleanSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead,
|
||||
message,
|
||||
) {
|
||||
const { projectId, repoFullName, defaultBranchName } = projectSyncState
|
||||
|
||||
// No changes in OL
|
||||
if (currentVersion === projectSyncState.lastSyncVersion) {
|
||||
// No changes in GH too: everything is up to date
|
||||
if (projectSyncState.lastSyncCommit === defaultBranchHead) return null
|
||||
|
||||
// Need to apply only GH changes
|
||||
const lastSyncCommit = defaultBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
|
||||
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Possible changes in OL
|
||||
const olBranchHead =
|
||||
await exportChangesToGit({
|
||||
token,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncVersion: projectSyncState.lastSyncVersion,
|
||||
currentVersion,
|
||||
message,
|
||||
baseCommit: projectSyncState.lastSyncCommit,
|
||||
})
|
||||
|
||||
if (!olBranchHead) {
|
||||
if ( defaultBranchHead === projectSyncState.lastSyncCommit) {
|
||||
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion: currentVersion })
|
||||
return null
|
||||
}
|
||||
|
||||
const lastSyncCommit = defaultBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
|
||||
if (defaultBranchHead === projectSyncState.lastSyncCommit) {
|
||||
const lastSyncCommit = olBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err, projectId }, 'Error during clean sync state resolution, fallback to merge')
|
||||
}
|
||||
|
||||
// Normal commit failed, fallback to merge, first create a temporary branch with OL changes
|
||||
const tempBranchName = generateBranchName()
|
||||
const olBranchHead =
|
||||
await exportChangesToGit({
|
||||
token,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncVersion: projectSyncState.lastSyncVersion,
|
||||
currentVersion,
|
||||
message,
|
||||
baseCommit: projectSyncState.lastSyncCommit,
|
||||
branch: tempBranchName,
|
||||
})
|
||||
|
||||
// Merge the temporary branch into the default branch
|
||||
const { mergeCommit, conflict } = await mergeWithTempBranch(
|
||||
token,
|
||||
repoFullName,
|
||||
defaultBranchName,
|
||||
tempBranchName
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'conflict',
|
||||
lastSyncCommit: defaultBranchHead,
|
||||
unmergedBranchName: tempBranchName,
|
||||
unmergedBranchHead: olBranchHead,
|
||||
conflictVersion: currentVersion,
|
||||
})
|
||||
return tempBranchName
|
||||
} else {
|
||||
const lastSyncCommit = mergeCommit
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ----------- conflict flow
|
||||
async function resolveConflictSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead
|
||||
) {
|
||||
const { projectId, repoFullName, defaultBranchName, conflictVersion } = projectSyncState
|
||||
|
||||
// No OL changes after manual conflict resolving: pull GH changes only
|
||||
if (currentVersion === conflictVersion) {
|
||||
const lastSyncCommit = defaultBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'clean',
|
||||
lastSyncVersion,
|
||||
lastSyncCommit,
|
||||
conflictVersion: null,
|
||||
unmergedBranchName: null,
|
||||
unmergedBranchHead: null
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// OL was changed after manual conflict resolution: need to merge
|
||||
const prevOlBranchHead = projectSyncState.unmergedBranchHead
|
||||
|
||||
// Create a temporary branch with OL changes after manual conflict resolution
|
||||
const tempBranchName = generateBranchName()
|
||||
const newOlBranchHead = await exportChangesToGit({
|
||||
token,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncVersion: conflictVersion,
|
||||
currentVersion,
|
||||
message: '[Updates in Overleaf during conflict resolution]',
|
||||
baseCommit: prevOlBranchHead,
|
||||
branch: tempBranchName,
|
||||
})
|
||||
|
||||
// Were there really any changes? (e.g.: file created, then removed: no changes)
|
||||
if (!newOlBranchHead) {
|
||||
// nothing to push to GH
|
||||
const lastSyncCommit = defaultBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'clean',
|
||||
lastSyncVersion,
|
||||
lastSyncCommit,
|
||||
conflictVersion: null,
|
||||
unmergedBranchName: null,
|
||||
unmergedBranchHead: null
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// merge
|
||||
const { mergeCommit, conflict } = await mergeWithTempBranch(
|
||||
token,
|
||||
repoFullName,
|
||||
defaultBranchName,
|
||||
newOlBranchHead,
|
||||
tempBranchName,
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
// conflict must be resolved manually
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'conflict',
|
||||
lastSyncCommit: defaultBranchHead,
|
||||
unmergedBranchName: tempBranchName,
|
||||
unmergedBranchHead: newOlBranchHead,
|
||||
conflictVersion: currentVersion,
|
||||
})
|
||||
return tempBranchName
|
||||
} else {
|
||||
// merge succeeded
|
||||
const lastSyncCommit = mergeCommit
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'clean',
|
||||
lastSyncVersion,
|
||||
lastSyncCommit,
|
||||
conflictVersion: null,
|
||||
unmergedBranchName: null,
|
||||
unmergedBranchHead: null
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ----------- detached flow
|
||||
// GH repo was force pushed, lastSync commit possibly unavailable
|
||||
async function resolveDetachedSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead,
|
||||
message,
|
||||
) {
|
||||
const { projectId, repoFullName, defaultBranchName } = projectSyncState
|
||||
|
||||
// No changes in OL: re-anchor
|
||||
if (currentVersion === projectSyncState.lastSyncVersion) {
|
||||
const lastSyncCommit = defaultBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
|
||||
// Need to compare lastSync OL version (base), current OL version (local) and GH version (remote)
|
||||
const baseSnapshot = await HistoryManager.getProjectSnapshot(projectId, projectSyncState.lastSyncVersion)
|
||||
const localSnapshot = await HistoryManager.getProjectSnapshot(projectId, currentVersion)
|
||||
const remoteBlobMap = await getGitBlobMap(token, repoFullName, defaultBranchHead)
|
||||
|
||||
const {
|
||||
cleanLocalEntries,
|
||||
conflictRemoteEntries,
|
||||
conflictLocalEntries,
|
||||
conflictBaseEntries,
|
||||
hasConflicts,
|
||||
hasChanges
|
||||
} = await buildDetachedSyncPlan({
|
||||
token,
|
||||
repoFullName,
|
||||
projectId,
|
||||
currentVersion,
|
||||
baseSnapshot,
|
||||
localSnapshot,
|
||||
remoteBlobMap,
|
||||
})
|
||||
|
||||
// were any changes introduced in GH?
|
||||
if (!hasChanges) {
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'clean',
|
||||
lastSyncVersion: currentVersion,
|
||||
lastSyncCommit: defaultBranchHead
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasConflicts) {
|
||||
let lastSyncCommit = defaultBranchHead
|
||||
if (cleanLocalEntries.length !== 0) {
|
||||
lastSyncCommit = await createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit: defaultBranchHead,
|
||||
entries: cleanLocalEntries,
|
||||
message,
|
||||
})
|
||||
}
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve potential conflict.
|
||||
// Build synthetic merge topology:
|
||||
//
|
||||
// GH
|
||||
// /
|
||||
// H --- B ---
|
||||
// \
|
||||
// OL
|
||||
//
|
||||
// merge(GH <- OL)
|
||||
//
|
||||
// B: conflicted files are restored to lastSyncVersion (merge base)
|
||||
// GH: in conflicted files remote changes after lastSyncVersion are replayed
|
||||
// OL: all changes done in Overleaf after lastSyncVersion are applied
|
||||
|
||||
// Blobs referenced in conflictBaseEntries in theory could be lost, but for GitHub,
|
||||
// GitLab and Gitea, blobs from historical commits are normally retained for a
|
||||
// very long time so the practical probability of failure is likely close to zero.
|
||||
// TODO: anyway, if error is thrown by api.createTree, upload all blobs
|
||||
// referenced in conflictBaseEntries and try again
|
||||
|
||||
// H --- B
|
||||
const baseCommit = await createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit: defaultBranchHead,
|
||||
entries: conflictBaseEntries,
|
||||
message: '[Overleaf GitSync conflict resolution: restore last sync]',
|
||||
})
|
||||
|
||||
// default branch: --- H --- B --- GH
|
||||
const remoteChangesCommit = await createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit: baseCommit,
|
||||
entries: conflictRemoteEntries,
|
||||
message: '[Overleaf GitSync conflict resolution: replay repo changes]',
|
||||
})
|
||||
|
||||
const tempBranchName = generateBranchName()
|
||||
// temp branch: --- H --- B --- OL
|
||||
const olChangesCommit = await createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit: baseCommit,
|
||||
entries: [...cleanLocalEntries, ...conflictLocalEntries],
|
||||
message,
|
||||
branch: tempBranchName,
|
||||
})
|
||||
|
||||
// merge: default <- temp (GH <- OL)
|
||||
const { mergeCommit, conflict } = await mergeWithTempBranch(
|
||||
token,
|
||||
repoFullName,
|
||||
defaultBranchName,
|
||||
tempBranchName
|
||||
)
|
||||
|
||||
// auto-merge succeeded, conflicts in editable files were resolved?
|
||||
if (!conflict) {
|
||||
const lastSyncCommit = mergeCommit
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
|
||||
// merge failed: must manually resolve conflicts
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'conflict',
|
||||
lastSyncCommit: remoteChangesCommit,
|
||||
unmergedBranchName: tempBranchName,
|
||||
unmergedBranchHead: olChangesCommit,
|
||||
conflictVersion: currentVersion,
|
||||
})
|
||||
return tempBranchName
|
||||
}
|
||||
|
||||
// ----------- Helpers
|
||||
async function applyGitSnapshotToProject({ token, userId, projectId, repoFullName, lastSyncCommit }) {
|
||||
|
||||
const invalidFiles = []
|
||||
const updates = []
|
||||
const deletions = []
|
||||
|
||||
const currentVersion = await HistoryManager.latestVersion(projectId)
|
||||
const historySnapshot = await HistoryManager.getProjectSnapshot(projectId, currentVersion)
|
||||
const blobMap = await getGitBlobMap(token, repoFullName, lastSyncCommit)
|
||||
const blobPaths = new Set()
|
||||
|
||||
for (const [path, sha] of Object.entries(blobMap)) {
|
||||
const invalid = validateFileName(path)
|
||||
|
||||
if (invalid) {
|
||||
invalidFiles.push(invalid.name)
|
||||
continue
|
||||
}
|
||||
|
||||
blobPaths.add(path)
|
||||
|
||||
const hstSha = historySnapshot[path]?.data?.hash
|
||||
// File at path is not changed, do not touch it
|
||||
if (hstSha === sha) continue
|
||||
|
||||
updates.push(path)
|
||||
}
|
||||
|
||||
for (const path of Object.keys(historySnapshot)) {
|
||||
if (blobPaths.has(path)) continue
|
||||
deletions.push(path)
|
||||
}
|
||||
|
||||
// TODO: do not silently ignore invalidFiles, inform user about them
|
||||
if (invalidFiles.length > 0) {
|
||||
logger.warn({ projectId, invalidFiles }, 'Invalid file paths detected at Git server')
|
||||
}
|
||||
const limit = pLimit(api.maxConcurrency)
|
||||
await Promise.all(
|
||||
updates.map(path => limit(async () => {
|
||||
const stream = await api.getBlobStream(token, repoFullName, lastSyncCommit, path)
|
||||
return UpdateMerger.promises.mergeUpdate(userId, projectId, '/' + path, stream, 'gitlab')
|
||||
}))
|
||||
)
|
||||
const localLimit = pLimit(5)
|
||||
await Promise.all(
|
||||
deletions.map(path => localLimit(() => {
|
||||
return UpdateMerger.promises.deleteUpdate(userId, projectId, '/' + path, 'gitlab')
|
||||
}))
|
||||
)
|
||||
|
||||
return await HistoryManager.latestVersion(projectId)
|
||||
}
|
||||
|
||||
async function buildDetachedSyncPlan({
|
||||
token,
|
||||
repoFullName,
|
||||
projectId,
|
||||
currentVersion,
|
||||
baseSnapshot,
|
||||
localSnapshot,
|
||||
remoteBlobMap
|
||||
}) {
|
||||
const cleanLocalEntries = []
|
||||
const conflictRemoteEntries = []
|
||||
const conflictLocalEntries = []
|
||||
const conflictBaseEntries = []
|
||||
|
||||
let hasConflicts = false
|
||||
let hasChanges = false
|
||||
|
||||
const allPaths = new Set([
|
||||
...Object.keys(baseSnapshot || {}),
|
||||
...Object.keys(localSnapshot || {}),
|
||||
...Object.keys(remoteBlobMap || {}),
|
||||
])
|
||||
|
||||
for (const path of allPaths) {
|
||||
const baseHash = getEntryHash(baseSnapshot[path])
|
||||
const localHash = getEntryHash(localSnapshot[path])
|
||||
const remoteHash = remoteBlobMap[path] || null
|
||||
|
||||
// OL == GH, do nothing
|
||||
if (localHash === remoteHash) continue
|
||||
|
||||
if (baseHash === remoteHash) {
|
||||
// changed in OL
|
||||
hasChanges = true
|
||||
cleanLocalEntries.push({ path, sha: localHash, content: Buffer.from(localSnapshot[path]?.data?.content || '', 'utf8').toString('base64') })
|
||||
continue
|
||||
}
|
||||
|
||||
if (baseHash === localHash) {
|
||||
// changed in GH
|
||||
hasChanges = true
|
||||
continue
|
||||
}
|
||||
// conflict: changed both in GH and OL
|
||||
hasChanges = true
|
||||
hasConflicts = true
|
||||
|
||||
conflictRemoteEntries.push({ path, sha: remoteHash, content: await api.getBlobContent(token, repoFullName, remoteHash) })
|
||||
conflictLocalEntries.push({ path, sha: localHash, content: Buffer.from(localSnapshot[path]?.data?.content || '', 'utf8').toString('base64') })
|
||||
conflictBaseEntries.push({ path, sha: baseHash, content: Buffer.from(baseSnapshot[path]?.data?.content || '', 'utf8').toString('base64') })
|
||||
}
|
||||
return {
|
||||
cleanLocalEntries,
|
||||
conflictRemoteEntries,
|
||||
conflictLocalEntries,
|
||||
conflictBaseEntries,
|
||||
hasConflicts,
|
||||
hasChanges,
|
||||
}
|
||||
}
|
||||
|
||||
async function createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit,
|
||||
entries,
|
||||
message,
|
||||
branch,
|
||||
}) {
|
||||
const baseTree = await api.getCommitTree(token, repoFullName, parentCommit)
|
||||
const newTree = await api.createTree(token, repoFullName, entries, baseTree)
|
||||
return api.createCommit(token, repoFullName, {
|
||||
tree: newTree,
|
||||
start_sha: parentCommit,
|
||||
message,
|
||||
branch,
|
||||
})
|
||||
}
|
||||
|
||||
async function mergeWithTempBranch(
|
||||
token,
|
||||
repoFullName,
|
||||
branchName,
|
||||
tempBranchName
|
||||
) {
|
||||
try {
|
||||
const mergeCommit = await api.mergeBranch(token, repoFullName, branchName, tempBranchName)
|
||||
|
||||
return { conflict: false, mergeCommit }
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof GitConflictError) {
|
||||
return { conflict: true }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function exportChangesToGit({
|
||||
token,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncVersion,
|
||||
currentVersion,
|
||||
message,
|
||||
baseCommit,
|
||||
branch,
|
||||
}) {
|
||||
const diff = await HistoryManager.getProjectFileTreeDiff(
|
||||
projectId,
|
||||
lastSyncVersion,
|
||||
currentVersion
|
||||
)
|
||||
|
||||
const { paths } = await HistoryManager.getPathsAtVersion(projectId, lastSyncVersion)
|
||||
const existedAtLastSync = new Set(paths)
|
||||
|
||||
const deletePaths = new Set()
|
||||
const upsertPaths = new Set()
|
||||
|
||||
for (const item of diff) {
|
||||
const oldPath = item.pathname
|
||||
|
||||
if (item.operation === 'removed') {
|
||||
// it can happen that removed file was created after last sync
|
||||
if (existedAtLastSync.has(oldPath)) deletePaths.add(oldPath)
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.operation === 'renamed') {
|
||||
if (existedAtLastSync.has(oldPath)) deletePaths.add(oldPath)
|
||||
upsertPaths.add(item.newPathname)
|
||||
continue
|
||||
}
|
||||
if (item.operation === 'added' || item.operation === 'edited') {
|
||||
upsertPaths.add(oldPath)
|
||||
}
|
||||
}
|
||||
// if a file is renamed and a new file with the old name is created,
|
||||
// then the name will be in both sets, need to exclude it from deletePaths
|
||||
for (const path of upsertPaths) {
|
||||
if (deletePaths.has(path)) deletePaths.delete(path)
|
||||
}
|
||||
|
||||
// this can happen when a file is created and then removed
|
||||
if (deletePaths.size === 0 && upsertPaths.size === 0) return null
|
||||
|
||||
const limit = pLimit(api.maxConcurrency)
|
||||
const upsertEntries = await Promise.all(
|
||||
[...upsertPaths].map(path =>
|
||||
limit(async () => {
|
||||
const buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path)
|
||||
const sha = "DON_T_DELETE" // sha needs to be present so that the file is not detected as a deletion
|
||||
return { path, sha, content: buffer }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const deleteEntries = [...deletePaths].map(path => ({ path, sha: null, content: null }))
|
||||
const entries = [...deleteEntries, ...upsertEntries]
|
||||
|
||||
const newCommit = await createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit: baseCommit,
|
||||
entries,
|
||||
message,
|
||||
branch,
|
||||
})
|
||||
|
||||
return newCommit
|
||||
}
|
||||
|
||||
function generateBranchName() {
|
||||
const d = new Date()
|
||||
const pad = n => `${n}`.padStart(2, '0')
|
||||
return `overleaf-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
async function getGitBlobMap(token, repoFullName, commit) {
|
||||
const blobEntries = await api.listBlobsAtCommit(token, repoFullName, commit)
|
||||
return Object.fromEntries(
|
||||
blobEntries.map(({ path, sha }) => [path, sha])
|
||||
)
|
||||
}
|
||||
|
||||
function getEntryHash(entry) {
|
||||
if (!entry?.data) return null
|
||||
return (
|
||||
entry.data.hash ||
|
||||
gitBlobShaFromString(entry.data.content)
|
||||
)
|
||||
}
|
||||
|
||||
function gitBlobShaFromString(str) {
|
||||
if (!str) return null
|
||||
const content = Buffer.from(str, 'utf8')
|
||||
const header = Buffer.from(`blob ${content.length}\0`, 'utf8')
|
||||
const store = Buffer.concat([header, content])
|
||||
|
||||
return createHash('sha1').update(store).digest('hex')
|
||||
}
|
||||
|
||||
function validateFileName(name) {
|
||||
if (
|
||||
typeof name !== 'string' ||
|
||||
name.length === 0 ||
|
||||
name.includes('\0')
|
||||
) return { name }
|
||||
|
||||
const normalized = normalize(name)
|
||||
|
||||
if (
|
||||
normalized !== name ||
|
||||
normalized.startsWith('/') ||
|
||||
normalized.startsWith('../')
|
||||
) return { name }
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import OError from '@overleaf/o-error'
|
||||
|
||||
class GitNotLinkedError extends OError {
|
||||
constructor(projectId) {
|
||||
super('git server is not linked to project', { projectId })
|
||||
}
|
||||
}
|
||||
|
||||
class AlreadyExistsError extends OError {}
|
||||
class GitConflictError extends OError {}
|
||||
class InvalidTokenError extends OError {}
|
||||
class ExpiredTokenError extends OError {}
|
||||
class NotFoundError extends OError {}
|
||||
class PermissionDeniedError extends OError {}
|
||||
class RateLimitError extends OError {}
|
||||
class ProviderRequestError extends OError {}
|
||||
|
||||
export {
|
||||
AlreadyExistsError,
|
||||
GitNotLinkedError,
|
||||
GitConflictError,
|
||||
InvalidTokenError,
|
||||
ExpiredTokenError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
RateLimitError,
|
||||
ProviderRequestError,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { fetchJson, fetchStream } from '@overleaf/fetch-utils'
|
||||
import HistoryManager from '../../../../app/src/Features/History/HistoryManager.mjs'
|
||||
|
||||
const urlBase = `${Settings.apis.project_history.url}/project`
|
||||
|
||||
async function latestVersion(projectId) {
|
||||
const url = `${urlBase}/${projectId}/version`
|
||||
try {
|
||||
await HistoryManager.promises.flushProject(projectId)
|
||||
const json = await fetchJson(url)
|
||||
return json.version
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to get the latest project version from history', { projectId })
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjectSnapshot(projectId, version) {
|
||||
const url = `${urlBase}/${projectId}/version/${version}`
|
||||
try {
|
||||
const snapshot = await fetchJson(url)
|
||||
return snapshot.files || {}
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to get the project snapshot from history', { projectId })
|
||||
}
|
||||
}
|
||||
|
||||
async function getPathsAtVersion(projectId, version) {
|
||||
const url = `${urlBase}/${projectId}/paths/version/${version}`
|
||||
try {
|
||||
const json = await fetchJson(url)
|
||||
return json
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to fetch file paths from history', { projectId, version })
|
||||
}
|
||||
}
|
||||
|
||||
// response field 'diff' is an array of objects:
|
||||
// {
|
||||
// pathname: string,
|
||||
// operation: 'added' | 'removed' | 'edited'| 'renamed' | undefined,
|
||||
// ...
|
||||
// }
|
||||
async function getProjectFileTreeDiff(projectId, fromV, toV) {
|
||||
const url = `${urlBase}/${projectId}/filetree/diff?from=${fromV}&to=${toV}`
|
||||
try {
|
||||
const json = await fetchJson(url)
|
||||
return json.diff || []
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to fetch filetree diff from history', { projectId, fromV, toV })
|
||||
}
|
||||
}
|
||||
|
||||
// return file from history as a base64 encoded string
|
||||
async function getProjectFileBuffer(projectId, version, filePath) {
|
||||
const url = `${urlBase}/${projectId}/version/${version}/${encodeURIComponent(filePath)}`
|
||||
try {
|
||||
const stream = await fetchStream(url)
|
||||
return await _streamToBase64(stream)
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to fetch file from history', { projectId, version, filePath })
|
||||
}
|
||||
}
|
||||
|
||||
async function _streamToBase64(stream) {
|
||||
const parts = []
|
||||
let leftover = null
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const data = leftover ? Buffer.concat([leftover, chunk]) : chunk
|
||||
|
||||
const remainder = data.length % 3
|
||||
const completeLength = data.length - remainder
|
||||
|
||||
if (completeLength > 0) {
|
||||
parts.push(data.subarray(0, completeLength).toString('base64'))
|
||||
}
|
||||
|
||||
leftover = remainder ? data.subarray(completeLength) : null
|
||||
}
|
||||
|
||||
if (leftover) {
|
||||
parts.push(leftover.toString('base64'))
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
export default {
|
||||
latestVersion,
|
||||
getPathsAtVersion,
|
||||
getProjectSnapshot,
|
||||
getProjectFileBuffer,
|
||||
getProjectFileTreeDiff,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { GitLabSyncProjectStates } from '../models/gitlabSyncProjectStates.mjs'
|
||||
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
|
||||
const { normalizeQuery } = Mongo
|
||||
|
||||
function getProjectState(projectId, projection = {}) {
|
||||
return GitLabSyncProjectStates.findOne(normalizeQuery({ projectId }), projection).lean()
|
||||
}
|
||||
|
||||
function createProjectState(projectId, data) {
|
||||
return GitLabSyncProjectStates.create({
|
||||
projectId: normalizeQuery(projectId),
|
||||
...data
|
||||
})
|
||||
}
|
||||
|
||||
function updateProjectState(projectId, data) {
|
||||
return GitLabSyncProjectStates.updateOne(
|
||||
normalizeQuery({ projectId }),
|
||||
{ $set: data },
|
||||
)
|
||||
}
|
||||
|
||||
function removeProjectState(projectId) {
|
||||
return GitLabSyncProjectStates.deleteMany(normalizeQuery({ projectId }))
|
||||
}
|
||||
|
||||
export default {
|
||||
getProjectState,
|
||||
createProjectState,
|
||||
updateProjectState,
|
||||
removeProjectState,
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
|
||||
import { GitLabSyncUserCredentials } from '../models/gitlabSyncUserCredentials.mjs'
|
||||
import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs'
|
||||
import { InvalidTokenError } from './GitSyncErrors.mjs'
|
||||
import api from './GitLabApiClient.mjs'
|
||||
|
||||
const { normalizeQuery } = Mongo
|
||||
|
||||
async function encryptAccessToken(accessToken) {
|
||||
try {
|
||||
return await AccessTokenEncryptor.encryptJson(accessToken)
|
||||
} catch (err) {
|
||||
throw OError.tag('failed to encrypt token', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptAccessToken(tokenEncrypted) {
|
||||
try {
|
||||
return await AccessTokenEncryptor.decryptToJson(tokenEncrypted)
|
||||
} catch (err) {
|
||||
throw new InvalidTokenError('failed to decrypt token', { status: 401 }, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshToken(userId, tokens) {
|
||||
logger.info({ userId }, "refreshing GitLab token")
|
||||
|
||||
if (!tokens.refresh_token) {
|
||||
logger.error("refresh_token invalid")
|
||||
return false
|
||||
}
|
||||
|
||||
let token
|
||||
let refresh_token
|
||||
let refresh_timestamp
|
||||
|
||||
try {
|
||||
[token, refresh_token, refresh_timestamp] = await api.refreshToken(tokens.refresh_token)
|
||||
if (!token || !refresh_token) {
|
||||
HttpErrorHandler.badRequest(req, res, 'Failed to refresh access token')
|
||||
return false
|
||||
}
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, userId }, 'Failed to refresh access token')
|
||||
HttpErrorHandler.badRequest(req, res, err.message || 'Bad request')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await saveUserToken(userId, { token, refresh_token, refresh_timestamp })
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, userId }, 'Error saving user token')
|
||||
HttpErrorHandler.handleErrorByStatusCode(req, res, err, errStatus)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ------------------------- exports -------------------------- //
|
||||
async function getUserToken(userId) {
|
||||
const credentials = await GitLabSyncUserCredentials.findOne(normalizeQuery({ userId }))
|
||||
if (!credentials) throw new InvalidTokenError('no user token', { userId, status: 400 })
|
||||
let tokens = await decryptAccessToken(credentials.gitlab)
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (!tokens.refresh_timestamp || tokens.refresh_timestamp < now) {
|
||||
const refreshed = await refreshToken(userId, tokens)
|
||||
if (refreshed) {
|
||||
return await getUserToken(userId)
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
async function saveUserToken(userId, accessToken) {
|
||||
const tokenEncrypted = await encryptAccessToken(accessToken)
|
||||
await GitLabSyncUserCredentials.findOneAndUpdate(
|
||||
normalizeQuery({ userId }),
|
||||
{ $set: { gitlab: tokenEncrypted } },
|
||||
{ upsert: true }
|
||||
)
|
||||
}
|
||||
|
||||
// Try to revoke user's token, then remove it
|
||||
async function removeUserToken(userId) {
|
||||
let token
|
||||
try {
|
||||
token = await getUserToken(userId)
|
||||
} catch (err) {
|
||||
logger.warn({ err, userId }, 'failed to get user token')
|
||||
}
|
||||
// fire-and-forget, but still handle errors
|
||||
if (token) {
|
||||
api.revokeToken(token).catch(err => {
|
||||
logger.warn({ err, userId }, 'failed to revoke user token')
|
||||
})
|
||||
}
|
||||
await GitLabSyncUserCredentials.deleteOne(normalizeQuery({ userId }))
|
||||
return
|
||||
}
|
||||
|
||||
export default {
|
||||
saveUserToken,
|
||||
getUserToken,
|
||||
removeUserToken
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import GitLabLogo from '@/shared/svgs/gitlab-logo'
|
||||
import IntegrationCard from '@/features/integrations-panel/integration-card'
|
||||
import GitSyncModal from './modals/git-sync-modal'
|
||||
import { GitSyncModalStatus } from '../types/git-sync-types'
|
||||
|
||||
const GitLabSyncCard = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const { projectId, name } = useProjectContext()
|
||||
const [modalStatus, setModalStatus] = useState<GitSyncModalStatus>('loading')
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegrationCard
|
||||
title={t('gitlab')}
|
||||
description={t('sync_with_a_gitlab_repository')}
|
||||
icon={<GitLabLogo size={32} />}
|
||||
showPaywallBadge={false}
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
</IntegrationCard>
|
||||
<GitSyncModal
|
||||
show={showModal}
|
||||
modalStatus={modalStatus}
|
||||
setModalStatus={setModalStatus}
|
||||
handleHide={() => {
|
||||
setShowModal(false)
|
||||
setModalStatus('loading')
|
||||
}}
|
||||
projectId={projectId}
|
||||
projectName={name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitLabSyncCard
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import GitLabLogo from '@/shared/svgs/gitlab-logo'
|
||||
|
||||
export const GitLabSyncWidget = function GitLabSyncWidget() {
|
||||
const { t } = useTranslation()
|
||||
const { appName } = getMeta('ol-ExposedSettings')
|
||||
|
||||
const {
|
||||
isLoading: isCheckingConn,
|
||||
isError: isErrorConnCheck,
|
||||
runAsync: runAsyncConnCheck,
|
||||
data: isConnected,
|
||||
setData: setConnState,
|
||||
} = useAsync<boolean>()
|
||||
|
||||
const {
|
||||
isLoading: isUnlinking,
|
||||
isError: isErrorUnlink,
|
||||
runAsync: runAsyncUnlink,
|
||||
} = useAsync<void>()
|
||||
|
||||
const [showUnlinkModal, setShowUnlinkModal] = useState(false)
|
||||
|
||||
const handleConnCheck = useCallback(() => {
|
||||
runAsyncConnCheck(getJSON('/user/gitlab-sync/status')).catch(err =>
|
||||
debugConsole.error(err?.data?.message || err?.message || err),
|
||||
)
|
||||
}, [runAsyncConnCheck])
|
||||
|
||||
useEffect(() => {
|
||||
handleConnCheck()
|
||||
}, [handleConnCheck])
|
||||
|
||||
const handleUnlink = useCallback(() => {
|
||||
runAsyncUnlink(postJSON('/user/gitlab-sync/unlink'))
|
||||
.then(() => setConnState(false))
|
||||
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||
.finally(() => setShowUnlinkModal(false))
|
||||
}, [runAsyncUnlink])
|
||||
|
||||
if (isCheckingConn) {
|
||||
return (
|
||||
<div className="settings-widget-container">
|
||||
<div>
|
||||
<GitLabLogo />
|
||||
</div>
|
||||
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4>GitLab</h4>
|
||||
</div>
|
||||
|
||||
<p className="small">
|
||||
<span>{t('loading')}…</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-widget-container">
|
||||
<div>
|
||||
<GitLabLogo size={40} />
|
||||
</div>
|
||||
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4 id="gitlab-sync">GitLab</h4>
|
||||
</div>
|
||||
|
||||
<p className="small">
|
||||
{t('gitlab_sync_description', { appName })}
|
||||
</p>
|
||||
|
||||
{isErrorConnCheck && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={t('gitlab_sync_error')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isErrorUnlink && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={t('generic_something_went_wrong')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isConnected ? (
|
||||
<OLButton
|
||||
variant="danger-ghost"
|
||||
onClick={() => setShowUnlinkModal(true)}
|
||||
disabled={isUnlinking}
|
||||
>
|
||||
{isUnlinking ? t('unlinking') : t('unlink')}
|
||||
</OLButton>
|
||||
) : isErrorConnCheck ? (
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleConnCheck}
|
||||
>
|
||||
{t('reconnect')}
|
||||
</OLButton>
|
||||
) : (
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
href="/user/gitlab-sync/oauth2"
|
||||
>
|
||||
{t('link')}
|
||||
</OLButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OLModal
|
||||
id="git-sync-modal"
|
||||
show={showUnlinkModal}
|
||||
onHide={() => setShowUnlinkModal(false)}
|
||||
backdrop="static"
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>
|
||||
{t('unlink_provider_account_title', {
|
||||
provider: 'GitLab',
|
||||
})}
|
||||
</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p>
|
||||
{t('unlink_gitlab_warning', {
|
||||
provider: 'GitLab',
|
||||
})}
|
||||
</p>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={() => setShowUnlinkModal(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
|
||||
<OLButton
|
||||
variant="danger-ghost"
|
||||
onClick={handleUnlink}
|
||||
disabled={isUnlinking}
|
||||
>
|
||||
{isUnlinking ? t('unlinking') : t('unlink')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
|
||||
|
||||
export default function ImportFromGitLabMenu({ onClick }: { onClick: (e: React.MouseEvent) => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<DropdownItem onClick={onClick}>
|
||||
{t('import_from_gitlab')}
|
||||
</DropdownItem>
|
||||
)
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import {
|
||||
postJSON,
|
||||
getJSON,
|
||||
} from '@/infrastructure/fetch-json'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
|
||||
type GitSyncRepo = {
|
||||
name: string
|
||||
fullName: string
|
||||
defaultBranchName: string
|
||||
}
|
||||
|
||||
type GitSyncReposResponse = {
|
||||
repos?: GitSyncRepo[]
|
||||
}
|
||||
|
||||
function ImportFromGitLabModalContent({ handleHide }: { handleHide: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { appName } = getMeta('ol-ExposedSettings')
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
data,
|
||||
runAsync,
|
||||
} = useAsync<GitSyncReposResponse>()
|
||||
|
||||
useEffect(() => {
|
||||
runAsync(getJSON('/user/gitlab-sync/repos'))
|
||||
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||
}, [runAsync])
|
||||
|
||||
const reposExist = data?.repos != null
|
||||
const repos = reposExist ? data.repos : []
|
||||
|
||||
const {
|
||||
isLoading: isImporting,
|
||||
isSuccess: isImported,
|
||||
isError: isErrorImport,
|
||||
runAsync: runAsyncImport,
|
||||
} = useAsync<{ projectId: string }>()
|
||||
|
||||
useEffect(() => {
|
||||
if (isImported) {
|
||||
handleHide()
|
||||
}
|
||||
}, [handleHide, isImported])
|
||||
|
||||
let gitlabUrl = getMeta('ol-ExposedSettings').gitlabUrl || ''
|
||||
gitlabUrl = gitlabUrl.endsWith('/') ? gitlabUrl.slice(0, -1) : gitlabUrl
|
||||
|
||||
const showLinkToGitLab = !isImporting && isSuccess && !reposExist
|
||||
const showRepos = !isImporting && isSuccess && reposExist
|
||||
|
||||
const handleImport = (repo: GitSyncRepo) => {
|
||||
|
||||
runAsyncImport(
|
||||
postJSON('/project/new/gitlab-sync', {
|
||||
body: repo
|
||||
})
|
||||
)
|
||||
.then(data => {
|
||||
window.location.href = `/project/${data.projectId}`
|
||||
})
|
||||
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalHeader onClose={handleHide}>
|
||||
<OLModalTitle>{t('import_from_gitlab')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
{isLoading && (
|
||||
<span>
|
||||
<OLSpinner size="sm" className="me-2"/>
|
||||
{t('loading_gitlab_repositories')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isImporting && (
|
||||
<span>
|
||||
<OLSpinner size="sm" className="me-2"/>
|
||||
{t('importing')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="notification-list">
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={t('something_went_wrong_server')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLinkToGitLab && (
|
||||
<div className="text-center">
|
||||
<p>
|
||||
{t('link_to_gitlab_description', { appName })}
|
||||
</p>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
href="/user/gitlab-sync/oauth2"
|
||||
>
|
||||
{t('link_to_gitlab')}
|
||||
</OLButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRepos && (
|
||||
<div>
|
||||
{repos.length === 0 ? (
|
||||
<p className="text-center">
|
||||
{t('you_dont_have_any_repositories')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center">
|
||||
{t('select_gitlab_repository', { appName })}
|
||||
</p>
|
||||
<div className="table-container table-container-bordered">
|
||||
<table className="table table-striped table-hover">
|
||||
<tbody>
|
||||
{repos.map(repo => (
|
||||
<tr key={repo.fullName}>
|
||||
<td>
|
||||
{repo.name}
|
||||
<div className="small">
|
||||
<a
|
||||
href={`${gitlabUrl}/${repo.fullName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{repo.fullName}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => handleImport(repo)}
|
||||
>
|
||||
{t('import_to_sharelatex', { appName: 'Overleaf' })}
|
||||
</OLButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isErrorImport && (
|
||||
<div className="notification-list">
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={t('something_went_wrong_server')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
{!isImporting && (
|
||||
<span className="me-auto">
|
||||
<a
|
||||
href="https://docs.gitlab.com/administration/settings/account_and_limit_settings/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('cant_see_what_youre_looking_for_question')}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
disabled={isImporting}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ImportFromGitLabModal({ onHide }: { onHide: () => void }) {
|
||||
return (
|
||||
<OLModal
|
||||
id="git-import-modal"
|
||||
show
|
||||
animation
|
||||
size="lg"
|
||||
onHide={onHide}
|
||||
backdrop="static"
|
||||
>
|
||||
<ImportFromGitLabModalContent handleHide={onHide} />
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { ProjectSyncState } from '../../types/git-sync-types'
|
||||
|
||||
type GitSyncNeedPermissionModalProps = {
|
||||
projectSyncState: ProjectSyncState
|
||||
handleHide: () => void
|
||||
}
|
||||
|
||||
const GitSyncCannotExportModal = ({ projectSyncState, handleHide }: GitSyncCannotExportModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<OLNotification
|
||||
type="warning"
|
||||
content={(
|
||||
<Trans
|
||||
i18nKey="ask_proj_owner_to_export_to_gitlab"
|
||||
values={{
|
||||
projectOwnerEmail: projectSyncState.ownerEmail ?? '?',
|
||||
}}
|
||||
components={[
|
||||
projectSyncState.ownerEmail ? <a href={`mailto:${projectSyncState.ownerEmail}`} /> : <></>
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncCannotExportModal
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { deleteJSON } from '@/infrastructure/fetch-json'
|
||||
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
type GitSyncUnlinkModalProps = {
|
||||
handleHide: () => void
|
||||
setModalStatus: (modalStatus: GitSyncModalStatus) => void
|
||||
projectSyncState: ProjectSyncState
|
||||
projectId: string
|
||||
}
|
||||
|
||||
const GitSyncUnlinkModal = ({
|
||||
handleHide,
|
||||
setModalStatus,
|
||||
projectSyncState,
|
||||
projectId,
|
||||
}: GitSyncInitModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
error,
|
||||
isLoading,
|
||||
runAsync,
|
||||
} = useAsync<void>()
|
||||
|
||||
const handleUnlink = () => {
|
||||
runAsync(deleteJSON(`/project/${projectId}/gitlab-sync`))
|
||||
.then(() => setModalStatus('need-export'))
|
||||
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||
}
|
||||
|
||||
let gitlabUrl = getMeta('ol-ExposedSettings').gitlabUrl || ''
|
||||
gitlabUrl = gitlabUrl.endsWith('/') ? gitlabUrl.slice(0, -1) : gitlabUrl
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<p>
|
||||
{t('project_linked_to')}:
|
||||
<a
|
||||
href={`${gitlabUrl}/${projectSyncState.repoFullName}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{projectSyncState.repoFullName}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
{!error ? (
|
||||
<OLNotification
|
||||
type="warning"
|
||||
content={t('unlink_the_project_from_the_current_gitlab_repo')}
|
||||
/>
|
||||
) : (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={
|
||||
error.info?.statusCode === 403 ? (
|
||||
<Trans
|
||||
i18nKey="ask_proj_owner_to_unlink_from_current_gitlab"
|
||||
values={{ projectOwnerEmail: error?.data?.ownerEmail ?? '?' }}
|
||||
components={[
|
||||
error?.data?.ownerEmail ? <a href={`mailto:${error.data.ownerEmail}`} /> : <></>
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
t('something_went_wrong_server')
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</OLModalBody>
|
||||
|
||||
|
||||
<OLModalFooter>
|
||||
<div className="d-flex gap-2">
|
||||
<OLButton
|
||||
variant="danger"
|
||||
onClick={handleUnlink}
|
||||
disabled={isLoading || !!error}
|
||||
>
|
||||
{t('confirm')}
|
||||
</OLButton>
|
||||
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncUnlinkModal
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
|
||||
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
|
||||
|
||||
type GitSyncConflictModalProps = {
|
||||
projectSyncState: ProjectSyncState
|
||||
handleHide: () => void
|
||||
setModalStatus: (modalStatus: GitSyncModalStatus) => void
|
||||
}
|
||||
|
||||
const GitSyncConflictModal = ({ projectSyncState, handleHide, setModalStatus }: GitSyncConflictModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { appName } = getMeta('ol-ExposedSettings')
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<OLNotification
|
||||
type="warning"
|
||||
content={t('gitlab_merge_failed_error', { appName })}
|
||||
/>
|
||||
<p className="mt-2">
|
||||
<Trans
|
||||
i18nKey="gitlab_manual_merge_user_prompt"
|
||||
values={{sharelatex_branch: projectSyncState.unmergedBranchName }}
|
||||
components={[<b />]}
|
||||
/>
|
||||
</p>
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={
|
||||
() => setModalStatus('run-merge-resolved')
|
||||
}
|
||||
>
|
||||
{t('continue_gitlab_merge')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncConflictModal
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useEffect } from 'react'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import {
|
||||
getJSON,
|
||||
postJSON
|
||||
} from '@/infrastructure/fetch-json'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import OLFormSelect from '@/shared/components/ol/ol-form-select'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import { GitSyncModalStatus } from '../../types/git-sync-types'
|
||||
|
||||
type OrgsResponse = {
|
||||
user: string
|
||||
orgs: string[]
|
||||
}
|
||||
|
||||
type GitSyncExportModalProps = {
|
||||
projectId: string
|
||||
projectName: string
|
||||
handleHide: () => void
|
||||
setModalStatus: (modalStatus: GitSyncModalStatus) => void
|
||||
}
|
||||
|
||||
const GitSyncExportModal = ({
|
||||
projectId,
|
||||
projectName,
|
||||
handleHide,
|
||||
setModalStatus
|
||||
}: GitSyncExportModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [selectedOwner, setSelectedOwner] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [repoName, setRepoName] = useState(projectName)
|
||||
const [description, setDescription] = useState('')
|
||||
const [visibility, setVisibility] = useState<'public' | 'private'>('private')
|
||||
|
||||
const {
|
||||
runAsync: runAsyncUserAndOrgs,
|
||||
data: userAndOrgs,
|
||||
error: errorUserAndOrgs
|
||||
} = useAsync<OrgsResponse>()
|
||||
|
||||
useEffect(() => {
|
||||
runAsyncUserAndOrgs(getJSON('/user/gitlab-sync/orgs'))
|
||||
.then(userAndOrgs => setSelectedOwner(userAndOrgs?.user))
|
||||
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||
}, [])
|
||||
|
||||
const { isLoading, error, setError, runAsync } = useAsync<void>()
|
||||
|
||||
const createRepo = () => {
|
||||
const isPublic = visibility === 'public'
|
||||
const org = selectedOwner === userAndOrgs?.user ? undefined : selectedOwner
|
||||
|
||||
runAsync(postJSON(`/project/${projectId}/gitlab-sync/export`, {
|
||||
body: {
|
||||
name: repoName,
|
||||
description,
|
||||
isPublic,
|
||||
org,
|
||||
},
|
||||
}))
|
||||
.then(() => setModalStatus('loading'))
|
||||
.catch(err => {
|
||||
debugConsole.error(err?.data?.message || err?.message || err)
|
||||
if (!err?.data?.key) setError(t('something_went_wrong_server'))
|
||||
else setError(t(err.data.key))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<h4>{t('export_project_to_gitlab')}</h4>
|
||||
<p>{t('project_not_linked_to_gitlab')}</p>
|
||||
|
||||
{error && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorUserAndOrgs && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={t('something_went_wrong_server')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OLForm onSubmit={createRepo}>
|
||||
<OLRow>
|
||||
<OLCol xs={4}>
|
||||
<OLFormGroup>
|
||||
<OLFormLabel htmlFor="gitlab-sync-owner">
|
||||
{selectedOwner === userAndOrgs?.user
|
||||
? t('owner')
|
||||
: t('organization')}
|
||||
</OLFormLabel>
|
||||
<OLFormSelect
|
||||
id="gitlab-sync-owner"
|
||||
name="org"
|
||||
value={selectedOwner}
|
||||
onChange={e => setSelectedOwner(e.target.value)}
|
||||
>
|
||||
<option key={userAndOrgs?.user} value={userAndOrgs?.user}>
|
||||
{userAndOrgs?.user}
|
||||
</option>
|
||||
{userAndOrgs?.orgs.map(org => (
|
||||
<option key={org} value={org}>
|
||||
{org}
|
||||
</option>
|
||||
))}
|
||||
</OLFormSelect>
|
||||
</OLFormGroup>
|
||||
</OLCol>
|
||||
|
||||
<OLCol xs={5}>
|
||||
<OLFormGroup>
|
||||
<OLFormLabel htmlFor="gitlab-sync-name">
|
||||
{t('repository_name')}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
id="gitlab-sync-name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={e => setRepoName(e.target.value)}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
|
||||
<OLRow>
|
||||
<OLCol xs={12}>
|
||||
<OLFormGroup>
|
||||
<OLFormLabel htmlFor="gitlab-sync-description">
|
||||
{t('description')} ({t('optional')})
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
id="gitlab-sync-description"
|
||||
name="description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
|
||||
<hr />
|
||||
|
||||
<fieldset>
|
||||
<legend className="visually-hidden">
|
||||
{t('repository_visibility')}
|
||||
</legend>
|
||||
|
||||
<OLFormGroup>
|
||||
<OLRow>
|
||||
<OLCol xs={12}>
|
||||
<OLFormCheckbox
|
||||
type="radio"
|
||||
id="public"
|
||||
name="repository"
|
||||
value="public"
|
||||
checked={visibility === 'public'}
|
||||
onChange={() => setVisibility('public')}
|
||||
label={t('public', { defaultValue: 'Public' })}
|
||||
description={t('gitlab_public_description')}
|
||||
/>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
|
||||
<OLRow>
|
||||
<OLCol xs={12}>
|
||||
<OLFormCheckbox
|
||||
className="mr-1"
|
||||
type="radio"
|
||||
id="private"
|
||||
name="repository"
|
||||
value="private"
|
||||
checked={visibility === 'private'}
|
||||
onChange={() => setVisibility('private')}
|
||||
label={t('private', { defaultValue: 'Private' })}
|
||||
description={t('gitlab_private_description')}
|
||||
/>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</OLFormGroup>
|
||||
</fieldset>
|
||||
</OLForm>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={createRepo}
|
||||
disabled={!repoName.trim() || isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{t('create_project_in_gitlab')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncExportModal
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import { GitSyncModalStatus } from '../../types/git-sync-types'
|
||||
|
||||
type GitSyncLoadingModalProps = {
|
||||
handleHide: () => void
|
||||
setModalStatus: (modalStatus: GitSyncModalStatus) => void
|
||||
errorMessage: string | null
|
||||
}
|
||||
const GitSyncLoadingModal = ({ handleHide, setModalStatus, errorMessage }: GitSyncLoadingModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<div role="status" className="loading align-items-start">
|
||||
<div aria-hidden="true" data-testid="ol-spinner" className="spinner-border spinner-border-sm"></div>
|
||||
{t('checking_project_gitlab_status')}
|
||||
</div>
|
||||
</OLModalBody>
|
||||
|
||||
{errorMessage && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={errorMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OLModalFooter>
|
||||
{errorMessage && (
|
||||
<OLButton
|
||||
variant="danger-ghost"
|
||||
onClick={() => setModalStatus('confirm-unlink')}
|
||||
>
|
||||
{t('unlink')}
|
||||
</OLButton>
|
||||
)}
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncLoadingModal
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import clientId from '@/utils/client-id'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types'
|
||||
|
||||
import { useReferencesContext } from '@/features/ide-react/context/references-context'
|
||||
|
||||
type GitSyncMergeModalProps = {
|
||||
handleHide: () => void
|
||||
modalStatus: GitSyncModalStatus
|
||||
setModalStatus: (modalStatus: GitSyncModalStatus) => void
|
||||
setProjectSyncState: (projectSyncState: ProjectSyncState) => void
|
||||
commitMessage: string
|
||||
setCommitMessage: (message: string) => void
|
||||
projectId: string
|
||||
}
|
||||
|
||||
const GitSyncMergeModal = ({
|
||||
handleHide,
|
||||
modalStatus,
|
||||
setModalStatus,
|
||||
setProjectSyncState,
|
||||
commitMessage,
|
||||
setCommitMessage,
|
||||
projectId
|
||||
}: GitSyncMergeModalProps) => {
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { error, isError, isLoading, setError, runAsync } = useAsync<ProjectSyncState>()
|
||||
const { indexAllReferences } = useReferencesContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (modalStatus !== 'run-merge' &&
|
||||
modalStatus !== 'run-merge-resolved'
|
||||
) return
|
||||
|
||||
const claimConflictIsResolved = (modalStatus === 'run-merge-resolved')
|
||||
|
||||
runAsync(
|
||||
postJSON(`/project/${projectId}/gitlab-sync/merge`, {
|
||||
body: { message: commitMessage, claimConflictIsResolved },
|
||||
})
|
||||
)
|
||||
.then(async data => {
|
||||
switch (data.mergeStatus) {
|
||||
|
||||
case 'clean':
|
||||
setModalStatus('merge-overview')
|
||||
|
||||
await postJSON(`/project/${projectId}/flush`)
|
||||
.catch(e => debugConsole.error(e))
|
||||
|
||||
indexAllReferences(true)
|
||||
|
||||
break
|
||||
|
||||
case 'conflict':
|
||||
setModalStatus('show-conflict')
|
||||
break
|
||||
|
||||
default:
|
||||
const message = `Unexpected merge status received: ${data.mergeStatus}`
|
||||
const msError = new Error(message)
|
||||
setError(msError)
|
||||
throw msError
|
||||
}
|
||||
setProjectSyncState(data)
|
||||
setCommitMessage('')
|
||||
})
|
||||
.catch(err => {
|
||||
debugConsole.error(err?.data?.message || err?.message || err)
|
||||
if (err?.info?.statusCode === 403 || err?.info?.statusCode === 404) setModalStatus('loading')
|
||||
})
|
||||
|
||||
}, [
|
||||
modalStatus,
|
||||
projectId,
|
||||
commitMessage,
|
||||
runAsync,
|
||||
])
|
||||
|
||||
if (!isLoading && !isError) return
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<div
|
||||
role="status"
|
||||
className="loading align-items-start"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="ol-spinner"
|
||||
className="spinner-border spinner-border-sm"
|
||||
></div>
|
||||
|
||||
{t('importing_and_merging_changes_in_gitlab')}
|
||||
</div>
|
||||
</OLModalBody>
|
||||
|
||||
{error && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={t('generic_something_went_wrong')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncMergeModal
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
type GitSyncMergeOverviewModalProps = {
|
||||
handleHide: () => void
|
||||
setModalStatus: (modalStatus: GitSyncModalStatus) => void
|
||||
projectSyncState: ProjectSyncState
|
||||
projectId: string
|
||||
commitMessage: string
|
||||
setCommitMessage: (message: string) => void
|
||||
|
||||
}
|
||||
|
||||
type GitCommit = {
|
||||
message: string
|
||||
author: {
|
||||
name: string
|
||||
email: string
|
||||
date: string
|
||||
}
|
||||
sha: string
|
||||
}
|
||||
|
||||
type UnmergedCommitsResponse = {
|
||||
commits: GitCommit[]
|
||||
diverged: boolean
|
||||
isProjectUpdated: boolean
|
||||
}
|
||||
|
||||
const GitSyncMergeOverviewModal = ({
|
||||
handleHide,
|
||||
setModalStatus,
|
||||
projectSyncState,
|
||||
projectId,
|
||||
commitMessage,
|
||||
setCommitMessage
|
||||
}: GitSyncMergeOverviewModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const appName = 'Overleaf'
|
||||
|
||||
const {
|
||||
data,
|
||||
setData,
|
||||
error,
|
||||
isSuccess,
|
||||
isLoading,
|
||||
runAsync,
|
||||
} = useAsync<UnmergedCommitsResponse | null>()
|
||||
|
||||
const loadUnmergedCommits = () => {
|
||||
setData({})
|
||||
|
||||
runAsync(getJSON(`/project/${projectId}/gitlab-sync/merge/overview`))
|
||||
.then(data => { if (!data) setModalStatus('loading') })
|
||||
.catch(err => {
|
||||
debugConsole.error(err?.data?.message || err?.message || err)
|
||||
if (err?.info?.statusCode === 403 ||
|
||||
err?.info?.statusCode === 404 ||
|
||||
err?.info?.statusCode === 401
|
||||
) setModalStatus('loading')
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUnmergedCommits()
|
||||
}, [projectId, runAsync])
|
||||
|
||||
let gitlabUrl = getMeta('ol-ExposedSettings').gitlabUrl || ''
|
||||
gitlabUrl = gitlabUrl.endsWith('/') ? gitlabUrl.slice(0, -1) : gitlabUrl
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<p>
|
||||
{t('project_linked_to')}:
|
||||
<a
|
||||
href={`${gitlabUrl}/${projectSyncState.repoFullName}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{projectSyncState.repoFullName}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
<div role="status" className="loading align-items-start">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="ol-spinner"
|
||||
className="spinner-border spinner-border-sm"
|
||||
></div>
|
||||
{t('loading')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<>
|
||||
<hr />
|
||||
|
||||
{data.diverged && (
|
||||
<OLNotification
|
||||
type="warning"
|
||||
content={t('gitlab_repository_diverged')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h3 className="gitlab-sync-commits-heading">
|
||||
{t('recent_commits_in_gitlab')}
|
||||
|
||||
<OLIconButton
|
||||
icon="refresh"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={loadUnmergedCommits}
|
||||
accessibilityLabel={t('refresh')}
|
||||
/>
|
||||
</h3>
|
||||
|
||||
{data.commits?.length === 0 && (
|
||||
<div>
|
||||
<p className="small">{t('no_new_commits_in_gitlab')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.commits?.length > 0 && (
|
||||
<div style={{ maxHeight: '200px', overflow: 'auto' }}>
|
||||
{data.commits.map((commit: GitCommit) => (
|
||||
<div key={commit.sha}>
|
||||
<span className="small float-end">
|
||||
<a
|
||||
href={`${gitlabUrl}/${projectSyncState.repoFullName}/-/commit/${commit.sha}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{commit.sha.substring(0, 7)}
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={`${gitlabUrl}/${projectSyncState.repoFullName}/-/commit/${commit.sha}`}
|
||||
target="_blank"
|
||||
className="commit-message"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{commit.message}
|
||||
</a>
|
||||
|
||||
<div className="small">
|
||||
by {commit.author.name} <{commit.author.email}>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.isProjectUpdated && (
|
||||
<>
|
||||
<hr />
|
||||
<OLForm>
|
||||
<p>{t('sync_project_to_gitlab_explanation', { appName })}</p>
|
||||
<OLRow>
|
||||
<OLCol xs={12}>
|
||||
<OLFormGroup>
|
||||
<OLFormControl
|
||||
as="textarea"
|
||||
rows={1}
|
||||
value={commitMessage}
|
||||
placeholder={t('gitlab_commit_message_placeholder', { appName })}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</OLForm>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={t('generic_something_went_wrong')}
|
||||
/>
|
||||
)}
|
||||
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<div className="d-flex justify-content-between w-100">
|
||||
|
||||
<div className="d-flex gap-2">
|
||||
<OLButton
|
||||
variant="danger-ghost"
|
||||
onClick={() => setModalStatus('confirm-unlink')}
|
||||
disabled={!isSuccess && error?.info?.statusCode !== 404}
|
||||
>
|
||||
{t('unlink')}
|
||||
</OLButton>
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-2">
|
||||
<OLButton
|
||||
variant="primary"
|
||||
leadingIcon="sync"
|
||||
onClick={() => setModalStatus('run-merge')}
|
||||
disabled={!isSuccess}
|
||||
>
|
||||
{t('sync')}
|
||||
</OLButton>
|
||||
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncMergeOverviewModal
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
OLModal,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
import GitSyncLoadingModal from './git-sync-loading-modal'
|
||||
import GitSyncMergeOverviewModal from './git-sync-merge-overview-modal'
|
||||
import GitSyncMergeModal from './git-sync-merge-modal'
|
||||
import GitSyncConflictModal from './git-sync-conflict-modal'
|
||||
import GitSyncExportModal from './git-sync-export-modal'
|
||||
import GitSyncNeedAuthModal from './git-sync-need-auth-modal'
|
||||
import GitSyncNeedPermissionModal from './git-sync-need-permission-modal'
|
||||
import GitSyncConfirmUnlinkModal from './git-sync-confirm-unlink-modal'
|
||||
import GitSyncUnlinkUnavailableModal from './git-sync-unlink-unavailable-modal'
|
||||
import GitSyncCannotExportModal from './git-sync-cannot-export-modal'
|
||||
|
||||
import { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types'
|
||||
|
||||
type GitSyncModalProps = {
|
||||
show: boolean
|
||||
handleHide: () => void
|
||||
projectId: string
|
||||
projectName: string
|
||||
modalStatus: GitSyncModalStatus
|
||||
setModalStatus: (modalStatus: GitSyncModalStatus) => void
|
||||
}
|
||||
|
||||
function GitSyncModal({
|
||||
show,
|
||||
handleHide,
|
||||
projectId,
|
||||
projectName,
|
||||
modalStatus,
|
||||
setModalStatus,
|
||||
}: GitSyncModalProps) {
|
||||
|
||||
const { t } = useTranslation()
|
||||
const [commitMessage, setCommitMessage] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
const {
|
||||
runAsync: runAsyncConn,
|
||||
error: errorConn,
|
||||
setError: setErrorConn,
|
||||
data: dataConn,
|
||||
} = useAsync<boolean>()
|
||||
|
||||
const {
|
||||
runAsync,
|
||||
data: projectSyncState,
|
||||
setData: setProjectSyncState,
|
||||
error,
|
||||
setError,
|
||||
} = useAsync<ProjectSyncState>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
if (modalStatus !== 'loading') return
|
||||
|
||||
const fetchProjectState = () => {
|
||||
setError(null)
|
||||
setErrorMessage('')
|
||||
|
||||
runAsync(getJSON(`/project/${projectId}/gitlab-sync/state`))
|
||||
.then(pss => {
|
||||
switch (pss.mergeStatus) {
|
||||
// pss.ownerEmail is set to the email address of the project owner
|
||||
// or null if the user is the project owner
|
||||
// only owner can export project to git server
|
||||
case 'need-export':
|
||||
if (!pss.ownerEmail) setModalStatus('need-export')
|
||||
else setModalStatus('cannot-export')
|
||||
break
|
||||
|
||||
case 'clean':
|
||||
case 'diverged':
|
||||
setModalStatus('merge-overview')
|
||||
break
|
||||
|
||||
case 'conflict':
|
||||
setModalStatus('show-conflict')
|
||||
break
|
||||
|
||||
// user does not have push permission to repo
|
||||
// pss.ownerEmail is set to the email address of the owner
|
||||
// or null if the user is the project owner
|
||||
// if the user is owner, it seems that repo is not accessible
|
||||
case 'need-permission': {
|
||||
if (!pss.ownerEmail) setModalStatus('unlink-unavailable')
|
||||
else setModalStatus('need-permission')
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
debugConsole.error(err?.data?.message || err?.message || err)
|
||||
setErrorMessage(t('generic_something_went_wrong'))
|
||||
})
|
||||
}
|
||||
|
||||
const fetchConnectionStatus = () => {
|
||||
setErrorConn(null)
|
||||
setErrorMessage('')
|
||||
|
||||
runAsyncConn(getJSON(`/user/gitlab-sync/status`))
|
||||
.then(isConnected => {
|
||||
if (!isConnected) {
|
||||
setModalStatus('need-auth')
|
||||
return
|
||||
}
|
||||
fetchProjectState()
|
||||
})
|
||||
.catch(err => {
|
||||
debugConsole.error(err?.data?.message || err?.message || err)
|
||||
setErrorMessage(t('generic_something_went_wrong'))
|
||||
})
|
||||
}
|
||||
|
||||
fetchConnectionStatus()
|
||||
|
||||
}, [show, modalStatus, projectId, runAsyncConn, runAsync])
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={handleHide} backdrop="static">
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('sync_with_gitlab')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
{modalStatus === 'loading' && (
|
||||
<GitSyncLoadingModal
|
||||
handleHide={handleHide}
|
||||
setModalStatus={setModalStatus}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalStatus === 'need-export' && (
|
||||
<GitSyncExportModal
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
handleHide={handleHide}
|
||||
setModalStatus={setModalStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalStatus === 'cannot-export' && (
|
||||
<GitSyncCannotExportModal
|
||||
projectSyncState={projectSyncState}
|
||||
handleHide={handleHide}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalStatus === 'need-auth' && (
|
||||
<GitSyncNeedAuthModal
|
||||
handleHide={handleHide}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalStatus === 'need-permission' && (
|
||||
<GitSyncNeedPermissionModal
|
||||
handleHide={handleHide}
|
||||
projectSyncState={projectSyncState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalStatus === 'merge-overview' && (
|
||||
<GitSyncMergeOverviewModal
|
||||
projectId={projectId}
|
||||
projectSyncState={projectSyncState}
|
||||
setModalStatus={setModalStatus}
|
||||
handleHide={handleHide}
|
||||
commitMessage={commitMessage}
|
||||
setCommitMessage={setCommitMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalStatus === 'confirm-unlink' && (
|
||||
<GitSyncConfirmUnlinkModal
|
||||
projectId={projectId}
|
||||
projectSyncState={projectSyncState}
|
||||
setModalStatus={setModalStatus}
|
||||
handleHide={handleHide}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalStatus === 'unlink-unavailable' && (
|
||||
<GitSyncUnlinkUnavailableModal
|
||||
projectId={projectId}
|
||||
setModalStatus={setModalStatus}
|
||||
handleHide={handleHide}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(modalStatus === 'run-merge' ||
|
||||
modalStatus === 'run-merge-resolved') && (
|
||||
<GitSyncMergeModal
|
||||
handleHide={handleHide}
|
||||
modalStatus={modalStatus}
|
||||
setModalStatus={setModalStatus}
|
||||
commitMessage={commitMessage}
|
||||
setCommitMessage={setCommitMessage}
|
||||
projectId={projectId}
|
||||
setProjectSyncState={setProjectSyncState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalStatus === 'show-conflict' && (
|
||||
<GitSyncConflictModal
|
||||
projectSyncState={projectSyncState}
|
||||
handleHide={handleHide}
|
||||
setModalStatus={setModalStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncModal
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
|
||||
const GitSyncNeedAuthModal = ({ handleHide }: { handleHide: () => void }) => {
|
||||
const { t } = useTranslation()
|
||||
const { appName } = getMeta('ol-ExposedSettings')
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<p>{t('link_to_gitlab_description', { appName })}</p>
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
handleHide()
|
||||
window.open(
|
||||
'/user/gitlab-sync/oauth2',
|
||||
'gitlabAuth',
|
||||
'width=600,height=700'
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t('link_to_gitlab')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncNeedAuthModal
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { ProjectSyncState } from '../../types/git-sync-types'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
type GitSyncNeedPermissionModalProps = {
|
||||
projectSyncState: ProjectSyncState
|
||||
handleHide: () => void
|
||||
}
|
||||
|
||||
const GitSyncNeedPermissionModal = ({ projectSyncState, handleHide }: GitSyncConflictModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
let gitlabUrl = getMeta('ol-ExposedSettings').gitlabUrl || ''
|
||||
gitlabUrl = gitlabUrl.endsWith('/') ? gitlabUrl.slice(0, -1) : gitlabUrl
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<OLNotification
|
||||
type="warning"
|
||||
content={(
|
||||
<Trans
|
||||
i18nKey="ask_proj_owner_to_add_you_as_gitlab_collaborator"
|
||||
values={{
|
||||
repoFullName: projectSyncState.repoFullName ?? '?',
|
||||
projectOwnerEmail: projectSyncState.ownerEmail ?? '?',
|
||||
}}
|
||||
components={[
|
||||
projectSyncState.repoFullName ? (
|
||||
<a
|
||||
href={`${gitlabUrl}/${projectSyncState.repoFullName}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
projectSyncState.ownerEmail ? <a href={`mailto:${projectSyncState.ownerEmail}`} /> : <></>
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncNeedPermissionModal
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { deleteJSON } from '@/infrastructure/fetch-json'
|
||||
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { GitSyncModalStatus } from '../../types/git-sync-types'
|
||||
|
||||
type GitSyncUnlinkUnavailableModalProps = {
|
||||
handleHide: () => void
|
||||
setModalStatus: (modalStatus: GitSyncModalStatus) => void
|
||||
projectId: string
|
||||
}
|
||||
|
||||
const GitSyncUnlinkUnavailableModal = ({
|
||||
handleHide,
|
||||
setModalStatus,
|
||||
projectId,
|
||||
}: GitSyncInitModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
error,
|
||||
isLoading,
|
||||
runAsync,
|
||||
} = useAsync<void>()
|
||||
|
||||
const handleUnlink = () => {
|
||||
runAsync(deleteJSON(`/project/${projectId}/gitlab-sync`))
|
||||
.then(() => setModalStatus('need-export'))
|
||||
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={t('gitlab_sync_repository_not_found_description')}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={
|
||||
error.info?.statusCode === 403 ? (
|
||||
<Trans
|
||||
i18nKey="ask_proj_owner_to_unlink_from_current_gitlab"
|
||||
values={{ projectOwnerEmail: error?.data?.ownerEmail ?? '?' }}
|
||||
components={[
|
||||
error?.data?.ownerEmail ? <a href={`mailto:${error.data.ownerEmail}`} /> : <></>
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
t('something_went_wrong_server')
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<div className="d-flex gap-2">
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={handleUnlink}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('unlink_gitlab_repository')}
|
||||
</OLButton>
|
||||
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncUnlinkUnavailableModal
|
||||
@@ -0,0 +1,19 @@
|
||||
export type GitSyncModalStatus =
|
||||
'loading'
|
||||
| 'need-export'
|
||||
| 'cannot-export'
|
||||
| 'need-auth'
|
||||
| 'need-permission'
|
||||
| 'merge-overview'
|
||||
| 'confirm-unlink'
|
||||
| 'unlink-unavailable'
|
||||
| 'run-merge'
|
||||
| 'run-merge-resolved'
|
||||
| 'show-conflict'
|
||||
|
||||
export type ProjectSyncState = {
|
||||
mergeStatus: 'clean' | 'conflict' | 'diverged' | 'need-export' | 'need-permission'
|
||||
repoFullName: string
|
||||
unmergedBranchName: string | null
|
||||
ownerEmail?: string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.gitlab-sync-commits-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: var(--font-size-03);
|
||||
font-weight: 600;
|
||||
justify-content: space-between;
|
||||
line-height: var(--line-height-03);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import Modules from '../../app/src/infrastructure/Modules.mjs'
|
||||
import logger from '@overleaf/logger'
|
||||
|
||||
let GitLabSyncModule = {}
|
||||
if (process.env.GITLAB_SYNC_ENABLED?.toLowerCase() === 'true') {
|
||||
logger.debug({}, 'Enabling GitLab Sync module')
|
||||
|
||||
// The GITLAB_URL variable has to be set for this module to work, otherwise the module will not be enabled
|
||||
if (process.env.GITLAB_SYNC_URL === undefined || process.env.GITLAB_SYNC_URL === '') {
|
||||
logger.warn({}, 'GitLab Sync module is enabled but GITLAB_SYNC_URL is not set, stopping module initialization')
|
||||
} else {
|
||||
const [{ default: GitLabSyncRouter },
|
||||
{ default: SyncStateManager },
|
||||
{ default: TokenManager }
|
||||
] =
|
||||
await Promise.all([
|
||||
import('./app/src/GitLabSyncRouter.mjs'),
|
||||
import('./app/src/SyncStateManager.mjs'),
|
||||
import('./app/src/TokenManager.mjs'),
|
||||
])
|
||||
|
||||
const siteUrl = Settings.siteUrl.replace(/\/+$/, '') || 'http://localhost'
|
||||
Settings.gitlabSync = {
|
||||
clientID: process.env.GITLAB_SYNC_CLIENT_ID,
|
||||
clientSecret: process.env.GITLAB_SYNC_CLIENT_SECRET,
|
||||
callbackURL: `${siteUrl}/user/gitlab-sync/oauth2/callback`,
|
||||
},
|
||||
|
||||
// Delete project sync state from mongo (hook 'projectExpired')
|
||||
Modules.hooks.attach('projectExpired', async projectId => {
|
||||
try {
|
||||
await SyncStateManager.removeProjectState(projectId)
|
||||
logger.debug({ projectId }, 'on project expire: removed Git sync state')
|
||||
} catch (err) {
|
||||
logger.warn({ projectId, err }, 'on project expire: failed to remove Git sync state')
|
||||
}
|
||||
})
|
||||
|
||||
// Delete user gitlab token from mongo (hook 'expireDeletedUser')
|
||||
Modules.hooks.attach('expireDeletedUser', async userId => {
|
||||
try {
|
||||
await TokenManager.removeUserToken(userId)
|
||||
} catch (err) {
|
||||
logger.warn({ userId, err }, 'on user expire: failed removing user token')
|
||||
}
|
||||
})
|
||||
|
||||
GitLabSyncModule = {
|
||||
router: GitLabSyncRouter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default GitLabSyncModule
|
||||
@@ -74,7 +74,6 @@
|
||||
"last 1 year",
|
||||
"safari >= 15"
|
||||
],
|
||||
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "^4.0.113",
|
||||
"@ai-sdk/mcp": "patch:@ai-sdk/mcp@npm%3A1.0.37#~/.yarn/patches/@ai-sdk-mcp-npm-1.0.37-8cd89b8972.patch",
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('Features', function () {
|
||||
expect(ctx.Features.hasFeature('analytics')).to.be.false
|
||||
expect(ctx.Features.hasFeature('git-bridge')).to.be.false
|
||||
expect(ctx.Features.hasFeature('github-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('homepage')).to.be.false
|
||||
expect(ctx.Features.hasFeature('link-url')).to.be.false
|
||||
expect(ctx.Features.hasFeature('oauth')).to.be.false
|
||||
@@ -78,6 +79,7 @@ describe('Features', function () {
|
||||
expect(ctx.Features.hasFeature('analytics')).to.be.false
|
||||
expect(ctx.Features.hasFeature('git-bridge')).to.be.false
|
||||
expect(ctx.Features.hasFeature('github-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('homepage')).to.be.false
|
||||
expect(ctx.Features.hasFeature('link-url')).to.be.false
|
||||
expect(ctx.Features.hasFeature('oauth')).to.be.false
|
||||
@@ -108,6 +110,7 @@ describe('Features', function () {
|
||||
expect(ctx.Features.hasFeature('link-url')).to.be.false
|
||||
expect(ctx.Features.hasFeature('git-bridge')).to.be.false
|
||||
expect(ctx.Features.hasFeature('github-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('homepage')).to.be.false
|
||||
expect(ctx.Features.hasFeature('oauth')).to.be.false
|
||||
expect(ctx.Features.hasFeature('saml')).to.be.false
|
||||
@@ -118,6 +121,7 @@ describe('Features', function () {
|
||||
ctx.settings.enableHomepage = true
|
||||
ctx.settings.enableGitBridge = true
|
||||
ctx.settings.enableGithubSync = true
|
||||
ctx.settings.enableGitLabSync = true
|
||||
ctx.settings.enableSaml = true
|
||||
ctx.settings.oauth = true
|
||||
ctx.settings.enabledLinkedFileTypes = ['url', 'project_file']
|
||||
@@ -127,6 +131,7 @@ describe('Features', function () {
|
||||
expect(ctx.Features.hasFeature('affiliations')).to.be.true
|
||||
expect(ctx.Features.hasFeature('analytics')).to.be.true
|
||||
expect(ctx.Features.hasFeature('github-sync')).to.be.true
|
||||
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.true
|
||||
expect(ctx.Features.hasFeature('git-bridge')).to.be.true
|
||||
expect(ctx.Features.hasFeature('homepage')).to.be.true
|
||||
expect(ctx.Features.hasFeature('link-url')).to.be.true
|
||||
|
||||
@@ -53,6 +53,8 @@ export type ExposedSettings = {
|
||||
templatesEnabled?: boolean
|
||||
linkedInInsightsPartnerId?: string
|
||||
githubSyncEnabled: boolean
|
||||
gitlabSyncEnabled: boolean
|
||||
zoteroEnabled: boolean
|
||||
enablePandocConversions: boolean
|
||||
gitlabUrl: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user