Compare commits

...
Author SHA1 Message Date
Florian Porrmann 565b793055 GitLab Synchronization: Fixed v6.3.0 rebase related errors 2026-10-09 16:50:10 +02:00
Florian Porrmann fcb3d06f24 GitLab Synchronization: Replaced tabs with spaces to keep the style consistent 2026-09-28 00:34:31 +02:00
Florian Porrmann d037520105 GitLab Synchronization: Changed getUserToken to just return the token instead of the entire object, making the code more compatible with the Github integration 2026-09-28 00:34:31 +02:00
Florian Porrmann cb59625058 GitLab Synchronization: Fixed more merging related problems 2026-09-28 00:34:31 +02:00
yu-i-i 9c041338b3 Changes by @fporrmann
Added missing return in clean sync flow
Fixed incorrect initialization order
Rewrote commit comparison for merge state evaluation
Fixed a problem with the getBlobContent function
Removed old log lines
Fixed merging when there were changes in Gitlab and Overleaf
Removed additional log line
2026-09-28 00:34:31 +02:00
yu-i-i 3bfeb32bef GitLab Synchronization: add gitlabUrl to ExpressLocals; use settings.gitlabSync instead of env vars 2026-09-28 00:34:31 +02:00
Florian Porrmann d458e51918 GitLab Synchronization: Initial commit 2026-09-28 00:34:26 +02:00
50 changed files with 4433 additions and 8 deletions
@@ -424,6 +424,8 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
cioSiteId: Settings.analytics?.cio?.siteId, cioSiteId: Settings.analytics?.cio?.siteId,
linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId, linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId,
githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret, githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret,
gitlabSyncEnabled: !!Settings.gitlabSync,
gitlabUrl: Settings.gitlabSync?.url,
zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret, zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret,
enablePandocConversions: Settings.enablePandocConversions, enablePandocConversions: Settings.enablePandocConversions,
mixpanelLabsToken: mixpanelLabsToken:
@@ -16,6 +16,7 @@ const trackChangesModuleAvailable =
* @property {Object | undefined} apis.linkedUrlProxy * @property {Object | undefined} apis.linkedUrlProxy
* @property {string | undefined} apis.linkedUrlProxy.url * @property {string | undefined} apis.linkedUrlProxy.url
* @property {boolean | undefined} enableGithubSync * @property {boolean | undefined} enableGithubSync
* @property {boolean | undefined} enableGitLabSync
* @property {boolean | undefined} enableGitBridge * @property {boolean | undefined} enableGitBridge
* @property {boolean | undefined} enableHomepage * @property {boolean | undefined} enableHomepage
* @property {boolean | undefined} enableSaml * @property {boolean | undefined} enableSaml
@@ -61,6 +62,8 @@ const Features = {
return Boolean(Settings.disableLinkSharing) === false return Boolean(Settings.disableLinkSharing) === false
case 'github-sync': case 'github-sync':
return Boolean(Settings.enableGithubSync) return Boolean(Settings.enableGithubSync)
case 'gitlab-sync':
return Boolean(Settings.enableGitLabSync)
case 'git-bridge': case 'git-bridge':
return Boolean(Settings.enableGitBridge) return Boolean(Settings.enableGitBridge)
case 'oauth': case 'oauth':
@@ -6,6 +6,7 @@
const thirdPartyIcons = [ const thirdPartyIcons = [
{ file: 'git.svg', label: 'Git' }, { file: 'git.svg', label: 'Git' },
{ file: 'github.svg', label: 'GitHub' }, { file: 'github.svg', label: 'GitHub' },
{ file: 'gitlab.svg', label: 'GitLab' },
{ file: 'dropbox-transparent.svg', label: 'Dropbox' }, { file: 'dropbox-transparent.svg', label: 'Dropbox' },
{ file: 'zotero_64x64x32.png', label: 'Zotero' }, { file: 'zotero_64x64x32.png', label: 'Zotero' },
{ file: 'mendeley.svg', label: 'Mendeley' }, { file: 'mendeley.svg', label: 'Mendeley' },
+21
View File
@@ -1076,6 +1076,10 @@ module.exports = {
__dirname, __dirname,
'../modules/github-sync/frontend/js/components/github-sync-widget.tsx' '../modules/github-sync/frontend/js/components/github-sync-widget.tsx'
), ),
Path.resolve(
__dirname,
'../modules/gitlab-sync/frontend/js/components/gitlab-sync-widget.tsx'
),
], ],
referenceLinkingWidgets: [ referenceLinkingWidgets: [
Path.resolve( Path.resolve(
@@ -1095,6 +1099,18 @@ module.exports = {
'../modules/github-sync/frontend/js/components/import-from-github-menu.tsx' '../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: [], editorLeftMenuSync: [],
editorLeftMenuManageTemplate: [], editorLeftMenuManageTemplate: [],
menubarExtraComponents: [ menubarExtraComponents: [
@@ -1162,6 +1178,10 @@ module.exports = {
__dirname, __dirname,
'../modules/github-sync/frontend/js/components/github-integration-card.tsx' '../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( Path.resolve(
__dirname, __dirname,
'../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx' '../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx'
@@ -1204,6 +1224,7 @@ module.exports = {
'template-gallery', 'template-gallery',
'git-bridge', 'git-bridge',
'github-sync', 'github-sync',
'gitlab-sync',
'zotero', 'zotero',
], ],
viewIncludes: {}, 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 === 'upload') result = t('history_entry_origin_upload')
if (origin?.kind === 'git-bridge') result = t('history_entry_origin_git') if (origin?.kind === 'git-bridge') result = t('history_entry_origin_git')
if (origin?.kind === 'github') result = t('history_entry_origin_github') if (origin?.kind === 'github') result = t('history_entry_origin_github')
if (origin?.kind === 'gitlab') result = t('history_entry_origin_gitlab')
if (result) { if (result) {
return <span className="history-version-origin">({result})</span> return <span className="history-version-origin">({result})</span>
@@ -20,6 +20,7 @@ export interface Meta {
| 'upload' | 'upload'
| 'git-bridge' | 'git-bridge'
| 'github' | 'github'
| 'gitlab'
| 'history-resync' | 'history-resync'
| 'history-migration' | 'history-migration'
} }
@@ -71,7 +71,7 @@ export const RailLayout = () => {
const { features } = useProjectContext() const { features } = useProjectContext()
const { isRestrictedTokenMember } = useEditorContext() const { isRestrictedTokenMember } = useEditorContext()
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled') 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() const { view, setSettingsShown, focusMode } = useLayoutContext()
@@ -119,7 +119,7 @@ export const RailLayout = () => {
icon: 'integration_instructions', icon: 'integration_instructions',
title: t('integrations'), title: t('integrations'),
component: <IntegrationsPanel />, component: <IntegrationsPanel />,
hide: !isOverleaf && !gitBridgeEnabled && !githubSyncEnabled && !zoteroEnabled, hide: !isOverleaf && !gitBridgeEnabled && !githubSyncEnabled && !gitlabSyncEnabled && !zoteroEnabled,
}, },
{ {
key: 'review-panel', key: 'review-panel',
@@ -149,6 +149,7 @@ export const RailLayout = () => {
isOverleaf, isOverleaf,
gitBridgeEnabled, gitBridgeEnabled,
githubSyncEnabled, githubSyncEnabled,
gitlabSyncEnabled,
zoteroEnabled, zoteroEnabled,
] ]
) )
@@ -251,7 +251,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
} }
if ( if (
update.meta.type === 'external' && 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 return
} }
@@ -12,6 +12,7 @@ const integrationPanelComponents = allIntegrationPanelComponents.filter(
({ path }) => ({ path }) =>
(getMeta('ol-gitBridgeEnabled') || !path.includes('git-bridge')) && (getMeta('ol-gitBridgeEnabled') || !path.includes('git-bridge')) &&
(getMeta('ol-ExposedSettings').githubSyncEnabled || !path.includes('github-sync')) && (getMeta('ol-ExposedSettings').githubSyncEnabled || !path.includes('github-sync')) &&
(getMeta('ol-ExposedSettings').gitlabSyncEnabled || !path.includes('gitlab-sync')) &&
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero')) (getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero'))
) )
@@ -58,7 +58,7 @@ function NewProjectButton({
align = 'start', align = 'start',
}: NewProjectButtonProps) { }: NewProjectButtonProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { templateLinks, githubSyncEnabled } = getMeta('ol-ExposedSettings') const { templateLinks, githubSyncEnabled, gitlabSyncEnabled } = getMeta('ol-ExposedSettings')
const [modal, setModal] = const [modal, setModal] =
useState<Nullable<NewProjectButtonModalVariant>>(null) useState<Nullable<NewProjectButtonModalVariant>>(null)
const portalTemplates = getMeta('ol-portalTemplates') || [] const portalTemplates = getMeta('ol-portalTemplates') || []
@@ -167,11 +167,19 @@ function NewProjectButton({
const [importProjectFromGithubMenu] = !githubSyncEnabled ? [] : importOverleafModules( const [importProjectFromGithubMenu] = !githubSyncEnabled ? [] : importOverleafModules(
'importProjectFromGithubMenu' 'importProjectFromGithubMenu'
) )
const [importProjectFromGitLabMenu] = !gitlabSyncEnabled ? [] : importOverleafModules(
'importProjectFromGitLabMenu'
)
const ImportProjectFromGithubMenu: JSXElementConstructor<{ const ImportProjectFromGithubMenu: JSXElementConstructor<{
onClick: (e: React.MouseEvent) => void onClick: (e: React.MouseEvent) => void
}> = importProjectFromGithubMenu?.import.default }> = importProjectFromGithubMenu?.import.default
const ImportProjectFromGitLabMenu: JSXElementConstructor<{
onClick: (e: React.MouseEvent) => void
}> = importProjectFromGitLabMenu?.import.default
return ( return (
<> <>
<OLDropdown <OLDropdown
@@ -259,6 +267,18 @@ function NewProjectButton({
/> />
</li> </li>
)} )}
{ImportProjectFromGitLabMenu && (
<li role="none">
<ImportProjectFromGitLabMenu
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'import_from_gitlab',
dropdownMenuEvent: 'import-from-gitlab',
})
}
/>
</li>
)}
<OLDropdownDivider /> <OLDropdownDivider />
<OLDropdownHeader aria-hidden="true"> <OLDropdownHeader aria-hidden="true">
{t('templates')} {t('templates')}
@@ -15,6 +15,7 @@ export type NewProjectButtonModalVariant =
| 'example_project' | 'example_project'
| 'upload_project' | 'upload_project'
| 'import_from_github' | 'import_from_github'
| 'import_from_gitlab'
| 'import_docx' | 'import_docx'
| 'import_markdown' | 'import_markdown'
@@ -36,6 +37,14 @@ function NewProjectButtonModal({
onHide: () => void onHide: () => void
}> = importProjectFromGithubModalWrapper?.import.default }> = importProjectFromGithubModalWrapper?.import.default
const [importProjectFromGitLabModalWrapper] = importOverleafModules(
'importProjectFromGitLabModalWrapper'
)
const ImportProjectFromGitLabModalWrapper: JSXElementConstructor<{
onHide: () => void
}> = importProjectFromGitLabModalWrapper?.import.default
const location = useLocation() const location = useLocation()
const openProject = useCallback( const openProject = useCallback(
@@ -82,6 +91,8 @@ function NewProjectButtonModal({
) )
case 'import_from_github': case 'import_from_github':
return <ImportProjectFromGithubModalWrapper onHide={onHide} /> return <ImportProjectFromGithubModalWrapper onHide={onHide} />
case 'import_from_gitlab':
return <ImportProjectFromGitLabModalWrapper onHide={onHide} />
default: default:
return null return null
} }
@@ -67,7 +67,7 @@ function WelcomeMessageCreateNewProjectDropdown({
useFeatureFlag('import-markdown') && useFeatureFlag('import-markdown') &&
getMeta('ol-ExposedSettings').enablePandocConversions getMeta('ol-ExposedSettings').enablePandocConversions
const { isOverleaf, githubSyncEnabled } = getMeta('ol-ExposedSettings') const { isOverleaf, githubSyncEnabled, gitlabSyncEnabled } = getMeta('ol-ExposedSettings')
const handleDropdownItemClick = useCallback( const handleDropdownItemClick = useCallback(
( (
@@ -184,6 +184,23 @@ function WelcomeMessageCreateNewProjectDropdown({
</OLDropdownItem> </OLDropdownItem>
</li> </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 ? ( {(portalTemplates?.length ?? 0) > 0 ? (
<> <>
<OLDropdownDivider /> <OLDropdownDivider />
@@ -19,11 +19,12 @@ const availableLangFeedbackLinkingWidgets = importOverleafModules(
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled') const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
const githubSyncEnabled = getMeta('ol-ExposedSettings').githubSyncEnabled const githubSyncEnabled = getMeta('ol-ExposedSettings').githubSyncEnabled
const gitlabSyncEnabled = getMeta('ol-ExposedSettings').gitlabSyncEnabled
const zoteroEnabled = getMeta('ol-ExposedSettings').zoteroEnabled const zoteroEnabled = getMeta('ol-ExposedSettings').zoteroEnabled
const availableIntegrationLinkingWidgets = allAvailableIntegrationLinkingWidgets.filter( const availableIntegrationLinkingWidgets = allAvailableIntegrationLinkingWidgets.filter(
({ path }) => ({ path }) =>
(githubSyncEnabled || !path.includes('github-sync')) (githubSyncEnabled || !path.includes('github-sync')) && (gitlabSyncEnabled || !path.includes('gitlab-sync'))
) )
function LinkingSection() { function LinkingSection() {
@@ -55,7 +56,7 @@ function LinkingSection() {
}[] }[]
const renderSyncSection = const renderSyncSection =
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled || gitlabSyncEnabled
const allIntegrationLinkingWidgets = integrationLinkingWidgets.concat( const allIntegrationLinkingWidgets = integrationLinkingWidgets.concat(
oauth2ServerComponents 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/manage-users-page';
@import '../../../modules/admin-tools/frontend/styles/pagination-dark'; @import '../../../modules/admin-tools/frontend/styles/pagination-dark';
@import '../../../modules/github-sync/frontend/styles/github-sync'; @import '../../../modules/github-sync/frontend/styles/github-sync';
@import '../../../modules/gitlab-sync/frontend/styles/gitlab-sync';
@import '../../../modules/reference-picker/frontend/styles/reference-picker'; @import '../../../modules/reference-picker/frontend/styles/reference-picker';
+73
View File
@@ -259,6 +259,11 @@
"ask_proj_owner_to_upgrade_for_full_history": "Please ask the project owner to upgrade to access this project’s full history.", "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_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_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.",
"assistant_pointer_ask_ai": "Ask the AI assistant", "assistant_pointer_ask_ai": "Ask the AI assistant",
"assistant_pointer_hint_environment": "Ask the AI assistant to change this __name__", "assistant_pointer_hint_environment": "Ask the AI assistant to change this __name__",
"assistant_pointer_hint_package": "Ask the AI assistant to explain this package", "assistant_pointer_hint_package": "Ask the AI assistant to explain this package",
@@ -407,6 +412,7 @@
"checking": "Checking", "checking": "Checking",
"checking_dropbox_status": "Checking Dropbox status", "checking_dropbox_status": "Checking Dropbox status",
"checking_project_github_status": "Checking project status in GitHub", "checking_project_github_status": "Checking project status in GitHub",
"checking_project_gitlab_status": "Checking project status in GitLab",
"checking_your_student_status": "We’re checking your student status. We’ll email you if your document upload needs review.", "checking_your_student_status": "We’re checking your student status. We’ll email you if your document upload needs review.",
"choose_a_custom_color": "Choose a custom color", "choose_a_custom_color": "Choose a custom color",
"choose_a_new_password": "Choose a new password", "choose_a_new_password": "Choose a new password",
@@ -540,6 +546,7 @@
"contacting_the_sales_team": "Contacting the Sales team", "contacting_the_sales_team": "Contacting the Sales team",
"continue": "Continue", "continue": "Continue",
"continue_github_merge": "I have manually merged. Continue", "continue_github_merge": "I have manually merged. Continue",
"continue_gitlab_merge": "I have manually merged. Continue",
"continue_to": "Continue to __appName__", "continue_to": "Continue to __appName__",
"continue_to_checkout": "Continue to checkout", "continue_to_checkout": "Continue to checkout",
"continue_using_free_features": "Continue using our free features", "continue_using_free_features": "Continue using our free features",
@@ -575,6 +582,7 @@
"create_new_tag": "Create new tag", "create_new_tag": "Create new tag",
"create_password": "Create password", "create_password": "Create password",
"create_project_in_github": "Create a GitHub repository", "create_project_in_github": "Create a GitHub repository",
"create_project_in_gitlab": "Create a GitLab repository",
"create_your_account": "Create your account", "create_your_account": "Create your account",
"created": "Created", "created": "Created",
"created_at": "Created at", "created_at": "Created at",
@@ -924,6 +932,7 @@
"export_as_markdown": "Export as Markdown (.md)", "export_as_markdown": "Export as Markdown (.md)",
"export_csv": "Export CSV", "export_csv": "Export CSV",
"export_project_to_github": "Export Project to GitHub", "export_project_to_github": "Export Project to GitHub",
"export_project_to_gitlab": "Export Project to GitLab",
"failed": "Failed", "failed": "Failed",
"failed_to_consent_to_workbench_terms": "Failed to consent to workbench terms. Please try again later.", "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.", "failed_to_publish_as_a_template": "Failed to publish as a template.",
@@ -1033,6 +1042,7 @@
"from_external_url": "From external URL", "from_external_url": "From external URL",
"from_filename": "From <0>__filename__</0>", "from_filename": "From <0>__filename__</0>",
"from_github": "From GitHub", "from_github": "From GitHub",
"from_gitlab": "From GitLab",
"from_project_files": "From project files", "from_project_files": "From project files",
"from_provider": "From __provider__", "from_provider": "From __provider__",
"from_url": "From URL", "from_url": "From URL",
@@ -1134,6 +1144,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_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": "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.", "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-ai-consent": "Give AI consent",
"give_feedback": "Give feedback", "give_feedback": "Give feedback",
"global": "global", "global": "global",
@@ -1215,6 +1260,7 @@
"history_entry_origin_dropbox": "via Dropbox", "history_entry_origin_dropbox": "via Dropbox",
"history_entry_origin_git": "via Git", "history_entry_origin_git": "via Git",
"history_entry_origin_github": "via GitHub", "history_entry_origin_github": "via GitHub",
"history_entry_origin_gitlab": "via GitLab",
"history_entry_origin_upload": "upload", "history_entry_origin_upload": "upload",
"history_label_created_by": "Created by", "history_label_created_by": "Created by",
"history_label_project_current_state": "Current state", "history_label_project_current_state": "Current state",
@@ -1352,7 +1398,9 @@
"import_a_bibtex_file_from_your_provider_account": "Import a BibTeX file from your __provider__ account", "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_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_github": "Import existing projects from GitHub",
"import_existing_projects_from_gitlab": "Import existing projects from GitLab",
"import_from_github": "Import from GitHub", "import_from_github": "Import from GitHub",
"import_from_gitlab": "Import from GitLab",
"import_idp_metadata": "Import IdP metadata", "import_idp_metadata": "Import IdP metadata",
"import_markdown_file": "Import Markdown file", "import_markdown_file": "Import Markdown file",
"import_to_sharelatex": "Import to __appName__", "import_to_sharelatex": "Import to __appName__",
@@ -1367,6 +1415,7 @@
"imported_from_zotero_at_date_by": "Imported from Zotero at __formattedDate__ __relativeDate__ by __importedByName__", "imported_from_zotero_at_date_by": "Imported from Zotero at __formattedDate__ __relativeDate__ by __importedByName__",
"importing": "Importing", "importing": "Importing",
"importing_and_merging_changes_in_github": "Importing and merging changes in GitHub", "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_editor_chat": "In-editor chat", "in_editor_chat": "In-editor chat",
"in_editor_chat_managed_by_overleaf_support": "In-editor chat is managed by Overleaf support.", "in_editor_chat_managed_by_overleaf_support": "In-editor chat is managed by Overleaf support.",
"in_editor_chat_section_description": "Lets collaborators leave and reply to comments directly within a project.", "in_editor_chat_section_description": "Lets collaborators leave and reply to comments directly within a project.",
@@ -1428,6 +1477,7 @@
"integrations": "Integrations", "integrations": "Integrations",
"integrations_include": "Integrations include:", "integrations_include": "Integrations include:",
"integrations_like_github": "Integrations like GitHub Sync", "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?", "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_certificate": "Invalid certificate. Please check the certificate and try again.",
"invalid_confirmation_code": "That didn’t work. Please check the code and try again.", "invalid_confirmation_code": "That didn’t work. Please check the code and try again.",
@@ -1617,6 +1667,8 @@
"link_sharing_is_on": "Link sharing is on", "link_sharing_is_on": "Link sharing is on",
"link_to_github": "Link to your GitHub account", "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_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_mendeley": "Link to Mendeley",
"link_to_readcube": "Link to ReadCube", "link_to_readcube": "Link to ReadCube",
"link_to_zotero": "Link to Zotero", "link_to_zotero": "Link to Zotero",
@@ -1630,9 +1682,11 @@
"loading": "Loading", "loading": "Loading",
"loading_content": "Creating Project", "loading_content": "Creating Project",
"loading_github_repositories": "Loading your GitHub repositories", "loading_github_repositories": "Loading your GitHub repositories",
"loading_gitlab_repositories": "Loading your GitLab repositories",
"loading_prices": "loading prices", "loading_prices": "loading prices",
"loading_python_runtime": "Loading Python runtime...", "loading_python_runtime": "Loading Python runtime...",
"loading_recent_github_commits": "Loading recent commits", "loading_recent_github_commits": "Loading recent commits",
"loading_recent_gitlab_commits": "Loading recent commits",
"loading_references": "Loading references", "loading_references": "Loading references",
"local_account": "Local account", "local_account": "Local account",
"log_entry_description": "Log entry with level: __level__", "log_entry_description": "Log entry with level: __level__",
@@ -1858,6 +1912,7 @@
"no_members": "No members", "no_members": "No members",
"no_messages_yet": "No messages yet", "no_messages_yet": "No messages yet",
"no_new_commits_in_github": "No new commits in GitHub since last merge.", "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_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_invited_yet": "No one invited yet",
"no_one_sharing_link_is_disabled": "No one—sharing link is disabled", "no_one_sharing_link_is_disabled": "No one—sharing link is disabled",
@@ -1943,6 +1998,7 @@
"only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.", "only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.",
"only_invited_people": "Only invited people", "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_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_action_menu": "Open __name__ action menu",
"open_advanced_reference_search": "Open advanced reference search", "open_advanced_reference_search": "Open advanced reference search",
"open_as_template": "Open as Template", "open_as_template": "Open as Template",
@@ -2193,6 +2249,7 @@
"project_linked_to": "This project is linked to", "project_linked_to": "This project is linked to",
"project_name": "Project name", "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_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": "Project notifications",
"project_notifications_muted_description": "You are not receiving any notifications, as you have disabled all 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>?", "project_ownership_transfer_confirmation_1": "Are you sure you want to make <0>__user__</0> the owner of <1>__project__</1>?",
@@ -2234,9 +2291,12 @@
"publisher_account": "Publisher Account", "publisher_account": "Publisher Account",
"publishing": "Publishing", "publishing": "Publishing",
"pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__", "pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__",
"pull_gitlab_changes_into_sharelatex": "Pull GitLab changes into __appName__",
"purge": "Purge", "purge": "Purge",
"push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub", "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_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", "quoted_text": "Quoted text",
"raw_logs": "Raw logs", "raw_logs": "Raw logs",
"raw_logs_description": "Raw logs from the LaTeX compiler", "raw_logs_description": "Raw logs from the LaTeX compiler",
@@ -2273,9 +2333,11 @@
"real_time_track_changes": "Real-time track-changes", "real_time_track_changes": "Real-time track-changes",
"realtime_track_changes": "Real-time track changes", "realtime_track_changes": "Real-time track changes",
"reauthorize_github_account": "Reauthorize your GitHub Account", "reauthorize_github_account": "Reauthorize your GitHub Account",
"reauthorize_gitlab_account": "Reauthorize your GitLab Account",
"recaptcha_conditions": "The site is protected by reCAPTCHA.", "recaptcha_conditions": "The site is protected by reCAPTCHA.",
"recent": "Recent", "recent": "Recent",
"recent_commits_in_github": "Recent commits in GitHub", "recent_commits_in_github": "Recent commits in GitHub",
"recent_commits_in_gitlab": "Recent commits in GitLab",
"recommended": "Recommended", "recommended": "Recommended",
"recompile": "Recompile", "recompile": "Recompile",
"recompile_from_scratch": "Recompile from scratch", "recompile_from_scratch": "Recompile from scratch",
@@ -2557,6 +2619,7 @@
"select_from_source_files": "select from source files", "select_from_source_files": "select from source files",
"select_from_your_computer": "select from your computer", "select_from_your_computer": "select from your computer",
"select_github_repository": "Select a GitHub repository to import into __appName__.", "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_image_from_project_files": "Select image from project files",
"select_project": "Select __project__", "select_project": "Select __project__",
"select_projects": "Select Projects", "select_projects": "Select Projects",
@@ -2848,12 +2911,16 @@
"sync_failed": "Sync failed. Please try again.", "sync_failed": "Sync failed. Please try again.",
"sync_now": "Sync now", "sync_now": "Sync now",
"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_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_settings": "Sync settings", "sync_settings": "Sync settings",
"sync_to_dropbox": "Sync to Dropbox", "sync_to_dropbox": "Sync to Dropbox",
"sync_to_github": "Sync to GitHub", "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_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_dropbox": "Sync with Dropbox",
"sync_with_github": "Sync with GitHub", "sync_with_github": "Sync with GitHub",
"sync_with_gitlab": "Sync with GitLab",
"sync_with_zotero": "Sync with Zotero", "sync_with_zotero": "Sync with Zotero",
"syncing": "Syncing", "syncing": "Syncing",
"synctex_error_recompile_and_try_again": "That didn’t work. Recompile and try again.", "synctex_error_recompile_and_try_again": "That didn’t work. Recompile and try again.",
@@ -3007,6 +3074,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_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": "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_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_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_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", "to_modify_your_subscription_go_to": "To modify your subscription go to",
@@ -3163,6 +3231,8 @@
"unlink_from_sso": "Unlink from SSO", "unlink_from_sso": "Unlink from SSO",
"unlink_github_repository": "Unlink GitHub repository", "unlink_github_repository": "Unlink GitHub repository",
"unlink_github_warning": "Syncing will pause until you relink your GitHub account. Your projects’ GitHub connections won’t be lost, and syncing will resume automatically once you relink. Are you sure you want to unlink your GitHub account?", "unlink_github_warning": "Syncing will pause until you relink your GitHub account. Your projects’ GitHub connections won’t be lost, and syncing will resume automatically once you relink. Are you sure you want to unlink your GitHub account?",
"unlink_gitlab_repository": "Unlink GitLab repository",
"unlink_gitlab_warning": "Syncing will pause until you relink your GitLab account. Your projects’ GitLab connections won’t be lost, and syncing will resume automatically once you relink. 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_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_linked_google_account": "Unlink your Google account. <0>Remove it in Account settings (under Linked Accounts).</0>",
"unlink_provider_account_title": "Unlink __provider__ Account", "unlink_provider_account_title": "Unlink __provider__ Account",
@@ -3170,6 +3240,7 @@
"unlink_reference": "Unlink __provider__ References Provider", "unlink_reference": "Unlink __provider__ References Provider",
"unlink_sso": "Unlink SSO", "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_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_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_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.", "unlink_warning_reference": "Warning: When you unlink your account from __provider__ you will not be able to import references into your projects.",
@@ -3380,6 +3451,7 @@
"work_or_university_sso": "Work/university single sign-on", "work_or_university_sso": "Work/university single sign-on",
"work_with_non_overleaf_users": "Work with non-Overleaf users", "work_with_non_overleaf_users": "Work with non-Overleaf users",
"work_with_other_github_users": "Work with other GitHub users", "work_with_other_github_users": "Work with other GitHub users",
"work_with_other_gitlab_users": "Work with other GitLab users",
"writefull": "Writefull", "writefull": "Writefull",
"x_changes_in": "__count__ change in", "x_changes_in": "__count__ change in",
"x_changes_in_plural": "__count__ changes in", "x_changes_in_plural": "__count__ changes in",
@@ -3453,6 +3525,7 @@
"youll_be_moved_to_the_free_plan": "You’ll be moved to the Free plan", "youll_be_moved_to_the_free_plan": "You’ll be moved to the Free plan",
"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_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_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>.", "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_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.", "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,665 @@
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 = Settings.gitlabSync?.url
const GITLAB_API_BASE = `${GITLAB_URL}/api/v4`
const GITLAB_DEFAULT_BRANCH = Settings.gitlabSync?.defaultBranch
const MAX_PER_PAGE = 100
const REQUEST_TIMEOUT_MS = 60 * 1000
const REQUEST_LONG_TIMEOUT_MS = 600 * 1000
const MERGE_REQUEST_POLL_INTERVAL_MS = Settings.gitlabSync?.mergeRequestPollInterval
const MERGE_REQUEST_TIMEOUT_MS = Settings.gitlabSync?.mergeRequestTimeout
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;
}
async function compareCommitsFull(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)
return await fetchGitLabJson(url.toString(), {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'compareCommitsFull')
}
// ---------------------- 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) {
if (!sha) return null
const url = `${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/blobs/${sha}`
return await fetchGitLabJson(url, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
}, 'getBlobContent').then(r => r.content || '')
}
async function listNewCommitsWithStatus(token, fullName, branchName, fromCommit) {
const forward = await compareCommitsFull(token, fullName, fromCommit, branchName);
const reverse = await compareCommitsFull(token, fullName, branchName, fromCommit);
const forwardHasCommits = forward.commits.length > 0;
const reverseHasCommits = reverse.commits.length > 0;
let diverged = false
if (forward.compare_same_ref) {
// Identical
}
else if (forwardHasCommits && !reverseHasCommits) {
// Ahead
}
else if (!forwardHasCommits && reverseHasCommits) {
// Behind (here also considered a diverged state)
diverged = true
}
else if (forwardHasCommits && reverseHasCommits) {
// Diverged
diverged = true
}
const commits = (forward.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 }
}
// 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,249 @@
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 token = await TokenManager.getUserToken(userId)
if (!token) return false;
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 token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
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 token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
return await api.listUserRepos(token)
}
async function getUserAndOrgs(userId) {
const token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
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 token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
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 token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
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 token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
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,753 @@
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 token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
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
}
// Possible changes in OL
if (defaultBranchHead === projectSyncState.lastSyncCommit) {
try {
const olBranchHead =
await exportChangesToGit({
token,
projectId,
repoFullName,
lastSyncVersion: projectSyncState.lastSyncVersion,
currentVersion,
message,
baseCommit: projectSyncState.lastSyncCommit,
defaultBranchName,
})
if (!olBranchHead) { // no real canges in OL, but currentVersion is ahead
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion: currentVersion })
return null
}
const lastSyncCommit = olBranchHead
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
return null
} catch (err) {
logger.error({ err, projectId }, 'Error during clean sync state resolution, fallback to merge')
}
}
// Git head is ahead of last known sync commit, create a temporary branch with OL changes and perform a merge with the default branch
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,
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, decData) {
logger.info({ userId }, "refreshing GitLab token")
if (!decData.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(decData.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 decData = await decryptAccessToken(credentials.gitlab)
const now = Math.floor(Date.now() / 1000);
if (!decData.refresh_timestamp || decData.refresh_timestamp < now) {
const refreshed = await refreshToken(userId, decData)
if (refreshed) {
return await getUserToken(userId)
}
}
return decData.token
}
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 Notification from '@/shared/components/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 && (
<Notification
type="error"
content={t('gitlab_sync_error')}
/>
)}
{isErrorUnlink && (
<Notification
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 { OLDropdownItem } from '@/shared/components/ol/ol-dropdown-menu'
export default function ImportFromGitLabMenu({ onClick }: { onClick: (e: React.MouseEvent) => void }) {
const { t } = useTranslation()
return (
<OLDropdownItem onClick={onClick}>
{t('import_from_gitlab')}
</OLDropdownItem>
)
}
@@ -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 Notification from '@/shared/components/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">
<Notification
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">
<Notification
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>
)
}
@@ -0,0 +1,47 @@
import { useTranslation, Trans } from 'react-i18next'
import Notification from '@/shared/components/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 GitSyncCannotExportModalProps = {
projectSyncState: ProjectSyncState
handleHide: () => void
}
const GitSyncCannotExportModal = ({ projectSyncState, handleHide }: GitSyncCannotExportModalProps) => {
const { t } = useTranslation()
return (
<>
<OLModalBody>
<Notification
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
@@ -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 Notification from '@/shared/components/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,
}: GitSyncUnlinkModalProps) => {
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')}:&nbsp;
<a
href={`${gitlabUrl}/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
>
{projectSyncState.repoFullName}
</a>
</p>
<hr />
{!error ? (
<Notification
type="warning"
content={t('unlink_the_project_from_the_current_gitlab_repo')}
/>
) : (
<Notification
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
@@ -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 Notification from '@/shared/components/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>
<Notification
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
@@ -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 Notification from '@/shared/components/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 && (
<Notification
type="error"
content={error}
/>
)}
{errorUserAndOrgs && (
<Notification
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
@@ -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 Notification from '@/shared/components/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 && (
<Notification
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
@@ -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 Notification from '@/shared/components/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 && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncMergeModal
@@ -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 Notification from '@/shared/components/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')}:&nbsp;
<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 && (
<Notification
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} &lt;{commit.author.email}&gt;
</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 && (
<Notification
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
@@ -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
@@ -0,0 +1,60 @@
import { useTranslation, Trans } from 'react-i18next'
import Notification from '@/shared/components/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 }: GitSyncNeedPermissionModalProps) => {
const { t } = useTranslation()
let gitlabUrl = getMeta('ol-ExposedSettings').gitlabUrl || ''
gitlabUrl = gitlabUrl.endsWith('/') ? gitlabUrl.slice(0, -1) : gitlabUrl
return (
<>
<OLModalBody>
<Notification
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
@@ -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 Notification from '@/shared/components/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,
}: GitSyncUnlinkUnavailableModalProps) => {
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>
<Notification
type="error"
content={t('gitlab_sync_repository_not_found_description')}
/>
{error && (
<Notification
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,59 @@
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 || !process.env.GITLAB_SYNC_CLIENT_ID || !process.env.GITLAB_SYNC_CLIENT_SECRET) {
logger.warn({}, 'GitLab Sync module is enabled but not all mandatory envirionment variables are set, stopping module initialization')
} else {
const siteUrl = Settings.siteUrl.replace(/\/+$/, '') || 'http://localhost'
Settings.gitlabSync = {
url: process.env.GITLAB_SYNC_URL.replace(/\/+$/, ''),
clientID: process.env.GITLAB_SYNC_CLIENT_ID,
clientSecret: process.env.GITLAB_SYNC_CLIENT_SECRET,
callbackURL: `${siteUrl}/user/gitlab-sync/oauth2/callback`,
defaultBranch: process.env.GITLAB_DEFAULT_BRANCH || 'main',
mergeRequestPollInterval: process.env.GITLAB_MERGE_REQUEST_POLL_INTERVAL_MS || 1000,
mergeRequestTimeout: process.env.GITLAB_MERGE_REQUEST_TIMEOUT_MS || 60_000,
}
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'),
])
// 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
-1
View File
@@ -78,7 +78,6 @@
"last 1 year", "last 1 year",
"safari >= 15" "safari >= 15"
], ],
"dependencies": { "dependencies": {
"@ai-sdk/google-vertex": "^4.0.113", "@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", "@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('analytics')).to.be.false
expect(ctx.Features.hasFeature('git-bridge')).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('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('homepage')).to.be.false
expect(ctx.Features.hasFeature('link-url')).to.be.false expect(ctx.Features.hasFeature('link-url')).to.be.false
expect(ctx.Features.hasFeature('oauth')).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('analytics')).to.be.false
expect(ctx.Features.hasFeature('git-bridge')).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('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('homepage')).to.be.false
expect(ctx.Features.hasFeature('link-url')).to.be.false expect(ctx.Features.hasFeature('link-url')).to.be.false
expect(ctx.Features.hasFeature('oauth')).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('link-url')).to.be.false
expect(ctx.Features.hasFeature('git-bridge')).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('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('homepage')).to.be.false
expect(ctx.Features.hasFeature('oauth')).to.be.false expect(ctx.Features.hasFeature('oauth')).to.be.false
expect(ctx.Features.hasFeature('saml')).to.be.false expect(ctx.Features.hasFeature('saml')).to.be.false
@@ -118,6 +121,7 @@ describe('Features', function () {
ctx.settings.enableHomepage = true ctx.settings.enableHomepage = true
ctx.settings.enableGitBridge = true ctx.settings.enableGitBridge = true
ctx.settings.enableGithubSync = true ctx.settings.enableGithubSync = true
ctx.settings.enableGitLabSync = true
ctx.settings.enableSaml = true ctx.settings.enableSaml = true
ctx.settings.oauth = true ctx.settings.oauth = true
ctx.settings.enabledLinkedFileTypes = ['url', 'project_file'] 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('affiliations')).to.be.true
expect(ctx.Features.hasFeature('analytics')).to.be.true expect(ctx.Features.hasFeature('analytics')).to.be.true
expect(ctx.Features.hasFeature('github-sync')).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('git-bridge')).to.be.true
expect(ctx.Features.hasFeature('homepage')).to.be.true expect(ctx.Features.hasFeature('homepage')).to.be.true
expect(ctx.Features.hasFeature('link-url')).to.be.true expect(ctx.Features.hasFeature('link-url')).to.be.true
+2
View File
@@ -55,6 +55,8 @@ export type ExposedSettings = {
templatesEnabled?: boolean templatesEnabled?: boolean
linkedInInsightsPartnerId?: string linkedInInsightsPartnerId?: string
githubSyncEnabled: boolean githubSyncEnabled: boolean
gitlabSyncEnabled: boolean
zoteroEnabled: boolean zoteroEnabled: boolean
enablePandocConversions: boolean enablePandocConversions: boolean
gitlabUrl: string
} }