diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 7c88b1ab3..14576b24e 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -88,3 +88,5 @@ export const EFS_VOL_PATH_STAGING_LITE = path.join( ) export const STAGING_BRANCH = "staging" export const STAGING_LITE_BRANCH = "staging-lite" +export const PLACEHOLDER_FILE_NAME = ".keep" +export const GIT_SYSTEM_DIRECTORY = ".git" diff --git a/src/services/db/GitFileSystemService.ts b/src/services/db/GitFileSystemService.ts index 2acdb849f..6cca3c9af 100644 --- a/src/services/db/GitFileSystemService.ts +++ b/src/services/db/GitFileSystemService.ts @@ -32,12 +32,14 @@ import { MediaTypeError } from "@root/errors/MediaTypeError" import { MediaFileOutput } from "@root/types" import { GitHubCommitData } from "@root/types/commitData" import type { + DirectoryContents, GitCommitResult, GitDirectoryItem, GitFile, } from "@root/types/gitfilesystem" import type { IsomerCommitMessage } from "@root/types/github" import { ALLOWED_FILE_EXTENSIONS } from "@root/utils/file-upload-utils" +import { getPaginatedDirectoryContents } from "@root/utils/files" export default class GitFileSystemService { private readonly git: SimpleGit @@ -900,7 +902,7 @@ export default class GitFileSystemService { mediaUrl: `${dataUrlPrefix},${file.content}`, mediaPath: `${directoryName}/${fileName}`, type: fileType, - addedTime: stats.birthtimeMs, + addedTime: stats.ctimeMs, size: stats.size, }) }) @@ -914,6 +916,7 @@ export default class GitFileSystemService { ): ResultAsync { const efsVolPath = this.getEfsVolPathFromBranch(branchName) const isStaging = this.isStagingFromBranchName(branchName) + return this.getFilePathStats( repoName, directoryPath, @@ -952,38 +955,86 @@ export default class GitFileSystemService { const path = directoryPath === "" ? name : `${directoryPath}/${name}` const type = isDirectory ? "dir" : "file" - return this.getGitBlobHash(repoName, path, isStaging) - .orElse(() => okAsync("")) - .andThen((sha) => - ResultAsync.combine([ - okAsync(sha), - this.getFilePathStats( - repoName, - path, - branchName !== STAGING_LITE_BRANCH - ), - ]) - ) - .andThen((shaAndStats) => { - const [sha, stats] = shaAndStats + return this.getFilePathStats(repoName, path, isStaging).andThen( + (stats) => { const result: GitDirectoryItem = { name, type, - sha, path, size: type === "dir" ? 0 : stats.size, - addedTime: stats.birthtimeMs, + addedTime: stats.ctimeMs, } return okAsync(result) - }) + } + ) }) return ResultAsync.combine(resultAsyncs) }) - .andThen((directoryItems) => + } + + listPaginatedDirectoryContents( + repoName: string, + directoryPath: string, + branchName: string, + page = 0, + limit = 0, + search = "" + ): ResultAsync { + const isStaging = this.isStagingFromBranchName(branchName) + + return this.listDirectoryContents(repoName, directoryPath, branchName) + .andThen((directoryContents) => + okAsync( + getPaginatedDirectoryContents(directoryContents, page, limit, search) + ) + ) + .andThen((paginatedDirectoryContents) => { + const directories = paginatedDirectoryContents.directories.map( + (directory) => + this.getGitBlobHash(repoName, directory.path, isStaging) + .orElse(() => okAsync("")) + .andThen((sha) => { + const result: GitDirectoryItem = { + ...directory, + sha, + } + + return okAsync(result) + }) + ) + + const files = paginatedDirectoryContents.files.map((file) => + this.getGitBlobHash(repoName, file.path, isStaging) + .orElse(() => okAsync("")) + .andThen((sha) => { + const result: GitDirectoryItem = { + ...file, + sha, + } + + return okAsync(result) + }) + ) + + return ResultAsync.combine([ + ResultAsync.combine(directories), + ResultAsync.combine(files), + okAsync(paginatedDirectoryContents.total), + ]) + }) + .andThen(([directories, files, total]) => // Note: The sha is empty if the file is not tracked by Git - okAsync(directoryItems.filter((item) => item.sha !== "")) + // This may result in the number of files being less than the requested + // limit (if limit is greater than 0), but the trade-off is acceptable + // here because the user can use pagination to get the next set of files, + // which is guaranteed to be a fresh set of files + okAsync({ + directories: directories.filter((directory) => directory.sha !== ""), + files: files.filter((file) => file.sha !== ""), + total, + }) ) } diff --git a/src/services/db/RepoService.ts b/src/services/db/RepoService.ts index eacd93f07..15ed2ac6f 100644 --- a/src/services/db/RepoService.ts +++ b/src/services/db/RepoService.ts @@ -10,12 +10,14 @@ import UserWithSiteSessionData from "@root/classes/UserWithSiteSessionData" import { FEATURE_FLAGS, STAGING_BRANCH } from "@root/constants" import { GitHubCommitData } from "@root/types/commitData" import type { + DirectoryContents, GitCommitResult, GitDirectoryItem, GitFile, } from "@root/types/gitfilesystem" import { RawGitTreeEntry } from "@root/types/github" import { MediaDirOutput, MediaFileOutput, MediaType } from "@root/types/media" +import { getPaginatedDirectoryContents } from "@root/utils/files" import { getMediaFileInfo } from "@root/utils/media-utils" import GitFileCommitService from "./GitFileCommitService" @@ -23,54 +25,8 @@ import GitFileSystemService from "./GitFileSystemService" import GitHubService from "./GitHubService" import * as ReviewApi from "./review" -const PLACEHOLDER_FILE_NAME = ".keep" const BRANCH_REF = config.get("github.branchRef") -const getPaginatedDirectoryContents = ( - directoryContents: GitDirectoryItem[], - page: number, - limit = 15, - search = "" -): { - directories: GitDirectoryItem[] - files: GitDirectoryItem[] - total: number -} => { - const subdirectories = directoryContents.filter((item) => item.type === "dir") - const files = directoryContents.filter( - (item) => item.type === "file" && item.name !== PLACEHOLDER_FILE_NAME - ) - - let sortedFiles = _(files) - // Note: We are sorting by name here to maintain compatibility for - // GitHub-login users, since it is very expensive to get the addedTime for - // each file from the GitHub API. The files will be sorted by addedTime in - // milliseconds for GGS users, so they will never see the alphabetical - // sorting. - .orderBy( - [(file) => file.addedTime, (file) => file.name.toLowerCase()], - ["desc", "asc"] - ) - - if (search) { - sortedFiles = sortedFiles.filter((file) => - file.name.toLowerCase().includes(search.toLowerCase()) - ) - } - const totalLength = sortedFiles.value().length - - const paginatedFiles = sortedFiles - .drop(page * limit) - .take(limit) - .value() - - return { - directories: subdirectories, - files: paginatedFiles, - total: totalLength, - } -} - // TODO: update the typings here to remove `any`. // We can type as `unknown` if required. @@ -343,7 +299,7 @@ export default class RepoService extends GitHubService { const { siteName } = sessionData const defaultBranch = STAGING_BRANCH logger.debug(`Reading media directory: ${directoryName}`) - let dirContent: GitDirectoryItem[] = [] + let dirContent: DirectoryContents if ( sessionData.growthbook?.getFeatureValue( @@ -351,10 +307,13 @@ export default class RepoService extends GitHubService { false ) ) { - const result = await this.gitFileSystemService.listDirectoryContents( + const result = await this.gitFileSystemService.listPaginatedDirectoryContents( siteName, directoryName, - defaultBranch + defaultBranch, + page, + limit, + search ) if (result.isErr()) { @@ -363,17 +322,13 @@ export default class RepoService extends GitHubService { dirContent = result.value } else { - dirContent = await super.readDirectory(sessionData, { + const contents = await super.readDirectory(sessionData, { directoryName, }) + dirContent = getPaginatedDirectoryContents(contents, page, limit, search) } - const { directories, files, total } = getPaginatedDirectoryContents( - dirContent, - page, - limit, - search - ) + const { directories, files, total } = dirContent return { directories: directories.map(({ name, type }) => ({ diff --git a/src/services/db/__tests__/GitFileSystemService.spec.ts b/src/services/db/__tests__/GitFileSystemService.spec.ts index f7fa089bf..4019734d2 100644 --- a/src/services/db/__tests__/GitFileSystemService.spec.ts +++ b/src/services/db/__tests__/GitFileSystemService.spec.ts @@ -70,19 +70,105 @@ describe("GitFileSystemService", () => { }) describe("listDirectoryContents", () => { + it("should return the contents of a directory successfully", async () => { + const expectedFakeDir: GitDirectoryItem = { + name: "fake-dir", + type: "dir", + path: "fake-dir", + size: 0, + addedTime: fs.statSync(`${EFS_VOL_PATH_STAGING}/fake-repo/fake-dir`) + .ctimeMs, + } + const expectedAnotherFakeDir: GitDirectoryItem = { + name: "another-fake-dir", + type: "dir", + path: "another-fake-dir", + size: 0, + addedTime: fs.statSync( + `${EFS_VOL_PATH_STAGING}/fake-repo/another-fake-dir` + ).ctimeMs, + } + const expectedFakeEmptyDir: GitDirectoryItem = { + name: "fake-empty-dir", + type: "dir", + path: "fake-empty-dir", + size: 0, + addedTime: fs.statSync( + `${EFS_VOL_PATH_STAGING}/fake-repo/fake-empty-dir` + ).ctimeMs, + } + const expectedAnotherFakeFile: GitDirectoryItem = { + name: "another-fake-file", + type: "file", + path: "another-fake-file", + size: "Another fake content".length, + addedTime: fs.statSync( + `${EFS_VOL_PATH_STAGING}/fake-repo/another-fake-file` + ).ctimeMs, + } + + const result = await GitFileSystemService.listDirectoryContents( + "fake-repo", + "", + DEFAULT_BRANCH + ) + const actual = result + ._unsafeUnwrap() + .sort((a, b) => a.name.localeCompare(b.name)) + + expect(actual).toMatchObject([ + expectedAnotherFakeDir, + expectedAnotherFakeFile, + expectedFakeDir, + expectedFakeEmptyDir, + ]) + }) + + it("should return an empty result if the directory is empty", async () => { + const result = await GitFileSystemService.listDirectoryContents( + "fake-repo", + "fake-empty-dir", + DEFAULT_BRANCH + ) + + expect(result._unsafeUnwrap()).toHaveLength(0) + }) + + it("should return a GitFileSystemError if the path is not a directory", async () => { + const result = await GitFileSystemService.listDirectoryContents( + "fake-repo", + "fake-dir/fake-file", + DEFAULT_BRANCH + ) + + expect(result._unsafeUnwrapErr()).toBeInstanceOf(GitFileSystemError) + }) + + it("should return a NotFoundError if the path does not exist", async () => { + const result = await GitFileSystemService.listDirectoryContents( + "fake-repo", + "non-existent-dir", + DEFAULT_BRANCH + ) + + expect(result._unsafeUnwrapErr()).toBeInstanceOf(NotFoundError) + }) + }) + + describe("listPaginatedDirectoryContents", () => { it("should return the contents of a directory successfully", async () => { MockSimpleGit.cwd.mockReturnValueOnce({ revparse: jest.fn().mockResolvedValueOnce("another-fake-dir-hash"), }) - MockSimpleGit.cwd.mockReturnValueOnce({ - revparse: jest.fn().mockResolvedValueOnce("another-fake-file-hash"), - }) MockSimpleGit.cwd.mockReturnValueOnce({ revparse: jest.fn().mockResolvedValueOnce("fake-dir-hash"), }) MockSimpleGit.cwd.mockReturnValueOnce({ revparse: jest.fn().mockResolvedValueOnce("fake-empty-dir-hash"), }) + MockSimpleGit.cwd.mockReturnValueOnce({ + revparse: jest.fn().mockResolvedValueOnce("another-fake-file-hash"), + }) const expectedFakeDir: GitDirectoryItem = { name: "fake-dir", @@ -124,14 +210,15 @@ describe("GitFileSystemService", () => { ).ctimeMs, } - const result = await GitFileSystemService.listDirectoryContents( + const result = await GitFileSystemService.listPaginatedDirectoryContents( "fake-repo", "", DEFAULT_BRANCH ) - const actual = result - ._unsafeUnwrap() - .sort((a, b) => a.name.localeCompare(b.name)) + const actual = [ + ...result._unsafeUnwrap().directories, + ...result._unsafeUnwrap().files, + ].sort((a, b) => a.name.localeCompare(b.name)) expect(actual).toMatchObject([ expectedAnotherFakeDir, @@ -146,20 +233,20 @@ describe("GitFileSystemService", () => { revparse: jest.fn().mockRejectedValueOnce(new GitError()), }) MockSimpleGit.cwd.mockReturnValueOnce({ - revparse: jest.fn().mockResolvedValueOnce("another-fake-file-hash"), + revparse: jest.fn().mockRejectedValueOnce(new GitError()), }) MockSimpleGit.cwd.mockReturnValueOnce({ - revparse: jest.fn().mockResolvedValueOnce("fake-dir-hash"), + revparse: jest.fn().mockResolvedValueOnce("fake-empty-dir-hash"), }) MockSimpleGit.cwd.mockReturnValueOnce({ - revparse: jest.fn().mockRejectedValueOnce(new GitError()), + revparse: jest.fn().mockResolvedValueOnce("another-fake-file-hash"), }) const expectedFakeDir: GitDirectoryItem = { - name: "fake-dir", + name: "fake-empty-dir", type: "dir", - sha: "fake-dir-hash", - path: "fake-dir", + sha: "fake-empty-dir-hash", + path: "fake-empty-dir", size: 0, addedTime: fs.statSync(`${EFS_VOL_PATH_STAGING}/fake-repo/fake-dir`) .ctimeMs, @@ -175,15 +262,16 @@ describe("GitFileSystemService", () => { ).ctimeMs, } - const result = await GitFileSystemService.listDirectoryContents( + const result = await GitFileSystemService.listPaginatedDirectoryContents( "fake-repo", "", DEFAULT_BRANCH ) - const actual = result - ._unsafeUnwrap() - .sort((a, b) => a.name.localeCompare(b.name)) + const actual = [ + ...result._unsafeUnwrap().directories, + ...result._unsafeUnwrap().files, + ].sort((a, b) => a.name.localeCompare(b.name)) expect(actual).toMatchObject([expectedAnotherFakeFile, expectedFakeDir]) }) @@ -202,27 +290,37 @@ describe("GitFileSystemService", () => { revparse: jest.fn().mockRejectedValueOnce(new GitError()), }) - const actual = await GitFileSystemService.listDirectoryContents( + const result = await GitFileSystemService.listPaginatedDirectoryContents( "fake-repo", "", DEFAULT_BRANCH ) - expect(actual._unsafeUnwrap()).toHaveLength(0) + const actual = [ + ...result._unsafeUnwrap().directories, + ...result._unsafeUnwrap().files, + ] + + expect(actual).toHaveLength(0) }) it("should return an empty result if the directory is empty", async () => { - const actual = await GitFileSystemService.listDirectoryContents( + const result = await GitFileSystemService.listPaginatedDirectoryContents( "fake-repo", "fake-empty-dir", DEFAULT_BRANCH ) - expect(actual._unsafeUnwrap()).toHaveLength(0) + const actual = [ + ...result._unsafeUnwrap().directories, + ...result._unsafeUnwrap().files, + ] + + expect(actual).toHaveLength(0) }) it("should return a GitFileSystemError if the path is not a directory", async () => { - const result = await GitFileSystemService.listDirectoryContents( + const result = await GitFileSystemService.listPaginatedDirectoryContents( "fake-repo", "fake-dir/fake-file", DEFAULT_BRANCH @@ -232,7 +330,7 @@ describe("GitFileSystemService", () => { }) it("should return a NotFoundError if the path does not exist", async () => { - const result = await GitFileSystemService.listDirectoryContents( + const result = await GitFileSystemService.listPaginatedDirectoryContents( "fake-repo", "non-existent-dir", DEFAULT_BRANCH diff --git a/src/services/db/__tests__/RepoService.spec.ts b/src/services/db/__tests__/RepoService.spec.ts index 19f8b9fb7..92e8441dd 100644 --- a/src/services/db/__tests__/RepoService.spec.ts +++ b/src/services/db/__tests__/RepoService.spec.ts @@ -11,7 +11,7 @@ import { mockUserWithSiteSessionDataAndGrowthBook, } from "@fixtures/sessionData" import UserWithSiteSessionData from "@root/classes/UserWithSiteSessionData" -import { ItemType, MediaFileOutput } from "@root/types" +import { ItemType, MediaDirOutput, MediaFileOutput } from "@root/types" import { GitHubCommitData } from "@root/types/commitData" import { GitCommitResult, @@ -38,6 +38,7 @@ const MockGitFileSystemService = { readMediaFile: jest.fn(), create: jest.fn(), listDirectoryContents: jest.fn(), + listPaginatedDirectoryContents: jest.fn(), push: jest.fn(), update: jest.fn(), delete: jest.fn(), @@ -333,6 +334,14 @@ describe("RepoService", () => { describe("readDirectory", () => { it("should read from the local Git file system if the repo is ggs enabled", async () => { const expected: GitDirectoryItem[] = [ + { + name: "fake-dir", + type: "dir", + sha: "test-sha3", + path: "fake-dir", + size: 0, + addedTime: 1, + }, { name: "fake-file.md", type: "file", @@ -349,14 +358,6 @@ describe("RepoService", () => { size: 100, addedTime: 2, }, - { - name: "fake-dir", - type: "dir", - sha: "test-sha3", - path: "fake-dir", - size: 0, - addedTime: 1, - }, ] gbSpy.mockReturnValueOnce(true) MockGitFileSystemService.listDirectoryContents.mockResolvedValueOnce( @@ -421,123 +422,78 @@ describe("RepoService", () => { }) }) - //! TODO: fix this test, commented out for now as code changes did not change this method - // describe("readMediaDirectory", () => { - // it("should return an array of files and directories from disk if repo is ggs enabled", async () => { - // const image: MediaFileOutput = { - // name: "image-name", - // sha: "test-sha", - // mediaUrl: "base64ofimage", - // mediaPath: "images/image-name.jpg", - // type: "file", - // } - // const dir: MediaDirOutput = { - // name: "imageDir", - // type: "dir", - // } - // const expected = [image, dir] - // MockGitFileSystemService.listDirectoryContents.mockResolvedValueOnce( - // okAsync([ - // { - // name: "image-name", - // }, - // { - // name: "imageDir", - // type: "dir", - // sha: "test-sha", - // path: "images/imageDir", - // }, - // { - // name: ".keep", - // type: "file", - // sha: "test-sha", - // path: "images/.keep", - // }, - // ]) - // ) - // MockGitFileSystemService.readMediaFile.mockResolvedValueOnce( - // okAsync(expected) - // ) - - // const actual = await RepoService.readMediaDirectory( - // mockUserWithSiteSessionDataAndGrowthBook, - // "images" - // ) - - // expect(actual).toEqual(expected) - // }) - - // it("should return an array of files and directories from GitHub if repo is not ggs enabled", async () => { - // const sessionData: UserWithSiteSessionData = new UserWithSiteSessionData({ - // githubId: mockGithubId, - // accessToken: mockAccessToken, - // isomerUserId: mockIsomerUserId, - // email: mockEmail, - // siteName: "not-whitelisted", - // }) - - // const directories: MediaDirOutput[] = [ - // { - // name: "imageDir", - // type: "dir", - // }, - // ] - - // const files: Pick[] = [ - // { - // name: "image-name", - // }, - // ] - // const expected = { directories, files, total: 1 } - - // // const image: MediaFileOutput = { - // // name: "image-name", - // // sha: "test-sha", - // // mediaUrl: "base64ofimage", - // // mediaPath: "images/image-name.jpg", - // // type: "file", - // // } - // // const dir: MediaDirOutput = { - // // name: "imageDir", - // // type: "dir", - // // } - // // const expected = [image, dir] - - // const gitHubServiceGetRepoInfo = jest - // .spyOn(GitHubService.prototype, "getRepoInfo") - // .mockResolvedValueOnce({ private: false }) - // const gitHubServiceReadDirectory = jest - // .spyOn(GitHubService.prototype, "readDirectory") - // .mockResolvedValueOnce([ - // { - // name: "image-name", - // }, - // { - // name: "imageDir", - // type: "dir", - // sha: "test-sha", - // path: "images/imageDir", - // }, - // { - // name: ".keep", - // type: "file", - // sha: "test-sha", - // path: "images/.keep", - // }, - // ]) - - // // const repoServiceReadMediaFile = jest - // // .spyOn(_RepoService.prototype, "readMediaFile") - // // .mockResolvedValueOnce(expected) - - // const actual = await RepoService.readMediaDirectory(sessionData, "images") - - // expect(actual).toEqual(expected) - // expect(gitHubServiceGetRepoInfo).toBeCalledTimes(1) - // expect(gitHubServiceReadDirectory).toBeCalledTimes(1) - // // expect(repoServiceReadMediaFile).toBeCalledTimes(1) - // }) - // }) + describe("readMediaDirectory", () => { + it("should return an array of files and directories from disk if repo is ggs enabled", async () => { + const testDir: MediaDirOutput = { + name: "imageDir", + type: "dir", + } + const testFile: MediaFileOutput = { + name: "image-name", + sha: "test-sha", + mediaUrl: "base64ofimage", + mediaPath: "images/image-name.jpg", + type: "file", + addedTime: 0, + size: 0, + } + const expected = { + directories: [testDir], + files: [testFile], + total: 1, + } + MockGitFileSystemService.listPaginatedDirectoryContents.mockResolvedValueOnce( + okAsync(expected) + ) + + const actual = await RepoService.readMediaDirectory( + mockUserWithSiteSessionDataAndGrowthBook, + "images" + ) + + expect(actual).toEqual(expected) + }) + + it("should return an array of files and directories from GitHub if repo is not ggs enabled", async () => { + const sessionData: UserWithSiteSessionData = new UserWithSiteSessionData({ + githubId: mockGithubId, + accessToken: mockAccessToken, + isomerUserId: mockIsomerUserId, + email: mockEmail, + siteName: "not-whitelisted", + }) + + const testDirectory: MediaDirOutput = { + name: "imageDir", + type: "dir", + } + + const testFile: MediaFileOutput = { + name: "image-name", + sha: "test-sha", + mediaUrl: "base64ofimage", + mediaPath: "images/image-name.jpg", + type: "file", + addedTime: 0, + size: 0, + } + + const expected = { + directories: [testDirectory], + files: [testFile], + total: 1, + } + + const gitHubServiceReadDirectory = jest + .spyOn(GitHubService.prototype, "readDirectory") + .mockResolvedValueOnce([testDirectory, testFile]) + + const actual = await RepoService.readMediaDirectory(sessionData, "images") + + expect(actual).toEqual(expected) + expect(gitHubServiceReadDirectory).toBeCalledTimes(1) + }) + }) describe("update", () => { it("should update the local Git file system if the repo is ggs enabled", async () => { @@ -629,205 +585,195 @@ describe("RepoService", () => { directoryName: "pages", }) }) + }) - describe("renameSinglePath", () => { - it("should rename using the local Git file system if the repo is ggs enabled", async () => { - const expected: GitCommitResult = { newSha: "fake-commit-sha" } - MockGitFileCommitService.renameSinglePath.mockResolvedValueOnce( - expected - ) - gbSpy.mockReturnValueOnce(true) - - const actual = await RepoService.renameSinglePath( - mockUserWithSiteSessionDataAndGrowthBook, - mockGithubSessionData, - "fake-old-path", - "fake-new-path", - "fake-commit-message" - ) - - expect(actual).toEqual(expected) - }) + describe("renameSinglePath", () => { + it("should rename using the local Git file system if the repo is ggs enabled", async () => { + const expected: GitCommitResult = { newSha: "fake-commit-sha" } + MockGitFileCommitService.renameSinglePath.mockResolvedValueOnce(expected) + gbSpy.mockReturnValueOnce(true) - it("should rename file using GitHub directly if the repo is not ggs enabled", async () => { - const expectedSha = "fake-commit-sha" - const fakeCommitMessage = "fake-commit-message" - const sessionData: UserWithSiteSessionData = new UserWithSiteSessionData( - { - githubId: mockGithubId, - accessToken: mockAccessToken, - isomerUserId: mockIsomerUserId, - email: mockEmail, - siteName: "not-whitelisted", - } - ) - - const gitHubServiceRenameSinglePath = jest.spyOn( - GitHubService.prototype, - "renameSinglePath" - ) - gitHubServiceRenameSinglePath.mockResolvedValueOnce({ - newSha: expectedSha, - }) - - const actual = await RepoService.renameSinglePath( - sessionData, - mockGithubSessionData, - "fake-path/old-fake-file.md", - "fake-path/new-fake-file.md", - fakeCommitMessage - ) - - expect(actual).toEqual({ newSha: expectedSha }) - }) + const actual = await RepoService.renameSinglePath( + mockUserWithSiteSessionDataAndGrowthBook, + mockGithubSessionData, + "fake-old-path", + "fake-new-path", + "fake-commit-message" + ) + + expect(actual).toEqual(expected) }) - describe("moveFiles", () => { - it("should move files using the Git local file system if the repo is ggs enabled", async () => { - const expected = { newSha: "fake-commit-sha" } - MockGitFileCommitService.moveFiles.mockResolvedValueOnce(expected) - gbSpy.mockReturnValueOnce(true) - // MockCommitServiceGitFile.push.mockReturnValueOnce(undefined) - - const actual = await RepoService.moveFiles( - mockUserWithSiteSessionDataAndGrowthBook, - mockGithubSessionData, - "fake-old-path", - "fake-new-path", - ["fake-file1", "fake-file2"], - "fake-commit-message" - ) - - expect(actual).toEqual(expected) + it("should rename file using GitHub directly if the repo is not ggs enabled", async () => { + const expectedSha = "fake-commit-sha" + const fakeCommitMessage = "fake-commit-message" + const sessionData: UserWithSiteSessionData = new UserWithSiteSessionData({ + githubId: mockGithubId, + accessToken: mockAccessToken, + isomerUserId: mockIsomerUserId, + email: mockEmail, + siteName: "not-whitelisted", }) - it("should move files using GitHub directly if the repo is not ggs enabled", async () => { - const expected = { newSha: "fake-commit-sha" } - const fakeCommitMessage = "fake-commit-message" - const sessionData: UserWithSiteSessionData = new UserWithSiteSessionData( - { - githubId: mockGithubId, - accessToken: mockAccessToken, - isomerUserId: mockIsomerUserId, - email: mockEmail, - siteName: "not-whitelisted", - } - ) - - const gitHubServiceMoveFiles = jest.spyOn( - GitHubService.prototype, - "moveFiles" - ) - gitHubServiceMoveFiles.mockResolvedValueOnce(expected) - - const actual = await RepoService.moveFiles( - sessionData, - mockGithubSessionData, - "fake-path", - "fake-new-path", - ["old-fake-file.md", "old-fake-file-two.md"], - fakeCommitMessage - ) - - expect(actual).toEqual(expected) + const gitHubServiceRenameSinglePath = jest.spyOn( + GitHubService.prototype, + "renameSinglePath" + ) + gitHubServiceRenameSinglePath.mockResolvedValueOnce({ + newSha: expectedSha, }) + + const actual = await RepoService.renameSinglePath( + sessionData, + mockGithubSessionData, + "fake-path/old-fake-file.md", + "fake-path/new-fake-file.md", + fakeCommitMessage + ) + + expect(actual).toEqual({ newSha: expectedSha }) }) + }) - describe("getLatestCommitOfBranch", () => { - it("should read the latest commit data from the local Git file system if the repo is ggs enabled", async () => { - const expected: GitHubCommitData = { - author: { - name: "test author", - email: "test@email.com", - date: "2023-07-20T11:25:05+08:00", - }, - sha: "test-sha", - message: "test message", - } - gbSpy.mockReturnValueOnce(true) - MockGitFileSystemService.getLatestCommitOfBranch.mockResolvedValueOnce( - okAsync(expected) - ) - - const actual = await RepoService.getLatestCommitOfBranch( - mockUserWithSiteSessionDataAndGrowthBook, - "master" - ) - expect(actual).toEqual(expected) - }) + describe("moveFiles", () => { + it("should move files using the Git local file system if the repo is ggs enabled", async () => { + const expected = { newSha: "fake-commit-sha" } + MockGitFileCommitService.moveFiles.mockResolvedValueOnce(expected) + gbSpy.mockReturnValueOnce(true) + // MockCommitServiceGitFile.push.mockReturnValueOnce(undefined) - it("should read latest commit data from GitHub if the repo is not ggs enabled", async () => { - const sessionData: UserWithSiteSessionData = new UserWithSiteSessionData( - { - githubId: mockGithubId, - accessToken: mockAccessToken, - isomerUserId: mockIsomerUserId, - email: mockEmail, - siteName: "not-whitelisted", - } - ) - const expected: GitHubCommitData = { - author: { - name: "test author", - email: "test@email.com", - date: "2023-07-20T11:25:05+08:00", - }, - message: "test message", - } - const gitHubServiceReadDirectory = jest.spyOn( - GitHubService.prototype, - "getLatestCommitOfBranch" - ) - gitHubServiceReadDirectory.mockResolvedValueOnce(expected) - const actual = await RepoService.getLatestCommitOfBranch( - sessionData, - "master" - ) - expect(actual).toEqual(expected) + const actual = await RepoService.moveFiles( + mockUserWithSiteSessionDataAndGrowthBook, + mockGithubSessionData, + "fake-old-path", + "fake-new-path", + ["fake-file1", "fake-file2"], + "fake-commit-message" + ) + + expect(actual).toEqual(expected) + }) + + it("should move files using GitHub directly if the repo is not ggs enabled", async () => { + const expected = { newSha: "fake-commit-sha" } + const fakeCommitMessage = "fake-commit-message" + const sessionData: UserWithSiteSessionData = new UserWithSiteSessionData({ + githubId: mockGithubId, + accessToken: mockAccessToken, + isomerUserId: mockIsomerUserId, + email: mockEmail, + siteName: "not-whitelisted", }) + + const gitHubServiceMoveFiles = jest.spyOn( + GitHubService.prototype, + "moveFiles" + ) + gitHubServiceMoveFiles.mockResolvedValueOnce(expected) + + const actual = await RepoService.moveFiles( + sessionData, + mockGithubSessionData, + "fake-path", + "fake-new-path", + ["old-fake-file.md", "old-fake-file-two.md"], + fakeCommitMessage + ) + + expect(actual).toEqual(expected) + }) + }) + + describe("getLatestCommitOfBranch", () => { + it("should read the latest commit data from the local Git file system if the repo is ggs enabled", async () => { + const expected: GitHubCommitData = { + author: { + name: "test author", + email: "test@email.com", + date: "2023-07-20T11:25:05+08:00", + }, + sha: "test-sha", + message: "test message", + } + gbSpy.mockReturnValueOnce(true) + MockGitFileSystemService.getLatestCommitOfBranch.mockResolvedValueOnce( + okAsync(expected) + ) + + const actual = await RepoService.getLatestCommitOfBranch( + mockUserWithSiteSessionDataAndGrowthBook, + "master" + ) + expect(actual).toEqual(expected) }) - describe("updateRepoState", () => { - it("should update the repo state on the local Git file system if the repo is ggs enabled", async () => { - MockGitFileSystemService.updateRepoState.mockResolvedValueOnce( - okAsync(undefined) - ) - gbSpy.mockReturnValueOnce(true) - - await RepoService.updateRepoState( - mockUserWithSiteSessionDataAndGrowthBook, - { - commitSha: "fake-sha", - branchName: "master", - } - ) - - expect(MockGitFileSystemService.updateRepoState).toBeCalledTimes(1) + it("should read latest commit data from GitHub if the repo is not ggs enabled", async () => { + const sessionData: UserWithSiteSessionData = new UserWithSiteSessionData({ + githubId: mockGithubId, + accessToken: mockAccessToken, + isomerUserId: mockIsomerUserId, + email: mockEmail, + siteName: "not-whitelisted", }) + const expected: GitHubCommitData = { + author: { + name: "test author", + email: "test@email.com", + date: "2023-07-20T11:25:05+08:00", + }, + message: "test message", + } + const gitHubServiceReadDirectory = jest.spyOn( + GitHubService.prototype, + "getLatestCommitOfBranch" + ) + gitHubServiceReadDirectory.mockResolvedValueOnce(expected) + const actual = await RepoService.getLatestCommitOfBranch( + sessionData, + "master" + ) + expect(actual).toEqual(expected) + }) + }) - it("should update the repo state on GitHub if the repo is not ggs enabled", async () => { - const sessionData: UserWithSiteSessionData = new UserWithSiteSessionData( - { - githubId: mockGithubId, - accessToken: mockAccessToken, - isomerUserId: mockIsomerUserId, - email: mockEmail, - siteName: "not-whitelisted", - } - ) - const gitHubServiceUpdateRepoState = jest.spyOn( - GitHubService.prototype, - "updateRepoState" - ) - gitHubServiceUpdateRepoState.mockResolvedValueOnce(undefined) - - await RepoService.updateRepoState(sessionData, { + describe("updateRepoState", () => { + it("should update the repo state on the local Git file system if the repo is ggs enabled", async () => { + MockGitFileSystemService.updateRepoState.mockResolvedValueOnce( + okAsync(undefined) + ) + gbSpy.mockReturnValueOnce(true) + + await RepoService.updateRepoState( + mockUserWithSiteSessionDataAndGrowthBook, + { commitSha: "fake-sha", branchName: "master", - }) + } + ) + + expect(MockGitFileSystemService.updateRepoState).toBeCalledTimes(1) + }) + + it("should update the repo state on GitHub if the repo is not ggs enabled", async () => { + const sessionData: UserWithSiteSessionData = new UserWithSiteSessionData({ + githubId: mockGithubId, + accessToken: mockAccessToken, + isomerUserId: mockIsomerUserId, + email: mockEmail, + siteName: "not-whitelisted", + }) + const gitHubServiceUpdateRepoState = jest.spyOn( + GitHubService.prototype, + "updateRepoState" + ) + gitHubServiceUpdateRepoState.mockResolvedValueOnce(undefined) - expect(gitHubServiceUpdateRepoState).toBeCalledTimes(1) + await RepoService.updateRepoState(sessionData, { + commitSha: "fake-sha", + branchName: "master", }) + + expect(gitHubServiceUpdateRepoState).toBeCalledTimes(1) }) }) }) diff --git a/src/types/gitfilesystem.ts b/src/types/gitfilesystem.ts index 1a33359e7..14dc47ac9 100644 --- a/src/types/gitfilesystem.ts +++ b/src/types/gitfilesystem.ts @@ -10,8 +10,14 @@ export type GitCommitResult = { export type GitDirectoryItem = { name: string type: "file" | "dir" - sha: string + sha?: string path: string size: number addedTime: number } + +export type DirectoryContents = { + directories: GitDirectoryItem[] + files: GitDirectoryItem[] + total: number +} diff --git a/src/utils/files.ts b/src/utils/files.ts index f6fb04f71..5c53007c9 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -1,6 +1,12 @@ +import _ from "lodash" import { Result, err, ok } from "neverthrow" +import { GIT_SYSTEM_DIRECTORY, PLACEHOLDER_FILE_NAME } from "@root/constants" import EmptyStringError from "@root/errors/EmptyStringError" +import type { + DirectoryContents, + GitDirectoryItem, +} from "@root/types/gitfilesystem" import { PathInfo } from "@root/types/util" export const getFileExt = (fileName: string): string => @@ -34,3 +40,49 @@ export const extractPathInfo = ( __kind: "PathInfo", }) } + +export const getPaginatedDirectoryContents = ( + directoryContents: GitDirectoryItem[], + page: number, + limit = 15, + search = "" +): DirectoryContents => { + const subdirectories = directoryContents.filter( + (item) => item.type === "dir" && item.name !== GIT_SYSTEM_DIRECTORY + ) + const files = directoryContents.filter( + (item) => item.type === "file" && item.name !== PLACEHOLDER_FILE_NAME + ) + + let sortedFiles = _(files) + // Note: We are sorting by name here to maintain compatibility for + // GitHub-login users, since it is very expensive to get the addedTime for + // each file from the GitHub API. The files will be sorted by addedTime in + // milliseconds for GGS users, so they will never see the alphabetical + // sorting. + .orderBy( + [(file) => file.addedTime, (file) => file.name.toLowerCase()], + ["desc", "asc"] + ) + + if (search) { + sortedFiles = sortedFiles.filter((file) => + file.name.toLowerCase().includes(search.toLowerCase()) + ) + } + const totalLength = sortedFiles.value().length + + const paginatedFiles = + limit === 0 + ? sortedFiles.value() + : sortedFiles + .drop(page * limit) + .take(limit) + .value() + + return { + directories: subdirectories, + files: paginatedFiles, + total: totalLength, + } +}