Github Sync: initial commit

This commit is contained in:
yu-i-i
2026-07-17 18:03:40 +02:00
parent 25765f6a7d
commit fd583d5bf0
36 changed files with 3971 additions and 12 deletions
@@ -0,0 +1,24 @@
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
const { Schema } = mongoose
const { ObjectId } = Schema
export const GitHubSyncProjectStatesSchema = 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: 'githubSyncProjectStates', minimize: false }
)
export const GitHubSyncProjectStates = mongoose.model(
'GitHubSyncProjectStates',
GitHubSyncProjectStatesSchema,
)
@@ -0,0 +1,17 @@
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
const { Schema } = mongoose
const { ObjectId } = Schema
export const GitHubSyncUserCredentialsSchema = new Schema(
{
userId: { type: ObjectId, ref: 'User', required: true, unique: true },
github: { type: String, required: true },
},
{ collection: 'githubSyncUserCredentials', minimize: false }
)
export const GitHubSyncUserCredentials = mongoose.model(
'GitHubSyncUserCredentials',
GitHubSyncUserCredentialsSchema,
)
@@ -0,0 +1,495 @@
import AbortError from 'node-fetch'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import {
fetchJson,
fetchJsonWithResponse,
fetchStreamWithResponse,
fetchNothing,
fetchStream,
RequestFailedError,
} from '@overleaf/fetch-utils'
import {
InvalidTokenError,
NotFoundError,
GitConflictError,
RateLimitError,
ProviderRequestError,
PermissionDeniedError,
AlreadyExistsError,
} from './GitSyncErrors.mjs'
const GITHUB_URL = 'https://github.com'
const GITHUB_API_BASE = 'https://api.github.com'
const GITHUB_GRAPHQL = 'https://api.github.com/graphql'
const MAX_PER_PAGE = 100 // GitHub REST API limit
const REQUEST_TIMEOUT_MS = 60 * 1000
const REQUEST_LONG_TIMEOUT_MS = 600 * 1000
const maxConcurrency = process.env.GITHUB_API_MAX_CONCURRENCY || 5
function buildHeaders(token) {
return {
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Overleaf-CEP-GitHub-Sync',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
}
// Error handling
function isRateLimitError(response) {
const remaining = response.headers.get('x-ratelimit-remaining')
const retryAfter = response.headers.get('retry-after')
return remaining === '0' || retryAfter != null
}
function isRepositoryAlreadyExistsError(err) {
if (err.response?.status !== 422) {
return false
}
let body = err.body
if (typeof body === 'string') {
try {
body = JSON.parse(body)
} catch {
return false
}
}
const errors = body?.errors
if (!Array.isArray(errors)) {
return false
}
return errors.some(error =>
error?.resource === 'Repository' &&
error?.field === 'name' &&
typeof error?.message === 'string' &&
error.message.toLowerCase().includes('already exists')
)
}
function normalizeGitHubError(err, operation) {
logger.error({ operation }, 'GitHub API request failed')
if (err.name === 'AbortError') {
throw new ProviderRequestError('GitHub request timed out', { status: 504 }, err)
}
if (!(err instanceof RequestFailedError)) {
throw new ProviderRequestError('Something wrong with GitHub request', { status: 500 }, err)
}
const status = err.response?.status || 500
if (status === 401) {
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 === 422) {
if (isRepositoryAlreadyExistsError(err)) {
throw new AlreadyExistsError('Repository already exists', { status }, err)
}
}
throw new ProviderRequestError('GitHub request failed', { status }, err)
}
// wrappers
function fetchGitHubJson(url, options, operation) {
return fetchJson(url, options).catch(err => {
normalizeGitHubError(err, operation)
})
}
function fetchGitHubJsonWithResponse(url, options, operation) {
return fetchJsonWithResponse(url, options).catch(err => {
normalizeGitHubError(err, operation)
})
}
function fetchGitHubStreamWithResponse(url, options, operation) {
return fetchStreamWithResponse(url, options).catch(err => {
normalizeGitHubError(err, operation)
})
}
function fetchGitHubNothing(url, options, operation) {
return fetchNothing(url, options).catch(err => {
normalizeGitHubError(err, operation)
})
}
function fetchGitHubStream(url, options, operation) {
return fetchStream(url, options).catch(err => {
normalizeGitHubError(err, operation)
})
}
// ---------------------- exports ------------------------------- //
// OAuth
function getOAuth2Url() {
const oAuthUrl = new URL(`${GITHUB_URL}/login/oauth/authorize`)
oAuthUrl.searchParams.append('client_id', Settings.githubSync.clientID)
oAuthUrl.searchParams.append('redirect_uri', Settings.githubSync.callbackURL)
oAuthUrl.searchParams.append('scope', 'read:org,repo,workflow')
return oAuthUrl
}
function exchangeCodeForToken(code) {
return fetchGitHubJson(`${GITHUB_URL}/login/oauth/access_token`, {
method: 'POST',
headers: buildHeaders(),
json: {
code,
client_id: Settings.githubSync.clientID,
client_secret: Settings.githubSync.clientSecret,
redirect_uri: Settings.githubSync.callbackURL,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'exchangeCodeForToken').then(r => r.access_token)
}
function revokeToken(token) {
const { clientID, clientSecret } = Settings.githubSync
return fetchGitHubNothing(`${GITHUB_API_BASE}/applications/${clientID}/token`, {
method: 'DELETE',
headers: buildHeaders(),
basicAuth: {
user: clientID,
password: clientSecret,
},
json: { access_token: token },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'revokeToken')
}
// user, orgs, permissions
function getUser(token) {
return fetchGitHubJson(`${GITHUB_API_BASE}/user`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'getUser').then(({ id, login, name }) => ({ id, login, name }))
}
/*
async function getUserAndOrgsREST(token) {
const user = await fetchGitHubJson(`${GITHUB_API_BASE}/user`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'getUserAndOrgs:user')
const page = 1
const params = new URLSearchParams({
page: page.toString(),
per_page: MAX_PER_PAGE.toString(),
})
const orgs = await fetchGitHubJson(`${GITHUB_API_BASE}/user/orgs?${params}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'getUserAndOrgs:orgs')
return {
user: user.login,
orgs: orgs.map(o => o.login)
}
}
*/
async function getUserAndOrgs(token) {
const query = `
query {
viewer {
login
organizations(first: 100) {
nodes {
login
viewerCanCreateRepositories
}
}
}
}
`
const json = await fetchGitHubJson(GITHUB_GRAPHQL, {
method: 'POST',
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
json: { query }
}, 'getUserAndOrgs')
const viewer = json.data.viewer
return {
user: viewer.login,
orgs: viewer.organizations.nodes.map(o => o.login)
}
}
function getPushPermission(token, repoFullName) {
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'getPushPrmission').then(repo => (repo.permissions?.push === true))
}
// repos
function createRepo(token, { name, description, isPublic, org }) {
const url = org ? `${GITHUB_API_BASE}/orgs/${org}/repos` : `${GITHUB_API_BASE}/user/repos`
return fetchGitHubJson(url, {
method: 'POST',
headers: buildHeaders(token),
json: {
name,
description,
private: !isPublic,
auto_init: true,
},
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
}, 'createRepo')
}
async function _listUserReposPage(token, page) {
const params = new URLSearchParams({
page: page.toString(),
per_page: MAX_PER_PAGE.toString(),
})
const { json, response } = await fetchGitHubJsonWithResponse(`${GITHUB_API_BASE}/user/repos?${params}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, '_listUserReposPage')
const link = response.headers.get('link') || ''
const hasNext = link.includes('rel="next"')
return {
repos: json.map(r => ({
name: r.name,
fullName: r.full_name,
defaultBranchName: r.default_branch,
})),
hasNext,
}
}
async function listUserRepos(token) {
let all = []
let page = 1
while (true) {
const { repos, hasNext } = await _listUserReposPage(token, page)
all = all.concat(repos)
if (!hasNext) break
page++
}
return all
}
// Git (blobs / trees / commits)
function uploadBlob(token, repoFullName, buffer) {
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/blobs`, {
method: 'POST',
headers: buildHeaders(token),
json: {
content: buffer,
encoding: 'base64',
},
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
}, 'uploadBlob').then(r => r.sha)
}
function getBlobStream(token, repoFullName, ref, path) {
const encodedPath = encodeURIComponent(path).replace(/%2F/g, '/')
return fetchGitHubStream(`${GITHUB_API_BASE}/repos/${repoFullName}/contents/${encodedPath}?ref=${ref}`, {
headers: {
...buildHeaders(token),
Accept: 'application/vnd.github.raw',
},
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS),
}, 'getBlobStream')
}
function createTree(token, repoFullName, entries, base_tree) {
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/trees`, {
method: 'POST',
headers: buildHeaders(token),
json: {
tree: entries.map(e => ({
path: e.path,
sha: e.sha,
mode: '100644',
type: 'blob',
})),
...(base_tree && { base_tree }),
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'createTree').then(r => r.sha)
}
function createCommit(token, repoFullName, { tree, message, parents = [] }) {
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/commits`, {
method: 'POST',
headers: buildHeaders(token),
json: {
tree,
message,
parents,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'createCommit').then(r => r.sha)
}
function getCommitTree(token, repoFullName, commit) {
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/commits/${commit}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'getCommitTree').then(r => r.tree.sha)
}
function listBlobsAtCommit(token, repoFullName, commit) {
// can use commit sha here instead of tree sha
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/trees/${commit}?recursive=1`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
}, 'listBlobsAtCommit').then(r =>
(r.tree || [])
.filter(entry => entry.type === 'blob')
.map(entry => ({
sha: entry.sha,
path: entry.path
}))
)
}
async function listNewCommitsWithStatus(token, fullName, branchName, fromCommit) {
const url = `${GITHUB_API_BASE}/repos/${fullName}/compare/${fromCommit}...${encodeURIComponent(branchName)}`
const data = await fetchGitHubJson(url, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
}, 'listNewCommitsWithStatus')
const commits = (data.commits || []).map(c => ({
message: c.commit?.message || '',
author: {
name: c.commit?.author?.name || '',
email: c.commit?.author?.email || '',
date: c.commit?.author?.date || '',
},
sha: c.sha,
}))
const diverged = (data.status === 'diverged' || data.status === 'behind')
return { commits, diverged }
}
// branches
function getBranchHead(token, repoFullName, branchName) {
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/ref/heads/${encodeURIComponent(branchName)}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'getBranchHead').then(r => r.object.sha)
}
function createBranch(token, repoFullName, branchName, sha) {
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/refs`, {
method: 'POST',
headers: buildHeaders(token),
json: {
ref: `refs/heads/${branchName}`,
sha,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'createBranch')
}
function updateBranch(token, repoFullName, branchName, sha, force = false) {
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/refs/heads/${encodeURIComponent(branchName)}`, {
method: 'PATCH',
headers: buildHeaders(token),
json: { sha, force },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'updateBranch')
}
function deleteBranch(token, repoFullName, branchName) {
return fetchGitHubNothing(`${GITHUB_API_BASE}/repos/${repoFullName}/git/refs/heads/${encodeURIComponent(branchName)}`, {
method: 'DELETE',
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'deleteBranch')
}
// merge / compare
function mergeBranch(token, repoFullName, base, head) {
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/merges`, {
method: 'POST',
headers: buildHeaders(token),
json: { base, head },
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
}, 'mergeBranch').then(r => r.sha)
}
function compareCommits(token, repoFullName, from, to) {
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/compare/${from}...${to}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'compareCommits').then(r => { r.files || [] })
}
// zip
function getRepoZipball(token, repoFullName, sha) {
return fetchGitHubStream(`${GITHUB_API_BASE}/repos/${repoFullName}/zipball/${sha}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS),
}, 'getRepoZipball')
}
export default {
maxConcurrency,
getOAuth2Url,
exchangeCodeForToken,
revokeToken,
getUser,
getUserAndOrgs,
getPushPermission,
createRepo,
listUserRepos,
uploadBlob,
getBlobStream,
createTree,
createCommit,
getCommitTree,
listBlobsAtCommit,
listNewCommitsWithStatus,
getBranchHead,
createBranch,
updateBranch,
deleteBranch,
mergeBranch,
compareCommits,
getRepoZipball,
}
@@ -0,0 +1,239 @@
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 GitHubSyncHandler from './GitHubSyncHandler.mjs'
import TokenManager from './TokenManager.mjs'
import api from './GitHubApiClient.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 GitHubSyncHandler.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 GitHubSyncHandler.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 GitHubSyncHandler.getUserAndOrgs(userId)
res.json(userAndOrgs)
}
async function listUserRepos(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
try {
const repos = await GitHubSyncHandler.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 GitHubSyncHandler.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 GitHub 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 GitHubSyncHandler.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
try {
token = await api.exchangeCodeForToken(code)
if (!token) {
HttpErrorHandler.badRequest(req, res, 'Failed to obtain access token from Git server')
return
}
} catch (error) {
const info = OError.getFullInfo(err)
logger.error(OError.getFullStack(err))
logger.error({ info, userId }, 'Failed to obtain access token from Git server')
HttpErrorHandler.badRequest(req, res, error.message || 'Bad request')
return
}
try {
await TokenManager.saveUserToken(userId, token)
} 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('github_successfully_linked_description')
res.redirect('/user/settings?oauth-complete=github#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 GitHubSyncHandler.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 = 'github_validation_check'
if (err instanceof AlreadyExistsError) key = 'github_validation_name_exists'
else if (errStatus === 401 || errStatus === 403) key = 'github_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 GitHub')
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 GitHubSyncHandler.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,241 @@
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 './GitHubApiClient.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)
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)
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)
return await api.listUserRepos(token)
}
async function getUserAndOrgs(userId) {
const token = await TokenManager.getUserToken(userId)
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)
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)
const defaultBranchHead = await api.getBranchHead(token, repoFullName, defaultBranchName)
const fsPath = Path.join(Settings.path.dumpFolder, `github_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)
const { full_name: 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 = await api.uploadBlob(token, repoFullName, buffer)
return { path, sha }
})
)
)
const tree = await api.createTree(token, repoFullName, blobData)
const initialCommit = await api.createCommit(token, repoFullName, {
tree,
message: 'Initial Overleaf import',
})
const force = true
await api.updateBranch(token, repoFullName, defaultBranchName, initialCommit, force)
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 GitHubSyncController from './GitHubSyncController.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 github-sync router')
// start the GitHub OAuth flow by redirecting to GitHub authorization page
webRouter.get(
'/user/github-sync/oauth2',
AuthenticationController.requireLogin(),
GitHubSyncController.oauth2
)
// callback for GitHub OAuth flow
webRouter.get(
'/user/github-sync/oauth2/callback',
AuthenticationController.requireLogin(),
GitHubSyncController.oauth2Callback
)
// unlink GitHub account
webRouter.post(
'/user/github-sync/unlink',
AuthenticationController.requireLogin(),
GitHubSyncController.unlink
)
// get user git connection status
webRouter.get(
'/user/github-sync/status',
AuthenticationController.requireLogin(),
GitHubSyncController.getConnectionStatus
)
// get git user name and user's organizations
webRouter.get(
'/user/github-sync/orgs',
AuthenticationController.requireLogin(),
GitHubSyncController.getUserAndOrgs
)
// list user's repos
webRouter.get(
'/user/github-sync/repos',
AuthenticationController.requireLogin(),
GitHubSyncController.listUserRepos
)
// create a new project from git server repo
webRouter.post(
'/project/new/github-sync',
AuthenticationController.requireLogin(),
GitHubSyncController.importRepo
)
// export project to git server
webRouter.post(
'/project/:project_id/github-sync/export',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitHubSyncController.exportProject
)
// get project sync state
webRouter.get(
'/project/:project_id/github-sync/state',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitHubSyncController.getProjectState
)
// get overview of the coming merge:
// merge state (clean or diverged), unmerged commits, is OL version changed?
webRouter.get(
'/project/:project_id/github-sync/merge/overview',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitHubSyncController.getMergeOverview
)
// merge OL project with Git server repo
webRouter.post(
'/project/:project_id/github-sync/merge',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitHubSyncController.gitMerge
)
// unlink Git repo from Git server
webRouter.delete(
'/project/:project_id/github-sync',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitHubSyncController.unlinkRepo
)
},
}
@@ -0,0 +1,783 @@
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 './GitHubApiClient.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)
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
const olBranchHead =
await exportChangesToGit({
token,
projectId,
repoFullName,
lastSyncVersion: projectSyncState.lastSyncVersion,
currentVersion,
message,
baseCommit: projectSyncState.lastSyncCommit,
})
if (!olBranchHead) {
if ( defaultBranchHead === projectSyncState.lastSyncCommit) {
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion: currentVersion })
return null
}
const lastSyncCommit = defaultBranchHead
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
return null
}
if (defaultBranchHead === projectSyncState.lastSyncCommit) {
// Trying to FF
try {
await api.updateBranch(token, repoFullName, defaultBranchName, olBranchHead)
const lastSyncCommit = olBranchHead
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
return null
} catch (err) {
if (!(err instanceof GitConflictError)) { throw err }
}
}
// FF failed, fallback to merge
const { mergeCommit, tempBranchName, conflict } = await mergeWithTempBranch(
token,
repoFullName,
defaultBranchName,
olBranchHead
)
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
const newOlBranchHead = await exportChangesToGit({
token,
projectId,
repoFullName,
lastSyncVersion: conflictVersion,
currentVersion,
message: '[Updates in Overleaf during conflict resolution]',
baseCommit: prevOlBranchHead,
})
// Are there really were changes? (e.g.: file created, then removedi: 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, tempBranchName, conflict } = await mergeWithTempBranch(
token,
repoFullName,
defaultBranchName,
newOlBranchHead,
)
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, lastSybc commit possibly anavailable
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,
})
await api.updateBranch(token, repoFullName, defaultBranchName, lastSyncCommit)
}
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]',
})
// H --- B --- GH
const remoteChangesCommit = await createCommitFromEntries({
token,
repoFullName,
parentCommit: baseCommit,
entries: conflictRemoteEntries,
message: '[Overleaf GitSync conflict resolution: replay repo changes]',
})
// H --- B --- OL
const olChangesCommit = await createCommitFromEntries({
token,
repoFullName,
parentCommit: baseCommit,
entries: [...cleanLocalEntries, ...conflictLocalEntries],
message,
})
// default branch: --- H --- B --- GH
await api.updateBranch(
token,
repoFullName,
defaultBranchName,
remoteChangesCommit
)
// temp branch: --- H --- B --- OL
// merge: default <- temp (GH <- OL)
const { mergeCommit, tempBranchName, conflict } = await mergeWithTempBranch(
token,
repoFullName,
defaultBranchName,
olChangesCommit
)
// 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, 'github')
}))
)
const localLimit = pLimit(5)
await Promise.all(
deletions.map(path => localLimit(() => {
return UpdateMerger.promises.deleteUpdate(userId, projectId, '/' + path, 'github')
}))
)
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
// currentVesion of the file is not known to git server, uploading blob
if (localHash && localHash !== baseHash && localHash !== remoteHash) {
let buffer
if (localSnapshot[path].data?.content) {
buffer = Buffer.from(localSnapshot[path].data.content, 'utf8').toString('base64')
} else {
buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path)
}
const sha = await api.uploadBlob(token, repoFullName, buffer)
// assert sha !== localHash
}
// OL == GH, do nothing
if (localHash === remoteHash) continue
if (baseHash === remoteHash) {
// changed in OL
hasChanges = true
cleanLocalEntries.push({ path, sha: localHash })
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 })
conflictLocalEntries.push({ path, sha: localHash})
conflictBaseEntries.push({ path, sha: baseHash })
}
return {
cleanLocalEntries,
conflictRemoteEntries,
conflictLocalEntries,
conflictBaseEntries,
hasConflicts,
hasChanges,
}
}
async function createCommitFromEntries({
token,
repoFullName,
parentCommit,
entries,
message,
}) {
const baseTree = await api.getCommitTree(token, repoFullName, parentCommit)
const newTree = await api.createTree(token, repoFullName, entries, baseTree)
return api.createCommit(token, repoFullName, {
tree: newTree,
parents: [parentCommit],
message,
})
}
async function mergeWithTempBranch(
token,
repoFullName,
branchName,
updateCommit
) {
const tempBranchName = generateBranchName()
await api.createBranch(
token,
repoFullName,
tempBranchName,
updateCommit
)
try {
const mergeCommit =
await api.mergeBranch(token, repoFullName, branchName, tempBranchName)
try {
await api.deleteBranch(token, repoFullName, tempBranchName)
} catch (err) {
logger.warn({ err, repoFullName, tempBranchName }, 'Delete temp branch failed')
}
return { conflict: false, mergeCommit }
} catch (err) {
if (err instanceof GitConflictError) {
return { conflict: true, tempBranchName }
}
throw err
}
}
async function exportChangesToGit({
token,
projectId,
repoFullName,
lastSyncVersion,
currentVersion,
message,
baseCommit,
}) {
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 = await api.uploadBlob(token, repoFullName, buffer)
return { path, sha }
})
)
)
const deleteEntries = [...deletePaths].map(path => ({ path, sha: null }))
const entries = [...deleteEntries, ...upsertEntries]
const newCommit = await createCommitFromEntries({
token,
repoFullName,
parentCommit: baseCommit,
entries,
message,
})
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,26 @@
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 NotFoundError extends OError {}
class PermissionDeniedError extends OError {}
class RateLimitError extends OError {}
class ProviderRequestError extends OError {}
export {
AlreadyExistsError,
GitNotLinkedError,
GitConflictError,
InvalidTokenError,
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 { GitHubSyncProjectStates } from '../models/githubSyncProjectStates.mjs'
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
const { normalizeQuery } = Mongo
function getProjectState(projectId, projection = {}) {
return GitHubSyncProjectStates.findOne(normalizeQuery({ projectId }), projection).lean()
}
function createProjectState(projectId, data) {
return GitHubSyncProjectStates.create({
projectId: normalizeQuery(projectId),
...data
})
}
function updateProjectState(projectId, data) {
return GitHubSyncProjectStates.updateOne(
normalizeQuery({ projectId }),
{ $set: data },
)
}
function removeProjectState(projectId) {
return GitHubSyncProjectStates.deleteMany(normalizeQuery({ projectId }))
}
export default {
getProjectState,
createProjectState,
updateProjectState,
removeProjectState,
}
@@ -0,0 +1,72 @@
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import AccessTokenEncryptor from '@overleaf/access-token-encryptor'
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
import { GitHubSyncUserCredentials } from '../models/githubSyncUserCredentials.mjs'
import { InvalidTokenError } from './GitSyncErrors.mjs'
const { normalizeQuery } = Mongo
// encrypt / decrypt helpers
const accessTokenEncryptor = new AccessTokenEncryptor({
cipherLabel: process.env.GITHUB_TOKEN_CIPHER_LABEL || "OL_CEP-v3",
cipherPasswords: {
[process.env.GITHUB_TOKEN_CIPHER_LABEL || "OL_CEP-v3"]: process.env.GITHUB_TOKEN_CIPHER_PASSWORD,
},
})
async function encryptAccessToken(accessToken) {
try {
return await accessTokenEncryptor.promises.encryptJson(accessToken)
} catch (err) {
throw OError.tag('failed to encrypt token', err)
}
}
async function decryptAccessToken(tokenEncrypted) {
try {
return await accessTokenEncryptor.promises.decryptToJson(tokenEncrypted)
} catch (err) {
throw new InvalidTokenError('failed to decrypt token', { status: 401 }, err)
}
}
// ------------------------- exports -------------------------- //
async function getUserToken(userId) {
const credentials = await GitHubSyncUserCredentials.findOne(normalizeQuery({ userId }))
if (!credentials) throw new InvalidTokenError('no user token', { userId, status: 400 })
return await decryptAccessToken(credentials.github)
}
async function saveUserToken(userId, accessToken) {
const tokenEncrypted = await encryptAccessToken(accessToken)
await GitHubSyncUserCredentials.findOneAndUpdate(
normalizeQuery({ userId }),
{ $set: { github: tokenEncrypted } },
{ upsert: true }
)
}
// Try to revoke user's token, then remove it
async function removeUserToken(userId) {
let token
try {
const token = await getUserToken(userId)
} catch (err) {
logger.warn({ err, userId }, 'failed to get user token')
}
// fire-and-forget, but still handle errors
if (token) {
GitHubApiClient.revokeToken(token).catch(err => {
logger.warn({ err, userId }, 'failed to revoke user token')
})
}
await GitHubSyncUserCredentials.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 GithubLogo from '@/shared/svgs/github-logo'
import IntegrationCard from '@/features/integrations-panel/integration-card'
import GitSyncModal from './modals/git-sync-modal'
import { GitSyncModalStatus } from '../types/git-sync-types'
const GitHubSyncCard = () => {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const { projectId, name } = useProjectContext()
const [modalStatus, setModalStatus] = useState<GitSyncModalStatus>('loading')
return (
<>
<IntegrationCard
title={t('github')}
description={t('sync_with_a_github_repository')}
icon={<GithubLogo 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 GitHubSyncCard
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/shared/components/ol/ol-button'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLNotification from '@/shared/components/ol/ol-notification'
import GithubLogo from '@/shared/svgs/github-logo'
export const GitHubSyncWidget = function GitHubSyncWidget() {
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/github-sync/status')).catch(err =>
debugConsole.error(err?.data?.message || err?.message || err),
)
}, [runAsyncConnCheck])
useEffect(() => {
handleConnCheck()
}, [handleConnCheck])
const handleUnlink = useCallback(() => {
runAsyncUnlink(postJSON('/user/github-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>
<GithubLogo />
</div>
<div className="description-container">
<div className="title-row">
<h4>GitHub</h4>
</div>
<p className="small">
<span>{t('loading')}…</span>
</p>
</div>
</div>
)
}
return (
<>
<div className="settings-widget-container">
<div>
<GithubLogo size={40} />
</div>
<div className="description-container">
<div className="title-row">
<h4 id="github-sync">GitHub</h4>
</div>
<p className="small">
{t('github_sync_description', { appName })}
</p>
{isErrorConnCheck && (
<OLNotification
type="error"
content={t('github_sync_error')}
/>
)}
{isErrorUnlink && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</div>
<div>
{isConnected ? (
<OLButton
variant="danger-ghost"
onClick={() => setShowUnlinkModal(true)}
disabled={isUnlinking}
>
{isUnlinking ? t('unlinking') : t('unlink')}
</OLButton>
) : isErrorConnCheck ? (
<OLButton
variant="secondary"
onClick={handleConnCheck}
>
{t('reconnect')}
</OLButton>
) : (
<OLButton
variant="secondary"
href="/user/github-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: 'GitHub',
})}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
{t('unlink_github_warning', {
provider: 'GitHub',
})}
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={() => setShowUnlinkModal(false)}
>
{t('cancel')}
</OLButton>
<OLButton
variant="danger-ghost"
onClick={handleUnlink}
disabled={isUnlinking}
>
{isUnlinking ? t('unlinking') : t('unlink')}
</OLButton>
</OLModalFooter>
</OLModal>
</>
)
}
@@ -0,0 +1,12 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
export default function ImportFromGitHubMenu({ onClick }: { onClick: (e: React.MouseEvent) => void }) {
const { t } = useTranslation()
return (
<DropdownItem onClick={onClick}>
{t('import_from_github')}
</DropdownItem>
)
}
@@ -0,0 +1,218 @@
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import {
postJSON,
getJSON,
} from '@/infrastructure/fetch-json'
import getMeta from '@/utils/meta'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import OLNotification from '@/shared/components/ol/ol-notification'
type GitSyncRepo = {
name: string
fullName: string
defaultBranchName: string
}
type GitSyncReposResponse = {
repos?: GitSyncRepo[]
}
function ImportFromGitHubModalContent({ handleHide }: { handleHide: () => void }) {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
const {
isLoading,
isSuccess,
isError,
data,
runAsync,
} = useAsync<GitSyncReposResponse>()
useEffect(() => {
runAsync(getJSON('/user/github-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])
const showLinkToGitHub = !isImporting && isSuccess && !reposExist
const showRepos = !isImporting && isSuccess && reposExist
const handleImport = (repo: GitSyncRepo) => {
runAsyncImport(
postJSON('/project/new/github-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_github')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{isLoading && (
<span>
<OLSpinner size="sm" className="me-2"/>
{t('loading_github_repositories')}
</span>
)}
{isImporting && (
<span>
<OLSpinner size="sm" className="me-2"/>
{t('importing')}
</span>
)}
{isError && (
<div className="notification-list">
<OLNotification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
{showLinkToGitHub && (
<div className="text-center">
<p>
{t('link_to_github_description', { appName })}
</p>
<OLButton
variant="secondary"
href="/user/github-sync/oauth2"
>
{t('link_to_github')}
</OLButton>
</div>
)}
{showRepos && (
<div>
{repos.length === 0 ? (
<p className="text-center">
{t('you_dont_have_any_repositories')}
</p>
) : (
<>
<p className="text-center">
{t('select_github_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={`https://github.com/${repo.fullName}`}
target="_blank"
rel="noopener noreferrer"
>
{repo.fullName}
</a>
</div>
</td>
<td className="text-end">
<OLButton
variant="primary"
onClick={() => handleImport(repo)}
>
{t('import_to_sharelatex', { appName: 'Overleaf' })}
</OLButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
{isErrorImport && (
<div className="notification-list">
<OLNotification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
</OLModalBody>
<OLModalFooter>
{!isImporting && (
<span className="me-auto">
<a
href="https://help.github.com/en/articles/requesting-organization-approval-for-oauth-apps"
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 ImportFromGitHubModal({ onHide }: { onHide: () => void }) {
return (
<OLModal
id="git-import-modal"
show
animation
size="lg"
onHide={onHide}
backdrop="static"
>
<ImportFromGitHubModalContent handleHide={onHide} />
</OLModal>
)
}
@@ -0,0 +1,47 @@
import { useTranslation, Trans } from 'react-i18next'
import OLNotification from '@/shared/components/ol/ol-notification'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import { ProjectSyncState } from '../../types/git-sync-types'
type GitSyncNeedPermissionModalProps = {
projectSyncState: ProjectSyncState
handleHide: () => void
}
const GitSyncCannotExportModal = ({ projectSyncState, handleHide }: GitSyncCannotExportModalProps) => {
const { t } = useTranslation()
return (
<>
<OLModalBody>
<OLNotification
type="warning"
content={(
<Trans
i18nKey="ask_proj_owner_to_export_to_github"
values={{
projectOwnerEmail: projectSyncState.ownerEmail ?? '?',
}}
components={[
projectSyncState.ownerEmail ? <a href={`mailto:${projectSyncState.ownerEmail}`} /> : <></>
]}
/>
)}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncCannotExportModal
@@ -0,0 +1,101 @@
import { useTranslation, Trans } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { deleteJSON } from '@/infrastructure/fetch-json'
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { debugConsole } from '@/utils/debugging'
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncUnlinkModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
projectSyncState: ProjectSyncState
projectId: string
}
const GitSyncUnlinkModal = ({
handleHide,
setModalStatus,
projectSyncState,
projectId,
}: GitSyncInitModalProps) => {
const { t } = useTranslation()
const {
error,
isLoading,
runAsync,
} = useAsync<void>()
const handleUnlink = () => {
runAsync(deleteJSON(`/project/${projectId}/github-sync`))
.then(() => setModalStatus('need-export'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
return (
<>
<OLModalBody>
<p>
{t('project_linked_to')}:&nbsp;
<a
href={`https://github.com/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
>
{projectSyncState.repoFullName}
</a>
</p>
<hr />
{!error ? (
<OLNotification
type="warning"
content={t('unlink_the_project_from_the_current_github_repo')}
/>
) : (
<OLNotification
type="error"
content={
error.info?.statusCode === 403 ? (
<Trans
i18nKey="ask_proj_owner_to_unlink_from_current_github"
values={{ projectOwnerEmail: error?.data?.ownerEmail ?? '?' }}
components={[
error?.data?.ownerEmail ? <a href={`mailto:${error.data.ownerEmail}`} /> : <></>
]}
/>
) : (
t('something_went_wrong_server')
)
}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex gap-2">
<OLButton
variant="danger"
onClick={handleUnlink}
disabled={isLoading || !!error}
>
{t('confirm')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncUnlinkModal
@@ -0,0 +1,57 @@
import { useTranslation, Trans } from 'react-i18next'
import getMeta from '@/utils/meta'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncConflictModalProps = {
projectSyncState: ProjectSyncState
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
}
const GitSyncConflictModal = ({ projectSyncState, handleHide, setModalStatus }: GitSyncConflictModalProps) => {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
return (
<>
<OLModalBody>
<OLNotification
type="warning"
content={t('github_merge_failed_error', { appName })}
/>
<p className="mt-2">
<Trans
i18nKey="github_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_github_merge')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncConflictModal
@@ -0,0 +1,229 @@
import { useTranslation } from 'react-i18next'
import { useState, useEffect } from 'react'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import {
getJSON,
postJSON
} from '@/infrastructure/fetch-json'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLNotification from '@/shared/components/ol/ol-notification'
import OLButton from '@/shared/components/ol/ol-button'
import OLForm from '@/shared/components/ol/ol-form'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import OLFormSelect from '@/shared/components/ol/ol-form-select'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import { GitSyncModalStatus } from '../../types/git-sync-types'
type OrgsResponse = {
user: string
orgs: string[]
}
type GitSyncExportModalProps = {
projectId: string
projectName: string
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
}
const GitSyncExportModal = ({
projectId,
projectName,
handleHide,
setModalStatus
}: GitSyncExportModalProps) => {
const { t } = useTranslation()
const [selectedOwner, setSelectedOwner] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const [repoName, setRepoName] = useState(projectName)
const [description, setDescription] = useState('')
const [visibility, setVisibility] = useState<'public' | 'private'>('private')
const {
runAsync: runAsyncUserAndOrgs,
data: userAndOrgs,
error: errorUserAndOrgs
} = useAsync<OrgsResponse>()
useEffect(() => {
runAsyncUserAndOrgs(getJSON('/user/github-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}/github-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_github')}</h4>
<p>{t('project_not_linked_to_github')}</p>
{error && (
<OLNotification
type="error"
content={error}
/>
)}
{errorUserAndOrgs && (
<OLNotification
type="error"
content={t('something_went_wrong_server')}
/>
)}
<OLForm onSubmit={createRepo}>
<OLRow>
<OLCol xs={4}>
<OLFormGroup>
<OLFormLabel htmlFor="github-sync-owner">
{selectedOwner === userAndOrgs?.user
? t('owner')
: t('organization')}
</OLFormLabel>
<OLFormSelect
id="github-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="github-sync-name">
{t('repository_name')}
</OLFormLabel>
<OLFormControl
id="github-sync-name"
name="name"
type="text"
value={repoName}
onChange={e => setRepoName(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
<OLRow>
<OLCol xs={12}>
<OLFormGroup>
<OLFormLabel htmlFor="github-sync-description">
{t('description')} ({t('optional')})
</OLFormLabel>
<OLFormControl
id="github-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('github_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('github_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_github')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncExportModal
@@ -0,0 +1,54 @@
import { useTranslation } from 'react-i18next'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncLoadingModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
errorMessage: string | null
}
const GitSyncLoadingModal = ({ handleHide, setModalStatus, errorMessage }: GitSyncLoadingModalProps) => {
const { t } = useTranslation()
return (
<>
<OLModalBody>
<div role="status" className="loading align-items-start">
<div aria-hidden="true" data-testid="ol-spinner" className="spinner-border spinner-border-sm"></div>
{t('checking_project_github_status')}
</div>
</OLModalBody>
{errorMessage && (
<OLNotification
type="error"
content={errorMessage}
/>
)}
<OLModalFooter>
{errorMessage && (
<OLButton
variant="danger-ghost"
onClick={() => setModalStatus('confirm-unlink')}
>
{t('unlink')}
</OLButton>
)}
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncLoadingModal
@@ -0,0 +1,119 @@
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 {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types'
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
}: GitSynMergeModalProps) => {
const { t } = useTranslation()
const { error, isError, isLoading, setError, runAsync } = useAsync<ProjectSyncState>()
useEffect(() => {
if (modalStatus !== 'run-merge' &&
modalStatus !== 'run-merge-resolved'
) return
const claimConflictIsResolved = (modalStatus === 'run-merge-resolved')
runAsync(
postJSON(`/project/${projectId}/github-sync/merge`, {
body: { message: commitMessage, claimConflictIsResolved },
})
)
.then(data => {
switch (data.mergeStatus) {
case 'clean':
setModalStatus('merge-overview')
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_github')}
</div>
</OLModalBody>
{error && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncMergeModal
@@ -0,0 +1,238 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { getJSON } from '@/infrastructure/fetch-json'
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
import OLForm from '@/shared/components/ol/ol-form'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLButton from '@/shared/components/ol/ol-button'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { debugConsole } from '@/utils/debugging'
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
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}/github-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])
return (
<>
<OLModalBody>
<p>
{t('project_linked_to')}:&nbsp;
<a
href={`https://github.com/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
>
{projectSyncState.repoFullName}
</a>
</p>
{isLoading && (
<>
<div role="status" className="loading align-items-start">
<div
aria-hidden="true"
data-testid="ol-spinner"
className="spinner-border spinner-border-sm"
></div>
{t('loading')}
</div>
</>
)}
{isSuccess && (
<>
<hr />
{data.diverged && (
<OLNotification
type="warning"
content={t('github_repository_diverged')}
/>
)}
<h3 className="github-sync-commits-heading">
{t('recent_commits_in_github')}
<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_github')}</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={`https://github.com/${projectSyncState.repoFullName}/commit/${commit.sha}`}
target="_blank"
rel="noreferrer noopener"
>
{commit.sha.substring(0, 7)}
</a>
</span>
<a
href={`https://github.com/${projectSyncState.repoFullName}/commit/${commit.sha}`}
target="_blank"
className="commit-message"
rel="noreferrer noopener"
>
{commit.message}
</a>
<div className="small">
by {commit.author.name} &lt;{commit.author.email}&gt;
</div>
</div>
))}
</div>
)}
{data.isProjectUpdated && (
<>
<hr />
<OLForm>
<p>{t('sync_project_to_github_explanation', { appName })}</p>
<OLRow>
<OLCol xs={12}>
<OLFormGroup>
<OLFormControl
as="textarea"
rows={1}
value={commitMessage}
placeholder={t('github_commit_message_placeholder', { appName })}
onChange={(e) => setCommitMessage(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
</OLForm>
</>
)}
</>
)}
{error && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex justify-content-between w-100">
<div className="d-flex gap-2">
<OLButton
variant="danger-ghost"
onClick={() => setModalStatus('confirm-unlink')}
disabled={!isSuccess && error?.info?.statusCode !== 404}
>
{t('unlink')}
</OLButton>
</div>
<div className="d-flex gap-2">
<OLButton
variant="primary"
leadingIcon="sync"
onClick={() => setModalStatus('run-merge')}
disabled={!isSuccess}
>
{t('sync')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</div>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncMergeOverviewModal
@@ -0,0 +1,226 @@
import { useTranslation } from 'react-i18next'
import { useState, useEffect } from 'react'
import {
OLModalHeader,
OLModalTitle,
OLModal,
} from '@/shared/components/ol/ol-modal'
import { getJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import GitSyncLoadingModal from './git-sync-loading-modal'
import GitSyncMergeOverviewModal from './git-sync-merge-overview-modal'
import GitSyncMergeModal from './git-sync-merge-modal'
import GitSyncConflictModal from './git-sync-conflict-modal'
import GitSyncExportModal from './git-sync-export-modal'
import GitSyncNeedAuthModal from './git-sync-need-auth-modal'
import GitSyncNeedPermissionModal from './git-sync-need-permission-modal'
import GitSyncConfirmUnlinkModal from './git-sync-confirm-unlink-modal'
import GitSyncUnlinkUnavailableModal from './git-sync-unlink-unavailable-modal'
import GitSyncCannotExportModal from './git-sync-cannot-export-modal'
import { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types'
type GitSyncModalProps = {
show: boolean
handleHide: () => void
projectId: string
projectName: string
modalStatus: GitSyncModalStatus
setModalStatus: (modalStatus: GitSyncModalStatus) => void
}
function GitSyncModal({
show,
handleHide,
projectId,
projectName,
modalStatus,
setModalStatus,
}: GitSyncModalProps) {
const { t } = useTranslation()
const [commitMessage, setCommitMessage] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const {
runAsync: runAsyncConn,
error: errorConn,
setError: setErrorConn,
data: dataConn,
} = useAsync<boolean>()
const {
runAsync,
data: projectSyncState,
setData: setProjectSyncState,
error,
setError,
} = useAsync<ProjectSyncState>()
useEffect(() => {
if (!show) return
if (modalStatus !== 'loading') return
const fetchProjectState = () => {
setError(null)
setErrorMessage('')
runAsync(getJSON(`/project/${projectId}/github-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/github-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_github')}</OLModalTitle>
</OLModalHeader>
{modalStatus === 'loading' && (
<GitSyncLoadingModal
handleHide={handleHide}
setModalStatus={setModalStatus}
errorMessage={errorMessage}
/>
)}
{modalStatus === 'need-export' && (
<GitSyncExportModal
projectId={projectId}
projectName={projectName}
handleHide={handleHide}
setModalStatus={setModalStatus}
/>
)}
{modalStatus === 'cannot-export' && (
<GitSyncCannotExportModal
projectSyncState={projectSyncState}
handleHide={handleHide}
/>
)}
{modalStatus === 'need-auth' && (
<GitSyncNeedAuthModal
handleHide={handleHide}
/>
)}
{modalStatus === 'need-permission' && (
<GitSyncNeedPermissionModal
handleHide={handleHide}
projectSyncState={projectSyncState}
/>
)}
{modalStatus === 'merge-overview' && (
<GitSyncMergeOverviewModal
projectId={projectId}
projectSyncState={projectSyncState}
setModalStatus={setModalStatus}
handleHide={handleHide}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
/>
)}
{modalStatus === 'confirm-unlink' && (
<GitSyncConfirmUnlinkModal
projectId={projectId}
projectSyncState={projectSyncState}
setModalStatus={setModalStatus}
handleHide={handleHide}
/>
)}
{modalStatus === 'unlink-unavailable' && (
<GitSyncUnlinkUnavailableModal
projectId={projectId}
setModalStatus={setModalStatus}
handleHide={handleHide}
/>
)}
{(modalStatus === 'run-merge' ||
modalStatus === 'run-merge-resolved') && (
<GitSyncMergeModal
handleHide={handleHide}
modalStatus={modalStatus}
setModalStatus={setModalStatus}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
projectId={projectId}
setProjectSyncState={setProjectSyncState}
/>
)}
{modalStatus === 'show-conflict' && (
<GitSyncConflictModal
projectSyncState={projectSyncState}
handleHide={handleHide}
setModalStatus={setModalStatus}
/>
)}
</OLModal>
)
}
export default GitSyncModal
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
const GitSyncNeedAuthModal = ({ handleHide }: { handleHide: () => void }) => {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
return (
<>
<OLModalBody>
<p>{t('link_to_github_description', { appName })}</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
<OLButton
variant="primary"
onClick={() => {
handleHide()
window.open(
'/user/github-sync/oauth2',
'githubAuth',
'width=600,height=700'
)
}}
>
{t('link_to_github')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncNeedAuthModal
@@ -0,0 +1,57 @@
import { useTranslation, Trans } from 'react-i18next'
import OLNotification from '@/shared/components/ol/ol-notification'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import { ProjectSyncState } from '../../types/git-sync-types'
type GitSyncNeedPermissionModalProps = {
projectSyncState: ProjectSyncState
handleHide: () => void
}
const GitSyncNeedPermissionModal = ({ projectSyncState, handleHide }: GitSyncConflictModalProps) => {
const { t } = useTranslation()
return (
<>
<OLModalBody>
<OLNotification
type="warning"
content={(
<Trans
i18nKey="ask_proj_owner_to_add_you_as_github_collaborator"
values={{
repoFullName: projectSyncState.repoFullName ?? '?',
projectOwnerEmail: projectSyncState.ownerEmail ?? '?',
}}
components={[
projectSyncState.repoFullName ? (
<a
href={`https://github.com/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
/>
) : (
<></>
),
projectSyncState.ownerEmail ? <a href={`mailto:${projectSyncState.ownerEmail}`} /> : <></>
]}
/>
)}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncNeedPermissionModal
@@ -0,0 +1,85 @@
import { useTranslation, Trans } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { deleteJSON } from '@/infrastructure/fetch-json'
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { debugConsole } from '@/utils/debugging'
import { GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncUnlinkUnavailableModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
projectId: string
}
const GitSyncUnlinkUnavailableModal = ({
handleHide,
setModalStatus,
projectId,
}: GitSyncInitModalProps) => {
const { t } = useTranslation()
const {
error,
isLoading,
runAsync,
} = useAsync<void>()
const handleUnlink = () => {
runAsync(deleteJSON(`/project/${projectId}/github-sync`))
.then(() => setModalStatus('need-export'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
return (
<>
<OLModalBody>
<OLNotification
type="error"
content={t('github_sync_repository_not_found_description')}
/>
{error && (
<OLNotification
type="error"
content={
error.info?.statusCode === 403 ? (
<Trans
i18nKey="ask_proj_owner_to_unlink_from_current_github"
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_github_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 @@
.github-sync-commits-heading {
align-items: center;
display: flex;
font-size: var(--font-size-03);
font-weight: 600;
justify-content: space-between;
line-height: var(--line-height-03);
}
@@ -0,0 +1,55 @@
import Settings from '@overleaf/settings'
import Modules from '../../app/src/infrastructure/Modules.mjs'
import logger from '@overleaf/logger'
import GitHubSyncRouter from './app/src/GitHubSyncRouter.mjs'
import SyncStateManager from './app/src/SyncStateManager.mjs'
import TokenManager from './app/src/TokenManager.mjs'
let GitHubSyncModule = {}
if (process.env.GITHUB_SYNC_ENABLED?.toLowerCase() === 'true') {
logger.debug({}, 'Enabling GitHub Sync module')
/*
const [{ default: GitHubSyncRouter },
{ default: SyncStateManager },
{ default: TokenManager }
] =
await Promise.all([
import('./app/src/GitHubSyncRouter.mjs'),
import('./app/src/SyncStateManager.mjs'),
import('./app/src/TokenManager.mjs'),
])
*/
// Delete project sync state from mongo (hook 'projectExpired')
const siteUrl = process.env.OVERLEAF_SITE_URL?.replace(/\/+$/, '') || 'http://localhost'
Settings.githubSync = {
clientID: process.env.GITHUB_SYNC_CLIENT_ID,
clientSecret: process.env.GITHUB_SYNC_CLIENT_SECRET,
callbackURL: `${siteUrl}/user/github-sync/oauth2/callback`,
},
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 github 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')
}
})
GitHubSyncModule = {
router: GitHubSyncRouter,
}
}
export default GitHubSyncModule