mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
565b793055 | ||
|
|
fcb3d06f24 | ||
|
|
d037520105 | ||
|
|
cb59625058 | ||
|
|
9c041338b3 | ||
|
|
3bfeb32bef | ||
|
|
d458e51918 |
@@ -424,6 +424,8 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
cioSiteId: Settings.analytics?.cio?.siteId,
|
||||
linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId,
|
||||
githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret,
|
||||
gitlabSyncEnabled: !!Settings.gitlabSync,
|
||||
gitlabUrl: Settings.gitlabSync?.url,
|
||||
zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret,
|
||||
enablePandocConversions: Settings.enablePandocConversions,
|
||||
mixpanelLabsToken:
|
||||
|
||||
@@ -16,6 +16,7 @@ const trackChangesModuleAvailable =
|
||||
* @property {Object | undefined} apis.linkedUrlProxy
|
||||
* @property {string | undefined} apis.linkedUrlProxy.url
|
||||
* @property {boolean | undefined} enableGithubSync
|
||||
* @property {boolean | undefined} enableGitLabSync
|
||||
* @property {boolean | undefined} enableGitBridge
|
||||
* @property {boolean | undefined} enableHomepage
|
||||
* @property {boolean | undefined} enableSaml
|
||||
@@ -61,6 +62,8 @@ const Features = {
|
||||
return Boolean(Settings.disableLinkSharing) === false
|
||||
case 'github-sync':
|
||||
return Boolean(Settings.enableGithubSync)
|
||||
case 'gitlab-sync':
|
||||
return Boolean(Settings.enableGitLabSync)
|
||||
case 'git-bridge':
|
||||
return Boolean(Settings.enableGitBridge)
|
||||
case 'oauth':
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const thirdPartyIcons = [
|
||||
{ file: 'git.svg', label: 'Git' },
|
||||
{ file: 'github.svg', label: 'GitHub' },
|
||||
{ file: 'gitlab.svg', label: 'GitLab' },
|
||||
{ file: 'dropbox-transparent.svg', label: 'Dropbox' },
|
||||
{ file: 'zotero_64x64x32.png', label: 'Zotero' },
|
||||
{ file: 'mendeley.svg', label: 'Mendeley' },
|
||||
|
||||
@@ -1076,6 +1076,10 @@ module.exports = {
|
||||
__dirname,
|
||||
'../modules/github-sync/frontend/js/components/github-sync-widget.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/gitlab-sync/frontend/js/components/gitlab-sync-widget.tsx'
|
||||
),
|
||||
],
|
||||
referenceLinkingWidgets: [
|
||||
Path.resolve(
|
||||
@@ -1095,6 +1099,18 @@ module.exports = {
|
||||
'../modules/github-sync/frontend/js/components/import-from-github-menu.tsx'
|
||||
),
|
||||
],
|
||||
importProjectFromGitLabModalWrapper: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/gitlab-sync/frontend/js/components/import-from-gitlab-modal-wrapper.tsx'
|
||||
),
|
||||
],
|
||||
importProjectFromGitLabMenu: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/gitlab-sync/frontend/js/components/import-from-gitlab-menu.tsx'
|
||||
),
|
||||
],
|
||||
editorLeftMenuSync: [],
|
||||
editorLeftMenuManageTemplate: [],
|
||||
menubarExtraComponents: [
|
||||
@@ -1162,6 +1178,10 @@ module.exports = {
|
||||
__dirname,
|
||||
'../modules/github-sync/frontend/js/components/github-integration-card.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/gitlab-sync/frontend/js/components/gitlab-integration-card.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx'
|
||||
@@ -1204,6 +1224,7 @@ module.exports = {
|
||||
'template-gallery',
|
||||
'git-bridge',
|
||||
'github-sync',
|
||||
'gitlab-sync',
|
||||
'zotero',
|
||||
],
|
||||
viewIncludes: {},
|
||||
|
||||
@@ -9,6 +9,7 @@ function Origin({ origin }: Pick<LoadedUpdate['meta'], 'origin'>) {
|
||||
if (origin?.kind === 'upload') result = t('history_entry_origin_upload')
|
||||
if (origin?.kind === 'git-bridge') result = t('history_entry_origin_git')
|
||||
if (origin?.kind === 'github') result = t('history_entry_origin_github')
|
||||
if (origin?.kind === 'gitlab') result = t('history_entry_origin_gitlab')
|
||||
|
||||
if (result) {
|
||||
return <span className="history-version-origin">({result})</span>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Meta {
|
||||
| 'upload'
|
||||
| 'git-bridge'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'history-resync'
|
||||
| 'history-migration'
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const RailLayout = () => {
|
||||
const { features } = useProjectContext()
|
||||
const { isRestrictedTokenMember } = useEditorContext()
|
||||
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
||||
const { isOverleaf, githubSyncEnabled, zoteroEnabled } = getMeta('ol-ExposedSettings')
|
||||
const { isOverleaf, githubSyncEnabled, gitlabSyncEnabled, zoteroEnabled } = getMeta('ol-ExposedSettings')
|
||||
|
||||
const { view, setSettingsShown, focusMode } = useLayoutContext()
|
||||
|
||||
@@ -119,7 +119,7 @@ export const RailLayout = () => {
|
||||
icon: 'integration_instructions',
|
||||
title: t('integrations'),
|
||||
component: <IntegrationsPanel />,
|
||||
hide: !isOverleaf && !gitBridgeEnabled && !githubSyncEnabled && !zoteroEnabled,
|
||||
hide: !isOverleaf && !gitBridgeEnabled && !githubSyncEnabled && !gitlabSyncEnabled && !zoteroEnabled,
|
||||
},
|
||||
{
|
||||
key: 'review-panel',
|
||||
@@ -149,6 +149,7 @@ export const RailLayout = () => {
|
||||
isOverleaf,
|
||||
gitBridgeEnabled,
|
||||
githubSyncEnabled,
|
||||
gitlabSyncEnabled,
|
||||
zoteroEnabled,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -251,7 +251,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
|
||||
}
|
||||
if (
|
||||
update.meta.type === 'external' &&
|
||||
(update.meta.source === 'git-bridge' || update.meta.source === 'github')
|
||||
(update.meta.source === 'git-bridge' || update.meta.source === 'github' || update.meta.source === 'gitlab')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const integrationPanelComponents = allIntegrationPanelComponents.filter(
|
||||
({ path }) =>
|
||||
(getMeta('ol-gitBridgeEnabled') || !path.includes('git-bridge')) &&
|
||||
(getMeta('ol-ExposedSettings').githubSyncEnabled || !path.includes('github-sync')) &&
|
||||
(getMeta('ol-ExposedSettings').gitlabSyncEnabled || !path.includes('gitlab-sync')) &&
|
||||
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero'))
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ function NewProjectButton({
|
||||
align = 'start',
|
||||
}: NewProjectButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { templateLinks, githubSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
const { templateLinks, githubSyncEnabled, gitlabSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
const [modal, setModal] =
|
||||
useState<Nullable<NewProjectButtonModalVariant>>(null)
|
||||
const portalTemplates = getMeta('ol-portalTemplates') || []
|
||||
@@ -167,11 +167,19 @@ function NewProjectButton({
|
||||
const [importProjectFromGithubMenu] = !githubSyncEnabled ? [] : importOverleafModules(
|
||||
'importProjectFromGithubMenu'
|
||||
)
|
||||
|
||||
const [importProjectFromGitLabMenu] = !gitlabSyncEnabled ? [] : importOverleafModules(
|
||||
'importProjectFromGitLabMenu'
|
||||
)
|
||||
|
||||
const ImportProjectFromGithubMenu: JSXElementConstructor<{
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}> = importProjectFromGithubMenu?.import.default
|
||||
|
||||
const ImportProjectFromGitLabMenu: JSXElementConstructor<{
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}> = importProjectFromGitLabMenu?.import.default
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLDropdown
|
||||
@@ -259,6 +267,18 @@ function NewProjectButton({
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{ImportProjectFromGitLabMenu && (
|
||||
<li role="none">
|
||||
<ImportProjectFromGitLabMenu
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
modalVariant: 'import_from_gitlab',
|
||||
dropdownMenuEvent: 'import-from-gitlab',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
<OLDropdownDivider />
|
||||
<OLDropdownHeader aria-hidden="true">
|
||||
{t('templates')}
|
||||
|
||||
+11
@@ -15,6 +15,7 @@ export type NewProjectButtonModalVariant =
|
||||
| 'example_project'
|
||||
| 'upload_project'
|
||||
| 'import_from_github'
|
||||
| 'import_from_gitlab'
|
||||
| 'import_docx'
|
||||
| 'import_markdown'
|
||||
|
||||
@@ -36,6 +37,14 @@ function NewProjectButtonModal({
|
||||
onHide: () => void
|
||||
}> = importProjectFromGithubModalWrapper?.import.default
|
||||
|
||||
const [importProjectFromGitLabModalWrapper] = importOverleafModules(
|
||||
'importProjectFromGitLabModalWrapper'
|
||||
)
|
||||
const ImportProjectFromGitLabModalWrapper: JSXElementConstructor<{
|
||||
onHide: () => void
|
||||
}> = importProjectFromGitLabModalWrapper?.import.default
|
||||
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
const openProject = useCallback(
|
||||
@@ -82,6 +91,8 @@ function NewProjectButtonModal({
|
||||
)
|
||||
case 'import_from_github':
|
||||
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
|
||||
case 'import_from_gitlab':
|
||||
return <ImportProjectFromGitLabModalWrapper onHide={onHide} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
+18
-1
@@ -67,7 +67,7 @@ function WelcomeMessageCreateNewProjectDropdown({
|
||||
useFeatureFlag('import-markdown') &&
|
||||
getMeta('ol-ExposedSettings').enablePandocConversions
|
||||
|
||||
const { isOverleaf, githubSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
const { isOverleaf, githubSyncEnabled, gitlabSyncEnabled } = getMeta('ol-ExposedSettings')
|
||||
|
||||
const handleDropdownItemClick = useCallback(
|
||||
(
|
||||
@@ -184,6 +184,23 @@ function WelcomeMessageCreateNewProjectDropdown({
|
||||
</OLDropdownItem>
|
||||
</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 ? (
|
||||
<>
|
||||
<OLDropdownDivider />
|
||||
|
||||
@@ -19,11 +19,12 @@ const availableLangFeedbackLinkingWidgets = importOverleafModules(
|
||||
|
||||
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
||||
const githubSyncEnabled = getMeta('ol-ExposedSettings').githubSyncEnabled
|
||||
const gitlabSyncEnabled = getMeta('ol-ExposedSettings').gitlabSyncEnabled
|
||||
const zoteroEnabled = getMeta('ol-ExposedSettings').zoteroEnabled
|
||||
|
||||
const availableIntegrationLinkingWidgets = allAvailableIntegrationLinkingWidgets.filter(
|
||||
({ path }) =>
|
||||
(githubSyncEnabled || !path.includes('github-sync'))
|
||||
(githubSyncEnabled || !path.includes('github-sync')) && (gitlabSyncEnabled || !path.includes('gitlab-sync'))
|
||||
)
|
||||
|
||||
function LinkingSection() {
|
||||
@@ -55,7 +56,7 @@ function LinkingSection() {
|
||||
}[]
|
||||
|
||||
const renderSyncSection =
|
||||
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled
|
||||
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled || gitlabSyncEnabled
|
||||
|
||||
const allIntegrationLinkingWidgets = integrationLinkingWidgets.concat(
|
||||
oauth2ServerComponents
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
function GitLabLogoBlack({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 380 380"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M282.83,170.73l-.27-.69-26.14-68.22a6.81,6.81,0,0,0-2.69-3.24,7,7,0,0,0-8,.43,7,7,0,0,0-2.32,3.52l-17.65,54H154.29l-17.65-54A6.86,6.86,0,0,0,134.32,99a7,7,0,0,0-8-.43,6.87,6.87,0,0,0-2.69,3.24L97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82,19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91,40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitLabLogoBlack
|
||||
@@ -0,0 +1,32 @@
|
||||
function GitLabLogo({ size = 40 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 200 200"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g transform="translate(-90 -90)">
|
||||
<path
|
||||
d="m265.26416 174.37243-.2134-.55822-21.19899-55.30908c-.4236-1.08359-1.18542-1.99642-2.17699-2.62689-.98837-.63373-2.14749-.93253-3.32305-.87014-1.1689.0624-2.29195.48925-3.20809 1.21821-.90957.73554-1.56629 1.73047-1.87493 2.85346l-14.31327 43.80662h-57.90965l-14.31327-43.80662c-.30864-1.12299-.96536-2.11791-1.87493-2.85346-.91614-.72895-2.03911-1.15582-3.20809-1.21821-1.17548-.0624-2.33468.23641-3.32297.87014-.99166.63047-1.75348 1.5433-2.17707 2.62689l-21.19891 55.31237-.21348.55493c-6.28158 16.38521-.92929 34.90803 13.05891 45.48782.0262.0164.0492.0361.0755.0558l.18719.14119 32.29094 24.17392 15.97151 12.09024 9.71951 7.34871c2.34117 1.77316 5.57877 1.77316 7.92002 0l9.71943-7.34871 15.96822-12.09024 32.48142-24.31511c.0296-.023.0559-.0427.0854-.0657 13.97834-10.57977 19.32735-29.09604 13.04905-45.47796z"
|
||||
fill="#E24329"
|
||||
/>
|
||||
<path
|
||||
d="m265.26416 174.37243-.2134-.55822c-10.5174 2.16062-20.20405 6.6099-28.49844 12.81593-.1346.0985-25.20497 19.05805-46.55171 35.19699 15.84998 11.98517 29.6477 22.40405 29.6477 22.40405l32.48142-24.31511c.0296-.023.0559-.0427.0854-.0657 13.97834-10.57977 19.32735-29.09604 13.04905-45.47796z"
|
||||
fill="#FC6D26"
|
||||
/>
|
||||
<path
|
||||
d="m160.34962 244.23117 15.97151 12.09024 9.71951 7.34871c2.34117 1.77316 5.57877 1.77316 7.92002 0l9.71943-7.34871 15.96822-12.09024s-13.79772-10.41888-29.6477-22.40405c-15.85327 11.98517-29.65099 22.40405-29.65099 22.40405z"
|
||||
fill="#FCA326"
|
||||
/>
|
||||
<path
|
||||
d="m143.44561 186.63014c-8.29111-6.20274-17.97446-10.65531-28.49507-12.81264l-.21348.55493c-6.28158 16.38521-.92929 34.90803 13.05891 45.48782.0262.0164.0492.0361.0755.0558l.18719.14119 32.29094 24.17392s13.79772-10.41888 29.65099-22.40405c-21.34673-16.13894-46.42031-35.09848-46.55499-35.19699z"
|
||||
fill="#FC6D26"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitLabLogo
|
||||
@@ -16,4 +16,5 @@
|
||||
@import '../../../modules/admin-tools/frontend/styles/manage-users-page';
|
||||
@import '../../../modules/admin-tools/frontend/styles/pagination-dark';
|
||||
@import '../../../modules/github-sync/frontend/styles/github-sync';
|
||||
@import '../../../modules/gitlab-sync/frontend/styles/gitlab-sync';
|
||||
@import '../../../modules/reference-picker/frontend/styles/reference-picker';
|
||||
|
||||
@@ -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_repo_owner_to_reconnect": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
|
||||
"ask_repo_owner_to_renew_overleaf_subscription": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
|
||||
"ask_proj_owner_to_add_you_as_gitlab_collaborator": "You don’t have permission to push to the linked repository <0>__repoFullName__</0>. Ask the owner of the project (<1>__projectOwnerEmail__</1>) to add you as a collaborator to this repository.",
|
||||
"ask_proj_owner_to_export_to_gitlab": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to export the project to a Git repository and add you as a collaborator.",
|
||||
"ask_proj_owner_to_unlink_from_current_gitlab": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to unlink the project from the current git repository and create a connection to a different repository.",
|
||||
"ask_gitlab_repo_owner_to_reconnect": "Ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
|
||||
"ask_gitlab_repo_owner_to_renew_overleaf_subscription": "Ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
|
||||
"assistant_pointer_ask_ai": "Ask the AI assistant",
|
||||
"assistant_pointer_hint_environment": "Ask the AI assistant to change this __name__",
|
||||
"assistant_pointer_hint_package": "Ask the AI assistant to explain this package",
|
||||
@@ -407,6 +412,7 @@
|
||||
"checking": "Checking",
|
||||
"checking_dropbox_status": "Checking Dropbox status",
|
||||
"checking_project_github_status": "Checking project status in GitHub",
|
||||
"checking_project_gitlab_status": "Checking project status in GitLab",
|
||||
"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_new_password": "Choose a new password",
|
||||
@@ -540,6 +546,7 @@
|
||||
"contacting_the_sales_team": "Contacting the Sales team",
|
||||
"continue": "Continue",
|
||||
"continue_github_merge": "I have manually merged. Continue",
|
||||
"continue_gitlab_merge": "I have manually merged. Continue",
|
||||
"continue_to": "Continue to __appName__",
|
||||
"continue_to_checkout": "Continue to checkout",
|
||||
"continue_using_free_features": "Continue using our free features",
|
||||
@@ -575,6 +582,7 @@
|
||||
"create_new_tag": "Create new tag",
|
||||
"create_password": "Create password",
|
||||
"create_project_in_github": "Create a GitHub repository",
|
||||
"create_project_in_gitlab": "Create a GitLab repository",
|
||||
"create_your_account": "Create your account",
|
||||
"created": "Created",
|
||||
"created_at": "Created at",
|
||||
@@ -924,6 +932,7 @@
|
||||
"export_as_markdown": "Export as Markdown (.md)",
|
||||
"export_csv": "Export CSV",
|
||||
"export_project_to_github": "Export Project to GitHub",
|
||||
"export_project_to_gitlab": "Export Project to GitLab",
|
||||
"failed": "Failed",
|
||||
"failed_to_consent_to_workbench_terms": "Failed to consent to workbench terms. Please try again later.",
|
||||
"failed_to_publish_as_a_template": "Failed to publish as a template.",
|
||||
@@ -1033,6 +1042,7 @@
|
||||
"from_external_url": "From external URL",
|
||||
"from_filename": "From <0>__filename__</0>",
|
||||
"from_github": "From GitHub",
|
||||
"from_gitlab": "From GitLab",
|
||||
"from_project_files": "From project files",
|
||||
"from_provider": "From __provider__",
|
||||
"from_url": "From URL",
|
||||
@@ -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_error": "The __appName__ GitHub sync service couldn’t sync GitHub Workflow files (in .github/workflows/). Please authorize __appName__ to edit your GitHub workflow files and try again.",
|
||||
"github_workflow_files_error_non_owner": "The __appName__ GitHub sync service couldn’t sync GitHub Workflow files (in .github/workflows/). Please ensure the project owner has authorized __appName__ to edit this project’s GitHub workflow files and try again.",
|
||||
"gitlab": "GitLab",
|
||||
"gitlab_commit_message_placeholder": "Commit message for changes made in __appName__...",
|
||||
"gitlab_credentials_expired": "Your GitLab authorization credentials have expired",
|
||||
"gitlab_empty_repository_error": "It looks like your GitLab repository is empty or not yet available. Create a new file on GitLab.com then try again.",
|
||||
"gitlab_file_name_error": "This repository cannot be imported, because it contains file(s) with an invalid filename:",
|
||||
"gitlab_file_sync_error": "We are unable to sync the following files:",
|
||||
"gitlab_git_and_dropbox_integrations": "<0>GitLab</0>, <0>Git</0> and <0>Dropbox</0> integrations",
|
||||
"gitlab_git_and_reference_manager_integrations": "GitLab, Git and reference manager integrations",
|
||||
"gitlab_git_folder_error": "This project contains a .git folder at the top level, indicating that it is already a git repository. The Overleaf GitLab sync service cannot sync git histories. Please remove the .git folder and try again.",
|
||||
"gitlab_integration": "GitLab integration",
|
||||
"gitlab_integration_lowercase": "Git and GitLab integration",
|
||||
"gitlab_is_no_longer_connected": "GitLab is no longer connected to this project.",
|
||||
"gitlab_is_premium": "GitLab Sync is a premium feature",
|
||||
"gitlab_large_files_error": "Merge failed: your GitLab repository contains files over the 50mb file size limit ",
|
||||
"gitlab_manual_merge_user_prompt": "Please manually resolve the merge request associated with the branch <0>__sharelatex_branch__</0>. Click below to continue, after you have manually merged.",
|
||||
"gitlab_merge_failed_error": "Your changes in __appName__ and GitLab could not be automatically merged.",
|
||||
"gitlab_no_master_branch_error": "This repository cannot be imported as it is missing a default branch. Please make sure the project has a default branch",
|
||||
"gitlab_private_description": "You choose who can see and commit to this repository.",
|
||||
"gitlab_public_description": "Anyone can see this repository. You choose who can commit.",
|
||||
"gitlab_repository_diverged": "The default branch of the linked repository has been force-pushed. The list of recent commits below may be incomplete due to rewritten history since the last sync.",
|
||||
"gitlab_successfully_linked_description": "Thanks, we’ve successfully linked your GitLab account to __appName__. You can now export your __appName__ projects to GitLab, or import projects from your GitLab repositories.",
|
||||
"gitlab_symlink_error": "Your GitLab repository contains symbolic link files, which are not currently supported by Overleaf. Please remove these and try again.",
|
||||
"gitlab_sync_description": "With GitLab Sync you can link your __appName__ projects to GitLab repositories, create new commits from __appName__, and merge commits from GitLab.",
|
||||
"gitlab_sync_error": "Sorry, there was a problem checking our GitLab service. Please try again in a few moments.",
|
||||
"gitlab_sync_repository_not_found_description": "The linked repository has either been removed, or you no longer have access to it. You can set up sync with a new repository by cloning the project and using the ‘GitLab’ menu item. You can also unlink the repository from this project.",
|
||||
"gitlab_timeout_error": "Syncing your Overleaf project with GitLab has timed out. This may be due to the overall size of your project, or the number of files/changes to sync, being too large.",
|
||||
"gitlab_too_many_files_error": "This repository cannot be imported as it exceeds the maximum number of files allowed",
|
||||
"gitlab_validation_check": "Please check that the repository name is valid, and that you have permission to create the repository.",
|
||||
"gitlab_validation_check_auth": "Please check that you have permission to create the repository.",
|
||||
"gitlab_validation_name_exists": "Repository with this name already exists.",
|
||||
"gitlab_validation_name_invalid": "Repository name is invalid.",
|
||||
"gitlab_workflow_authorize": "Authorize GitLab Workflow files",
|
||||
"gitlab_workflow_files_delete_gitlab_repo": "The repository has been created on GitLab but linking was unsuccessful. You will have to delete GitLab repository or choose a new name.",
|
||||
"gitlab_workflow_files_error": "The __appName__ GitLab sync service couldn’t sync GitLab Workflow files (in .gitlab/workflows/). Please authorize __appName__ to edit your GitLab workflow files and try again.",
|
||||
"gitlab_workflow_files_error_non_owner": "The __appName__ GitLab sync service couldn’t sync GitLab Workflow files (in .gitlab/workflows/). Please ensure the project owner has authorized __appName__ to edit this project’s GitLab workflow files and try again.",
|
||||
"give-ai-consent": "Give AI consent",
|
||||
"give_feedback": "Give feedback",
|
||||
"global": "global",
|
||||
@@ -1215,6 +1260,7 @@
|
||||
"history_entry_origin_dropbox": "via Dropbox",
|
||||
"history_entry_origin_git": "via Git",
|
||||
"history_entry_origin_github": "via GitHub",
|
||||
"history_entry_origin_gitlab": "via GitLab",
|
||||
"history_entry_origin_upload": "upload",
|
||||
"history_label_created_by": "Created by",
|
||||
"history_label_project_current_state": "Current state",
|
||||
@@ -1352,7 +1398,9 @@
|
||||
"import_a_bibtex_file_from_your_provider_account": "Import a BibTeX file from your __provider__ account",
|
||||
"import_document_description": "Content will be imported from the selected document. Formatting may not be reproduced exactly.",
|
||||
"import_existing_projects_from_github": "Import existing projects from GitHub",
|
||||
"import_existing_projects_from_gitlab": "Import existing projects from GitLab",
|
||||
"import_from_github": "Import from GitHub",
|
||||
"import_from_gitlab": "Import from GitLab",
|
||||
"import_idp_metadata": "Import IdP metadata",
|
||||
"import_markdown_file": "Import Markdown file",
|
||||
"import_to_sharelatex": "Import to __appName__",
|
||||
@@ -1367,6 +1415,7 @@
|
||||
"imported_from_zotero_at_date_by": "Imported from Zotero at __formattedDate__ __relativeDate__ by __importedByName__",
|
||||
"importing": "Importing",
|
||||
"importing_and_merging_changes_in_github": "Importing and merging changes in GitHub",
|
||||
"importing_and_merging_changes_in_gitlab": "Importing and merging changes in GitLab",
|
||||
"in_editor_chat": "In-editor chat",
|
||||
"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.",
|
||||
@@ -1428,6 +1477,7 @@
|
||||
"integrations": "Integrations",
|
||||
"integrations_include": "Integrations include:",
|
||||
"integrations_like_github": "Integrations like GitHub Sync",
|
||||
"integrations_like_gitlab": "Integrations like GitLab Sync",
|
||||
"interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <0>__price__</0> Personal plan?",
|
||||
"invalid_certificate": "Invalid certificate. Please check the certificate and try again.",
|
||||
"invalid_confirmation_code": "That didn’t work. Please check the code and try again.",
|
||||
@@ -1617,6 +1667,8 @@
|
||||
"link_sharing_is_on": "Link sharing is on",
|
||||
"link_to_github": "Link to your GitHub account",
|
||||
"link_to_github_description": "You need to authorise __appName__ to access your GitHub account to allow us to sync your projects.",
|
||||
"link_to_gitlab": "Link to your GitLab account",
|
||||
"link_to_gitlab_description": "You need to authorise __appName__ to access your GitLab account to allow us to sync your projects.",
|
||||
"link_to_mendeley": "Link to Mendeley",
|
||||
"link_to_readcube": "Link to ReadCube",
|
||||
"link_to_zotero": "Link to Zotero",
|
||||
@@ -1630,9 +1682,11 @@
|
||||
"loading": "Loading",
|
||||
"loading_content": "Creating Project",
|
||||
"loading_github_repositories": "Loading your GitHub repositories",
|
||||
"loading_gitlab_repositories": "Loading your GitLab repositories",
|
||||
"loading_prices": "loading prices",
|
||||
"loading_python_runtime": "Loading Python runtime...",
|
||||
"loading_recent_github_commits": "Loading recent commits",
|
||||
"loading_recent_gitlab_commits": "Loading recent commits",
|
||||
"loading_references": "Loading references",
|
||||
"local_account": "Local account",
|
||||
"log_entry_description": "Log entry with level: __level__",
|
||||
@@ -1858,6 +1912,7 @@
|
||||
"no_members": "No members",
|
||||
"no_messages_yet": "No messages yet",
|
||||
"no_new_commits_in_github": "No new commits in GitHub since last merge.",
|
||||
"no_new_commits_in_gitlab": "No new commits in GitLab since last merge.",
|
||||
"no_one_has_commented_or_left_any_suggestions_yet": "No one has commented or left any suggestions yet.",
|
||||
"no_one_invited_yet": "No one invited yet",
|
||||
"no_one_sharing_link_is_disabled": "No one—sharing link is disabled",
|
||||
@@ -1943,6 +1998,7 @@
|
||||
"only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.",
|
||||
"only_invited_people": "Only invited people",
|
||||
"only_project_owner_can_link_github": "Only the project owner can link this project to a GitHub repository. Please contact the project owner if you would like to set this up.",
|
||||
"only_project_owner_can_link_gitlab": "Only the project owner can link this project to a GitLab repository. Please contact the project owner if you would like to set this up.",
|
||||
"open_action_menu": "Open __name__ action menu",
|
||||
"open_advanced_reference_search": "Open advanced reference search",
|
||||
"open_as_template": "Open as Template",
|
||||
@@ -2193,6 +2249,7 @@
|
||||
"project_linked_to": "This project is linked to",
|
||||
"project_name": "Project name",
|
||||
"project_not_linked_to_github": "This project is not linked to a GitHub repository. You can create a repository for it in GitHub:",
|
||||
"project_not_linked_to_gitlab": "This project is not linked to a GitLab repository. You can create a repository for it in GitLab:",
|
||||
"project_notifications": "Project notifications",
|
||||
"project_notifications_muted_description": "You are not receiving any notifications, as you have disabled all project notifications.",
|
||||
"project_ownership_transfer_confirmation_1": "Are you sure you want to make <0>__user__</0> the owner of <1>__project__</1>?",
|
||||
@@ -2234,9 +2291,12 @@
|
||||
"publisher_account": "Publisher Account",
|
||||
"publishing": "Publishing",
|
||||
"pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__",
|
||||
"pull_gitlab_changes_into_sharelatex": "Pull GitLab changes into __appName__",
|
||||
"purge": "Purge",
|
||||
"push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub",
|
||||
"push_sharelatex_changes_to_gitlab": "Push __appName__ changes to GitLab",
|
||||
"push_to_github_pull_to_overleaf": "Push to GitHub, pull to __appName__",
|
||||
"push_to_gitlab_pull_to_overleaf": "Push to GitLab, pull to __appName__",
|
||||
"quoted_text": "Quoted text",
|
||||
"raw_logs": "Raw logs",
|
||||
"raw_logs_description": "Raw logs from the LaTeX compiler",
|
||||
@@ -2273,9 +2333,11 @@
|
||||
"real_time_track_changes": "Real-time track-changes",
|
||||
"realtime_track_changes": "Real-time track changes",
|
||||
"reauthorize_github_account": "Reauthorize your GitHub Account",
|
||||
"reauthorize_gitlab_account": "Reauthorize your GitLab Account",
|
||||
"recaptcha_conditions": "The site is protected by reCAPTCHA.",
|
||||
"recent": "Recent",
|
||||
"recent_commits_in_github": "Recent commits in GitHub",
|
||||
"recent_commits_in_gitlab": "Recent commits in GitLab",
|
||||
"recommended": "Recommended",
|
||||
"recompile": "Recompile",
|
||||
"recompile_from_scratch": "Recompile from scratch",
|
||||
@@ -2557,6 +2619,7 @@
|
||||
"select_from_source_files": "select from source files",
|
||||
"select_from_your_computer": "select from your computer",
|
||||
"select_github_repository": "Select a GitHub repository to import into __appName__.",
|
||||
"select_gitlab_repository": "Select a GitLab repository to import into __appName__.",
|
||||
"select_image_from_project_files": "Select image from project files",
|
||||
"select_project": "Select __project__",
|
||||
"select_projects": "Select Projects",
|
||||
@@ -2848,12 +2911,16 @@
|
||||
"sync_failed": "Sync failed. Please try again.",
|
||||
"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_gitlab_explanation": "Any changes you have made in __appName__ will be committed and merged with any updates in GitLab.",
|
||||
"sync_settings": "Sync settings",
|
||||
"sync_to_dropbox": "Sync to Dropbox",
|
||||
"sync_to_github": "Sync to GitHub",
|
||||
"sync_to_gitlab": "Sync to GitLab",
|
||||
"sync_with_a_github_repository": "Sync with a GitHub repository.",
|
||||
"sync_with_a_gitlab_repository": "Sync with a GitLab repository.",
|
||||
"sync_with_dropbox": "Sync with Dropbox",
|
||||
"sync_with_github": "Sync with GitHub",
|
||||
"sync_with_gitlab": "Sync with GitLab",
|
||||
"sync_with_zotero": "Sync with Zotero",
|
||||
"syncing": "Syncing",
|
||||
"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_fix_this_you_can": "To fix this, you can:",
|
||||
"to_fix_this_you_can_ask_the_github_repository_owner": "To fix this, you can ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
|
||||
"to_fix_this_you_can_ask_the_gitlab_repository_owner": "To fix this, you can ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
|
||||
"to_insert_or_move_a_caption_make_sure_tabular_is_directly_within_table": "To insert or move a caption, make sure \\begin{tabular} is directly within a table environment",
|
||||
"to_keep_edit_access": "To keep edit access, ask the project owner to upgrade their plan or reduce the number of people with edit access.",
|
||||
"to_modify_your_subscription_go_to": "To modify your subscription go to",
|
||||
@@ -3163,6 +3231,8 @@
|
||||
"unlink_from_sso": "Unlink from SSO",
|
||||
"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_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_google_account": "Unlink your Google account. <0>Remove it in Account settings (under Linked Accounts).</0>",
|
||||
"unlink_provider_account_title": "Unlink __provider__ Account",
|
||||
@@ -3170,6 +3240,7 @@
|
||||
"unlink_reference": "Unlink __provider__ References Provider",
|
||||
"unlink_sso": "Unlink SSO",
|
||||
"unlink_the_project_from_the_current_github_repo": "Unlink the project from the current GitHub repository and create a connection to a repository you own.",
|
||||
"unlink_the_project_from_the_current_gitlab_repo": "Unlink the project from the current GitLab repository and create a connection to a repository you own.",
|
||||
"unlink_user_explanation": "You’re about to remove the SSO login option for <0>__email__</0>. This will force them to reauthenticate their Overleaf account with your IdP. They’ll receive an email asking them to do this.",
|
||||
"unlink_users": "Unlink users",
|
||||
"unlink_warning_reference": "Warning: When you unlink your account from __provider__ you will not be able to import references into your projects.",
|
||||
@@ -3380,6 +3451,7 @@
|
||||
"work_or_university_sso": "Work/university single sign-on",
|
||||
"work_with_non_overleaf_users": "Work with non-Overleaf users",
|
||||
"work_with_other_github_users": "Work with other GitHub users",
|
||||
"work_with_other_gitlab_users": "Work with other GitLab users",
|
||||
"writefull": "Writefull",
|
||||
"x_changes_in": "__count__ change in",
|
||||
"x_changes_in_plural": "__count__ changes in",
|
||||
@@ -3453,6 +3525,7 @@
|
||||
"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_need_to_ask_the_github_repository_owner": "You’ll need to ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
|
||||
"youll_need_to_ask_the_gitlab_repository_owner": "You’ll need to ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
|
||||
"youll_no_longer_need_to_remember_credentials": "You’ll no longer need to remember a separate email address and password. Instead, you will use single-sign on to login to Overleaf. <0>Read more about SSO</0>.",
|
||||
"your_account_is_managed_by_admin_cant_join_additional_group": "Your __appName__ account is managed by your current group admin (__admin__). This means you can’t join additional group subscriptions. <0>Read more about Managed Users.</0>",
|
||||
"your_account_is_managed_by_your_group_admin": "Your account is managed by your group admin. You can’t change or delete your email address.",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
||||
export const GitLabSyncProjectStatesSchema = new Schema(
|
||||
{
|
||||
projectId: { type: ObjectId, ref: 'Project', required: true, unique: true },
|
||||
repoFullName: { type: String, required: true },
|
||||
defaultBranchName: { type: String, default: null },
|
||||
mergeStatus: { type: String, enum: ['clean', 'conflict', 'diverged'], default: 'clean' },
|
||||
lastSyncCommit: { type: String, default: null },
|
||||
lastSyncVersion: { type: Number, default: null },
|
||||
unmergedBranchName: { type: String, default: null },
|
||||
unmergedBranchHead: { type: String, default: null },
|
||||
conflictVersion: { type: Number, default: null },
|
||||
},
|
||||
{ collection: 'gitlabSyncProjectStates', minimize: false }
|
||||
)
|
||||
|
||||
export const GitLabSyncProjectStates = mongoose.model(
|
||||
'GitLabSyncProjectStates',
|
||||
GitLabSyncProjectStatesSchema,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
||||
export const GitLabSyncUserCredentialsSchema = new Schema(
|
||||
{
|
||||
userId: { type: ObjectId, ref: 'User', required: true, unique: true },
|
||||
gitlab: { type: String, required: true },
|
||||
},
|
||||
{ collection: 'gitlabSyncUserCredentials', minimize: false }
|
||||
)
|
||||
|
||||
export const GitLabSyncUserCredentials = mongoose.model(
|
||||
'GitLabSyncUserCredentials',
|
||||
GitLabSyncUserCredentialsSchema,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
import fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import Path from 'node:path'
|
||||
import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor'
|
||||
|
||||
const TOKEN_CIPHER_FILE = '/var/lib/overleaf/data/.token-cipher.json'
|
||||
const TOKEN_CIPHER_LABEL = 'OL_CEP-v3'
|
||||
|
||||
let encryptorInstance = null
|
||||
|
||||
/**
|
||||
* Get or create a stable encryptor data that persists across container
|
||||
* recreations. Priority:
|
||||
* 1. GITLAB_CIPHER_PASSWORD || TOKEN_CIPHER_PASSWORD env var (explicit user config)
|
||||
* 2. JSON file with encryptor data in the persistent volume (/var/lib/overleaf/data/)
|
||||
* — auto-generated on first use, survives container rebuilds
|
||||
*/
|
||||
function _getEncryptorData() {
|
||||
const cipherPassword = process.env.GITLAB_TOKEN_CIPHER_PASSWORD || process.env.TOKEN_CIPHER_PASSWORD
|
||||
const cipherLabel = process.env.GITLAB_TOKEN_CIPHER_LABEL ||
|
||||
process.env.TOKEN_CIPHER_LABEL ||
|
||||
TOKEN_CIPHER_LABEL
|
||||
if (cipherPassword) {
|
||||
return {
|
||||
cipherLabel,
|
||||
cipherPasswords: {
|
||||
[cipherLabel]: cipherPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
// cipherPassword is not set, use file
|
||||
const cipherFile = process.env.GITLAB_TOKEN_CIPHER_FILE ||
|
||||
process.env.TOKEN_CIPHER_FILE ||
|
||||
TOKEN_CIPHER_FILE
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(cipherFile, 'utf8'))
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
logger.info({ cipherFile }, 'Creating new cipherFile')
|
||||
} else {
|
||||
logger.error({ err, cipherFile }, 'Bad cipherFile')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// File doesn't exist yet — generate one
|
||||
try {
|
||||
const encryptorData = {
|
||||
cipherLabel,
|
||||
cipherPasswords: {
|
||||
[cipherLabel]: crypto.randomBytes(32).toString('base64')
|
||||
},
|
||||
}
|
||||
|
||||
const dir = Path.dirname(cipherFile)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(cipherFile, JSON.stringify(encryptorData, null, 2), { mode: 0o600 })
|
||||
return encryptorData
|
||||
|
||||
} catch (err) {
|
||||
logger.error({ err, cipherFile }, 'Cannot create cipherFile')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function _getEncryptor() {
|
||||
if (!encryptorInstance) {
|
||||
const encryptorData = _getEncryptorData()
|
||||
encryptorInstance = new AccessTokenEncryptorClass(encryptorData)
|
||||
}
|
||||
return encryptorInstance
|
||||
}
|
||||
|
||||
export const AccessTokenEncryptor = {
|
||||
async encryptJson(json) {
|
||||
return await _getEncryptor().promises.encryptJson(json)
|
||||
},
|
||||
async decryptToJson(encryptedJson) {
|
||||
return await _getEncryptor().promises.decryptToJson(encryptedJson)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import {
|
||||
postJSON,
|
||||
getJSON,
|
||||
} from '@/infrastructure/fetch-json'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||
import 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>
|
||||
)
|
||||
}
|
||||
+47
@@ -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
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { deleteJSON } from '@/infrastructure/fetch-json'
|
||||
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import 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')}:
|
||||
<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
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import 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
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useEffect } from 'react'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import {
|
||||
getJSON,
|
||||
postJSON
|
||||
} from '@/infrastructure/fetch-json'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import 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
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import 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
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import clientId from '@/utils/client-id'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import 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
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import 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')}:
|
||||
<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} <{commit.author.email}>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.isProjectUpdated && (
|
||||
<>
|
||||
<hr />
|
||||
<OLForm>
|
||||
<p>{t('sync_project_to_gitlab_explanation', { appName })}</p>
|
||||
<OLRow>
|
||||
<OLCol xs={12}>
|
||||
<OLFormGroup>
|
||||
<OLFormControl
|
||||
as="textarea"
|
||||
rows={1}
|
||||
value={commitMessage}
|
||||
placeholder={t('gitlab_commit_message_placeholder', { appName })}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</OLForm>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<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
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
|
||||
const GitSyncNeedAuthModal = ({ handleHide }: { handleHide: () => void }) => {
|
||||
const { t } = useTranslation()
|
||||
const { appName } = getMeta('ol-ExposedSettings')
|
||||
return (
|
||||
<>
|
||||
<OLModalBody>
|
||||
<p>{t('link_to_gitlab_description', { appName })}</p>
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={handleHide}
|
||||
>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
handleHide()
|
||||
window.open(
|
||||
'/user/gitlab-sync/oauth2',
|
||||
'gitlabAuth',
|
||||
'width=600,height=700'
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t('link_to_gitlab')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GitSyncNeedAuthModal
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import 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
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { deleteJSON } from '@/infrastructure/fetch-json'
|
||||
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import 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
|
||||
@@ -78,7 +78,6 @@
|
||||
"last 1 year",
|
||||
"safari >= 15"
|
||||
],
|
||||
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "^4.0.113",
|
||||
"@ai-sdk/mcp": "patch:@ai-sdk/mcp@npm%3A1.0.37#~/.yarn/patches/@ai-sdk-mcp-npm-1.0.37-8cd89b8972.patch",
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('Features', function () {
|
||||
expect(ctx.Features.hasFeature('analytics')).to.be.false
|
||||
expect(ctx.Features.hasFeature('git-bridge')).to.be.false
|
||||
expect(ctx.Features.hasFeature('github-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('homepage')).to.be.false
|
||||
expect(ctx.Features.hasFeature('link-url')).to.be.false
|
||||
expect(ctx.Features.hasFeature('oauth')).to.be.false
|
||||
@@ -78,6 +79,7 @@ describe('Features', function () {
|
||||
expect(ctx.Features.hasFeature('analytics')).to.be.false
|
||||
expect(ctx.Features.hasFeature('git-bridge')).to.be.false
|
||||
expect(ctx.Features.hasFeature('github-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('homepage')).to.be.false
|
||||
expect(ctx.Features.hasFeature('link-url')).to.be.false
|
||||
expect(ctx.Features.hasFeature('oauth')).to.be.false
|
||||
@@ -108,6 +110,7 @@ describe('Features', function () {
|
||||
expect(ctx.Features.hasFeature('link-url')).to.be.false
|
||||
expect(ctx.Features.hasFeature('git-bridge')).to.be.false
|
||||
expect(ctx.Features.hasFeature('github-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.false
|
||||
expect(ctx.Features.hasFeature('homepage')).to.be.false
|
||||
expect(ctx.Features.hasFeature('oauth')).to.be.false
|
||||
expect(ctx.Features.hasFeature('saml')).to.be.false
|
||||
@@ -118,6 +121,7 @@ describe('Features', function () {
|
||||
ctx.settings.enableHomepage = true
|
||||
ctx.settings.enableGitBridge = true
|
||||
ctx.settings.enableGithubSync = true
|
||||
ctx.settings.enableGitLabSync = true
|
||||
ctx.settings.enableSaml = true
|
||||
ctx.settings.oauth = true
|
||||
ctx.settings.enabledLinkedFileTypes = ['url', 'project_file']
|
||||
@@ -127,6 +131,7 @@ describe('Features', function () {
|
||||
expect(ctx.Features.hasFeature('affiliations')).to.be.true
|
||||
expect(ctx.Features.hasFeature('analytics')).to.be.true
|
||||
expect(ctx.Features.hasFeature('github-sync')).to.be.true
|
||||
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.true
|
||||
expect(ctx.Features.hasFeature('git-bridge')).to.be.true
|
||||
expect(ctx.Features.hasFeature('homepage')).to.be.true
|
||||
expect(ctx.Features.hasFeature('link-url')).to.be.true
|
||||
|
||||
@@ -55,6 +55,8 @@ export type ExposedSettings = {
|
||||
templatesEnabled?: boolean
|
||||
linkedInInsightsPartnerId?: string
|
||||
githubSyncEnabled: boolean
|
||||
gitlabSyncEnabled: boolean
|
||||
zoteroEnabled: boolean
|
||||
enablePandocConversions: boolean
|
||||
gitlabUrl: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user