diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index 12438c76..0afe97b7 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -1,29 +1,5 @@ import { GitHubProjectDataSource } from "@/features/projects/data" -import RemoteConfig from "@/features/projects/domain/RemoteConfig" - -/** - * Simple encryption service for testing. Does nothing. - */ -const noopEncryptionService = { - encrypt: function (data: string): string { - return data - }, - decrypt: function (encryptedDataBase64: string): string { - return encryptedDataBase64 - } -} - -/** - * Simple encoder for testing - */ -const base64RemoteConfigEncoder = { - encode: function (remoteConfig: RemoteConfig): string { - return Buffer.from(JSON.stringify(remoteConfig)).toString("base64") - }, - decode: function (encodedString: string): RemoteConfig { - return JSON.parse(Buffer.from(encodedString, "base64").toString()) - } -} +import { noopEncryptionService, base64RemoteConfigEncoder } from "./testUtils" test("It loads repositories from data source", async () => { let didLoadRepositories = false @@ -42,7 +18,7 @@ test("It loads repositories from data source", async () => { expect(didLoadRepositories).toBeTruthy() }) -test("It maps projects including branches and tags", async () => { +test("It generates GitHub-specific URLs", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", repositoryDataSource: { @@ -51,537 +27,21 @@ test("It maps projects including branches and tags", async () => { owner: "acme", name: "foo-openapi", defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml" - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects).toEqual([{ - id: "acme-foo", - name: "foo", - displayName: "foo", - url: "https://github.com/acme/foo-openapi", - versions: [{ - id: "main", - name: "main", - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/openapi.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/main", - isDefault: true - }, { - id: "1.0", - name: "1.0", - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/1.0/openapi.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/1.0", - isDefault: false - }], - owner: "acme", - ownerUrl: "https://github.com/acme" - }]) -}) - -test("It removes suffix from project name", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml" - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].id).toEqual("acme-foo") - expect(projects[0].name).toEqual("foo") - expect(projects[0].displayName).toEqual("foo") -}) - -test("It supports multiple OpenAPI specifications on a branch", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "foo-service.yml", - }, { - name: "bar-service.yml", - }, { - name: "baz-service.yml", - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects).toEqual([{ - id: "acme-foo", - name: "foo", - displayName: "foo", - url: "https://github.com/acme/foo-openapi", - versions: [{ - id: "main", - name: "main", - specifications: [{ - id: "bar-service.yml", - name: "bar-service.yml", - url: "/api/blob/acme/foo-openapi/bar-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/bar-service.yml", - isDefault: false - }, { - id: "baz-service.yml", - name: "baz-service.yml", - url: "/api/blob/acme/foo-openapi/baz-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/baz-service.yml", - isDefault: false - }, - { - id: "foo-service.yml", - name: "foo-service.yml", - url: "/api/blob/acme/foo-openapi/foo-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/foo-service.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/main", - isDefault: true - }, { - id: "1.0", - name: "1.0", - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/1.0/openapi.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/1.0", - isDefault: false - }], - owner: "acme", - ownerUrl: "https://github.com/acme" - }]) -}) - -test("It filters away projects with no versions", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects.length).toEqual(0) -}) - -test("It filters away branches with no specifications", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "bugfix", - files: [{ - name: "README.md", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions.length).toEqual(1) -}) - -test("It filters away tags with no specifications", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "foo-service.yml", - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }, { - id: "12345678", - name: "0.1", - files: [{ - name: "README.md" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions.length).toEqual(2) -}) - -test("It reads image from configuration file with .yml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: "image: icon.png" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") -}) - -test("It reads display name from configuration file with .yml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", + id: "abc123", name: "main" }, configYml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].id).toEqual("acme-foo") - expect(projects[0].name).toEqual("foo") - expect(projects[0].displayName).toEqual("Hello World") -}) - -test("It reads image from configuration file with .yaml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { text: "image: icon.png" }, branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") -}) - -test("It reads display name from configuration file with .yaml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].id).toEqual("acme-foo") - expect(projects[0].name).toEqual("foo") - expect(projects[0].displayName).toEqual("Hello World") -}) - -test("It sorts projects alphabetically", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "cathrine-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }, { - owner: "acme", - name: "bobby-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", + id: "abc123", name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }, { - owner: "acme", - name: "anne-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].name).toEqual("anne") - expect(projects[1].name).toEqual("bobby") - expect(projects[2].name).toEqual("cathrine") -}) - -test("It sorts versions alphabetically", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "bobby", - files: [{ - name: "openapi.yml", - }] + files: [{ name: "openapi.yml" }] }], tags: [{ - id: "12345678", - name: "cathrine", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", + id: "def456", name: "1.0", - files: [{ - name: "openapi.yml", - }] + files: [{ name: "openapi.yml" }] }] }] } @@ -590,218 +50,25 @@ test("It sorts versions alphabetically", async () => { remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() - expect(projects[0].versions[0].name).toEqual("1.0") - expect(projects[0].versions[1].name).toEqual("anne") - expect(projects[0].versions[2].name).toEqual("bobby") - expect(projects[0].versions[3].name).toEqual("cathrine") -}) + const project = projects[0] -test("It prioritizes main, master, develop, and development branch names when sorting verisons", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "develop", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "development", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "master", - files: [{ - name: "openapi.yml", - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml", - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].name).toEqual("main") - expect(projects[0].versions[1].name).toEqual("master") - expect(projects[0].versions[2].name).toEqual("develop") - expect(projects[0].versions[3].name).toEqual("development") - expect(projects[0].versions[4].name).toEqual("1.0") - expect(projects[0].versions[5].name).toEqual("anne") -}) + // GitHub-specific project URLs + expect(project.url).toEqual("https://github.com/acme/foo-openapi") + expect(project.ownerUrl).toEqual("https://github.com/acme") -test("It sorts file specifications alphabetically", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "z-openapi.yml", - }, { - name: "a-openapi.yml", - }, { - name: "1-openapi.yml", - }] - }], - tags: [{ - id: "12345678", - name: "cathrine", - files: [{ - name: "o-openapi.yml", - }, { - name: "2-openapi.yml", - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].specifications[0].name).toEqual("1-openapi.yml") - expect(projects[0].versions[0].specifications[1].name).toEqual("a-openapi.yml") - expect(projects[0].versions[0].specifications[2].name).toEqual("z-openapi.yml") - expect(projects[0].versions[1].specifications[0].name).toEqual("2-openapi.yml") - expect(projects[0].versions[1].specifications[1].name).toEqual("o-openapi.yml") -}) + // GitHub-specific image URL using ref id + expect(project.imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=abc123") -test("It maintains remote version specification ordering from config", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: ` - name: Hello World - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Zac - url: https://example.com/zac.yml - - id: another-spec - name: Bob - url: https://example.com/bob.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].specifications[0].name).toEqual("Zac") - expect(projects[0].versions[0].specifications[1].name).toEqual("Bob") -}) + // GitHub-specific version URLs + expect(project.versions[0].url).toEqual("https://github.com/acme/foo-openapi/tree/main") + expect(project.versions[1].url).toEqual("https://github.com/acme/foo-openapi/tree/1.0") -test("It identifies the default branch in returned versions", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "development" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "development", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const defaultVersionNames = projects[0] - .versions - .filter(e => e.isDefault) - .map(e => e.name) - expect(defaultVersionNames).toEqual(["development"]) + // GitHub-specific specification URLs using ref id + expect(project.versions[0].specifications[0].url).toEqual("/api/blob/acme/foo-openapi/openapi.yml?ref=abc123") + expect(project.versions[0].specifications[0].editURL).toEqual("https://github.com/acme/foo-openapi/edit/main/openapi.yml") }) -test("It adds remote versions from the project configuration", async () => { +test("It generates diff URLs for changed files", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", repositoryDataSource: { @@ -810,92 +77,17 @@ test("It adds remote versions from the project configuration", async () => { owner: "acme", name: "foo-openapi", defaultBranchRef: { - id: "12345678", + id: "abc123", name: "main" }, - configYaml: { - text: ` - remoteVersions: - - name: Anne - specifications: - - name: Huey - url: https://example.com/huey.yml - - name: Dewey - url: https://example.com/dewey.yml - - name: Bobby - specifications: - - name: Louie - url: https://example.com/louie.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "anne", - name: "Anne", - isDefault: false, - specifications: [{ - id: "huey", - name: "Huey", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}`, - isDefault: false - }, { - id: "dewey", - name: "Dewey", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}`, - isDefault: false - }] - }, { - id: "bobby", - name: "Bobby", - isDefault: false, - specifications: [{ - id: "louie", - name: "Louie", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}`, - isDefault: false - }] - }]) -}) - -test("It modifies ID of remote version if the ID already exists", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - name: Bar - specifications: - - name: Baz - url: https://example.com/baz.yml - - name: Bar - specifications: - - name: Hello - url: https://example.com/hello.yml - ` - }, branches: [{ - id: "12345678", - name: "bar", - files: [{ - name: "openapi.yml" - }] + id: "head-sha", + name: "feature-branch", + baseRefOid: "base-sha", + baseRef: "main", + prNumber: 42, + files: [{ name: "openapi.yml" }], + changedFiles: ["openapi.yml"] }], tags: [] }] @@ -905,130 +97,15 @@ test("It modifies ID of remote version if the ID already exists", async () => { remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "bar", - name: "bar", - url: "https://github.com/acme/foo-openapi/tree/bar", - isDefault: true, - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/bar/openapi.yml", - isDefault: false - }] - }, { - id: "bar1", - name: "Bar", - isDefault: false, - specifications: [{ - id: "baz", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - isDefault: false - }] - }, { - id: "bar2", - name: "Bar", - isDefault: false, - specifications: [{ - id: "hello", - name: "Hello", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/hello.yml" })}`, - isDefault: false - }] - }]) -}) - -test("It lets users specify the ID of a remote version", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - id: some-version - name: Bar - specifications: - - name: Baz - url: https://example.com/baz.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "some-version", - name: "Bar", - isDefault: false, - specifications: [{ - id: "baz", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - isDefault: false - }] - }]) -}) + const spec = projects[0].versions[0].specifications[0] -test("It lets users specify the ID of a remote specification", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "bar", - name: "Bar", - isDefault: false, - specifications: [{ - id: "some-spec", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - isDefault: false - }] - }]) + expect(spec.diffURL).toEqual("/api/diff/acme/foo-openapi/openapi.yml?baseRefOid=base-sha&to=head-sha") + expect(spec.diffBaseBranch).toEqual("main") + expect(spec.diffBaseOid).toEqual("base-sha") + expect(spec.diffPrUrl).toEqual("https://github.com/acme/foo-openapi/pull/42") }) -test("It sets isDefault on the correct specification based on defaultSpecificationName in config", async () => { +test("It does not generate diff URL when baseRefOid is missing", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", repositoryDataSource: { @@ -1037,28 +114,13 @@ test("It sets isDefault on the correct specification based on defaultSpecificati owner: "acme", name: "foo-openapi", defaultBranchRef: { - id: "12345678", + id: "abc123", name: "main" }, - configYml: { - text: ` - defaultSpecificationName: bar-service.yml - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - ` - }, branches: [{ - id: "12345678", + id: "head-sha", name: "main", - files: [ - { name: "foo-service.yml" }, - { name: "bar-service.yml" }, - { name: "baz-service.yml" } - ] + files: [{ name: "openapi.yml" }] }], tags: [] }] @@ -1068,55 +130,12 @@ test("It sets isDefault on the correct specification based on defaultSpecificati remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() - const specs = projects[0].versions[0].specifications - expect(specs.find(s => s.name === "bar-service.yml")!.isDefault).toBe(true) - expect(specs.find(s => s.name === "foo-service.yml")!.isDefault).toBe(false) - expect(specs.find(s => s.name === "baz-service.yml")!.isDefault).toBe(false) - expect(projects[0].versions[1].specifications.find(s => s.name === "Baz")!.isDefault).toBe(false) -}) + const spec = projects[0].versions[0].specifications[0] -test("It sets a remote specification as the default if specified", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: ` - defaultSpecificationName: Baz - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - - id: another-spec - name: Qux - url: https://example.com/qux.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const remoteSpecs = projects[0].versions[0].specifications - expect(remoteSpecs.find(s => s.id === "some-spec")!.isDefault).toBe(true) - expect(remoteSpecs.find(s => s.id === "another-spec")!.isDefault).toBe(false) + expect(spec.diffURL).toBeUndefined() }) - -test("It sets isDefault to false for all specifications if defaultSpecificationName is not set", async () => { +test("It encodes special characters in file paths", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", repositoryDataSource: { @@ -1125,20 +144,13 @@ test("It sets isDefault to false for all specifications if defaultSpecificationN owner: "acme", name: "foo-openapi", defaultBranchRef: { - id: "12345678", + id: "abc123", name: "main" }, - configYml: { - text: `` - }, branches: [{ - id: "12345678", + id: "abc123", name: "main", - files: [ - { name: "foo-service.yml" }, - { name: "bar-service.yml" }, - { name: "baz-service.yml" } - ] + files: [{ name: "path/to/my spec.yml" }] }], tags: [] }] @@ -1148,42 +160,7 @@ test("It sets isDefault to false for all specifications if defaultSpecificationN remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() - const specs = projects[0].versions[0].specifications - expect(specs.every(s => s.isDefault === false)).toBe(true) -}) + const spec = projects[0].versions[0].specifications[0] -test("It silently ignores defaultSpecificationName if no matching spec is found", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: `defaultSpecificationName: non-existent.yml` - }, - branches: [{ - id: "12345678", - name: "main", - files: [ - { name: "foo-service.yml" }, - { name: "bar-service.yml" }, - { name: "baz-service.yml" } - ] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const specs = projects[0].versions[0].specifications - expect(specs.every(s => s.isDefault === false)).toBe(true) + expect(spec.editURL).toEqual("https://github.com/acme/foo-openapi/edit/main/path%2Fto%2Fmy%20spec.yml") }) diff --git a/__test__/projects/ProjectMapper.test.ts b/__test__/projects/ProjectMapper.test.ts new file mode 100644 index 00000000..07860930 --- /dev/null +++ b/__test__/projects/ProjectMapper.test.ts @@ -0,0 +1,487 @@ +import ProjectMapper, { type URLBuilders, type RepositoryWithRefs, type RepositoryRef } from "@/features/projects/domain/ProjectMapper" +import { noopEncryptionService, base64RemoteConfigEncoder } from "./testUtils" + +// Simple URL builders for testing - uses predictable patterns +const testURLBuilders: URLBuilders = { + getImageRef(repository: RepositoryWithRefs): string { + return repository.defaultBranchRef.id! + }, + getBlobRef(ref: RepositoryRef): string { + return ref.id! + }, + getOwnerUrl(owner: string): string { + return `https://example.com/${owner}` + }, + getProjectUrl(repository: RepositoryWithRefs): string { + return `https://example.com/${repository.owner}/${repository.name}` + }, + getVersionUrl(repository: RepositoryWithRefs, ref: RepositoryRef): string { + return `https://example.com/${repository.owner}/${repository.name}/tree/${ref.name}` + }, + getSpecEditUrl(repository: RepositoryWithRefs, ref: RepositoryRef, fileName: string): string { + return `https://example.com/${repository.owner}/${repository.name}/edit/${ref.name}/${fileName}` + } +} + +function createMapper(repositoryNameSuffix = "-openapi") { + return new ProjectMapper({ + repositoryNameSuffix, + urlBuilders: testURLBuilders, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) +} + +function createRepository(overrides: Partial = {}): RepositoryWithRefs { + return { + owner: "acme", + name: "foo-openapi", + defaultBranchRef: { id: "12345678", name: "main" }, + branches: [{ + id: "12345678", + name: "main", + files: [{ name: "openapi.yml" }] + }], + tags: [], + ...overrides + } +} + +test("It removes suffix from project name", () => { + const mapper = createMapper("-openapi") + const project = mapper.mapRepositoryToProject(createRepository()) + expect(project.id).toEqual("acme-foo") + expect(project.name).toEqual("foo") + expect(project.displayName).toEqual("foo") +}) + +test("It maps branches and tags to versions", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "12345678", + name: "main", + files: [{ name: "openapi.yml" }] + }], + tags: [{ + id: "87654321", + name: "1.0", + files: [{ name: "openapi.yml" }] + }] + })) + expect(project.versions.length).toEqual(2) + expect(project.versions.map(v => v.name)).toContain("main") + expect(project.versions.map(v => v.name)).toContain("1.0") +}) + +test("It supports multiple OpenAPI specifications on a branch", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }] + })) + expect(project.versions[0].specifications.length).toEqual(3) +}) + +test("It filters away branches with no specifications", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [ + { id: "1", name: "main", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "bugfix", files: [{ name: "README.md" }] } + ] + })) + expect(project.versions.length).toEqual(1) + expect(project.versions[0].name).toEqual("main") +}) + +test("It filters away tags with no specifications", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ id: "1", name: "main", files: [{ name: "openapi.yml" }] }], + tags: [ + { id: "2", name: "1.0", files: [{ name: "openapi.yml" }] }, + { id: "3", name: "0.1", files: [{ name: "README.md" }] } + ] + })) + expect(project.versions.length).toEqual(2) +}) + +test("It reads image from configuration file with .yml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "image: icon.png" } + })) + expect(project.imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") +}) + +test("It reads display name from configuration file with .yml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "name: Hello World" } + })) + expect(project.displayName).toEqual("Hello World") +}) + +test("It reads image from configuration file with .yaml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYaml: { text: "image: icon.png" } + })) + expect(project.imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") +}) + +test("It reads display name from configuration file with .yaml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYaml: { text: "name: Hello World" } + })) + expect(project.displayName).toEqual("Hello World") +}) + +test("It sorts versions alphabetically", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [ + { id: "1", name: "anne", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "bobby", files: [{ name: "openapi.yml" }] } + ], + tags: [ + { id: "3", name: "cathrine", files: [{ name: "openapi.yml" }] }, + { id: "4", name: "1.0", files: [{ name: "openapi.yml" }] } + ] + })) + expect(project.versions[0].name).toEqual("1.0") + expect(project.versions[1].name).toEqual("anne") + expect(project.versions[2].name).toEqual("bobby") + expect(project.versions[3].name).toEqual("cathrine") +}) + +test("It prioritizes main, master, develop, and development branch names when sorting versions", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [ + { id: "1", name: "anne", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "develop", files: [{ name: "openapi.yml" }] }, + { id: "3", name: "main", files: [{ name: "openapi.yml" }] }, + { id: "4", name: "development", files: [{ name: "openapi.yml" }] }, + { id: "5", name: "master", files: [{ name: "openapi.yml" }] } + ], + tags: [{ id: "6", name: "1.0", files: [{ name: "openapi.yml" }] }] + })) + expect(project.versions[0].name).toEqual("main") + expect(project.versions[1].name).toEqual("master") + expect(project.versions[2].name).toEqual("develop") + expect(project.versions[3].name).toEqual("development") + expect(project.versions[4].name).toEqual("1.0") + expect(project.versions[5].name).toEqual("anne") +}) + +test("It sorts file specifications alphabetically", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "1", + name: "main", + files: [ + { name: "z-openapi.yml" }, + { name: "a-openapi.yml" }, + { name: "1-openapi.yml" } + ] + }] + })) + expect(project.versions[0].specifications[0].name).toEqual("1-openapi.yml") + expect(project.versions[0].specifications[1].name).toEqual("a-openapi.yml") + expect(project.versions[0].specifications[2].name).toEqual("z-openapi.yml") +}) + +test("It maintains remote version specification ordering from config", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + name: Hello World + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Zac + url: https://example.com/zac.yml + - id: another-spec + name: Bob + url: https://example.com/bob.yml + ` + } + })) + expect(project.versions[0].specifications[0].name).toEqual("Zac") + expect(project.versions[0].specifications[1].name).toEqual("Bob") +}) + +test("It identifies the default branch in returned versions", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + defaultBranchRef: { id: "1", name: "development" }, + branches: [ + { id: "1", name: "anne", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "main", files: [{ name: "openapi.yml" }] }, + { id: "3", name: "development", files: [{ name: "openapi.yml" }] } + ] + })) + const defaultVersionNames = project.versions.filter(v => v.isDefault).map(v => v.name) + expect(defaultVersionNames).toEqual(["development"]) +}) + +test("It adds remote versions from the project configuration", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + remoteVersions: + - name: Anne + specifications: + - name: Huey + url: https://example.com/huey.yml + - name: Dewey + url: https://example.com/dewey.yml + - name: Bobby + specifications: + - name: Louie + url: https://example.com/louie.yml + ` + } + })) + expect(project.versions).toEqual([{ + id: "anne", + name: "Anne", + isDefault: false, + specifications: [{ + id: "huey", + name: "Huey", + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}`, + isDefault: false + }, { + id: "dewey", + name: "Dewey", + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}`, + isDefault: false + }] + }, { + id: "bobby", + name: "Bobby", + isDefault: false, + specifications: [{ + id: "louie", + name: "Louie", + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}`, + isDefault: false + }] + }]) +}) + +test("It modifies ID of remote version if the ID already exists", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + defaultBranchRef: { id: "12345678", name: "bar" }, + branches: [{ + id: "12345678", + name: "bar", + files: [{ name: "openapi.yml" }] + }], + tags: [], + configYaml: { + text: ` + remoteVersions: + - name: Bar + specifications: + - name: Baz + url: https://example.com/baz.yml + - name: Bar + specifications: + - name: Hello + url: https://example.com/hello.yml + ` + } + })) + expect(project.versions[0].id).toEqual("bar") + expect(project.versions[1].id).toEqual("bar1") + expect(project.versions[2].id).toEqual("bar2") +}) + +test("It lets users specify the ID of a remote version", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + remoteVersions: + - id: some-version + name: Bar + specifications: + - name: Baz + url: https://example.com/baz.yml + ` + } + })) + expect(project.versions[0].id).toEqual("some-version") +}) + +test("It lets users specify the ID of a remote specification", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Baz + url: https://example.com/baz.yml + ` + } + })) + expect(project.versions[0].specifications[0].id).toEqual("some-spec") +}) + +test("It sets isDefault on the correct specification based on defaultSpecificationName in config", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "defaultSpecificationName: bar-service.yml" }, + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }] + })) + const specs = project.versions[0].specifications + expect(specs.find(s => s.name === "bar-service.yml")!.isDefault).toBe(true) + expect(specs.find(s => s.name === "foo-service.yml")!.isDefault).toBe(false) + expect(specs.find(s => s.name === "baz-service.yml")!.isDefault).toBe(false) +}) + +test("It sets a remote specification as the default if specified", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + defaultSpecificationName: Baz + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Baz + url: https://example.com/baz.yml + - id: another-spec + name: Qux + url: https://example.com/qux.yml + ` + } + })) + const remoteSpecs = project.versions[0].specifications + expect(remoteSpecs.find(s => s.id === "some-spec")!.isDefault).toBe(true) + expect(remoteSpecs.find(s => s.id === "another-spec")!.isDefault).toBe(false) +}) + +test("It sets isDefault to false for all specifications if defaultSpecificationName is not set", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }] + })) + const specs = project.versions[0].specifications + expect(specs.every(s => s.isDefault === false)).toBe(true) +}) + +test("It silently ignores defaultSpecificationName if no matching spec is found", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "defaultSpecificationName: non-existent.yml" }, + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" } + ] + }] + })) + const specs = project.versions[0].specifications + expect(specs.every(s => s.isDefault === false)).toBe(true) +}) + +test("It generates URLs using the provided URL builders", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "branch-id-123", + name: "main", + files: [{ name: "openapi.yml" }] + }] + })) + expect(project.url).toEqual("https://example.com/acme/foo-openapi") + expect(project.ownerUrl).toEqual("https://example.com/acme") + expect(project.versions[0].url).toEqual("https://example.com/acme/foo-openapi/tree/main") + expect(project.versions[0].specifications[0].editURL).toEqual("https://example.com/acme/foo-openapi/edit/main/openapi.yml") + expect(project.versions[0].specifications[0].url).toEqual("/api/blob/acme/foo-openapi/openapi.yml?ref=branch-id-123") +}) + +test("mapRepositories filters out projects with no versions", () => { + const mapper = createMapper() + const projects = mapper.mapRepositories([ + createRepository({ + name: "with-specs-openapi", + branches: [{ id: "1", name: "main", files: [{ name: "openapi.yml" }] }] + }), + createRepository({ + name: "without-specs-openapi", + branches: [{ id: "2", name: "main", files: [{ name: "README.md" }] }] + }) + ]) + expect(projects.length).toEqual(1) + expect(projects[0].name).toEqual("with-specs") +}) + +test("mapRepositories sorts projects alphabetically by name", () => { + const mapper = createMapper() + const projects = mapper.mapRepositories([ + createRepository({ + name: "zebra-openapi", + branches: [{ id: "1", name: "main", files: [{ name: "openapi.yml" }] }] + }), + createRepository({ + name: "alpha-openapi", + branches: [{ id: "2", name: "main", files: [{ name: "openapi.yml" }] }] + }), + createRepository({ + name: "middle-openapi", + branches: [{ id: "3", name: "main", files: [{ name: "openapi.yml" }] }] + }) + ]) + expect(projects.map(p => p.name)).toEqual(["alpha", "middle", "zebra"]) +}) diff --git a/__test__/projects/testUtils.ts b/__test__/projects/testUtils.ts new file mode 100644 index 00000000..014e2640 --- /dev/null +++ b/__test__/projects/testUtils.ts @@ -0,0 +1,25 @@ +import RemoteConfig from "@/features/projects/domain/RemoteConfig" + +/** + * Simple encryption service for testing. Does nothing. + */ +export const noopEncryptionService = { + encrypt: function (data: string): string { + return data + }, + decrypt: function (encryptedDataBase64: string): string { + return encryptedDataBase64 + } +} + +/** + * Simple encoder for testing + */ +export const base64RemoteConfigEncoder = { + encode: function (remoteConfig: RemoteConfig): string { + return Buffer.from(JSON.stringify(remoteConfig)).toString("base64") + }, + decode: function (encodedString: string): RemoteConfig { + return JSON.parse(Buffer.from(encodedString, "base64").toString()) + } +} diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index 4762ed3e..5db35cf7 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,25 +1,53 @@ import { IEncryptionService } from "@/features/encrypt/EncryptionService" import { Project, - Version, - IProjectConfig, IProjectDataSource, - ProjectConfigParser, - ProjectConfigRemoteVersion, - IGitHubRepositoryDataSource, - GitHubRepository, - GitHubRepositoryRef, - ProjectConfigRemoteSpecification + IGitHubRepositoryDataSource } from "../domain" -import RemoteConfig from "../domain/RemoteConfig" +import ProjectMapper, { type URLBuilders, type RepositoryWithRefs, type RepositoryRef } from "../domain/ProjectMapper" import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder" +const gitHubURLBuilders: URLBuilders = { + getImageRef(repository: RepositoryWithRefs): string { + // GitHub always provides an id for branches; fall back to name if not present + return repository.defaultBranchRef.id ?? repository.defaultBranchRef.name + }, + getBlobRef(ref: RepositoryRef): string { + // GitHub always provides an id for refs; fall back to name if not present + return ref.id ?? ref.name + }, + getOwnerUrl(owner: string): string { + return `https://github.com/${owner}` + }, + getProjectUrl(repository: RepositoryWithRefs): string { + return `https://github.com/${repository.owner}/${repository.name}` + }, + getVersionUrl(repository: RepositoryWithRefs, ref: RepositoryRef): string { + return `https://github.com/${repository.owner}/${repository.name}/tree/${ref.name}` + }, + getSpecEditUrl(repository: RepositoryWithRefs, ref: RepositoryRef, fileName: string): string { + return `https://github.com/${repository.owner}/${repository.name}/edit/${ref.name}/${encodeURIComponent(fileName)}` + }, + getDiffUrl(repository: RepositoryWithRefs, ref: RepositoryRef, fileName: string): string | undefined { + if (!ref.baseRefOid) { + return undefined + } + const toRef = ref.id ?? ref.name + const encodedPath = fileName.split('/').map(segment => encodeURIComponent(segment)).join('/') + return `/api/diff/${repository.owner}/${repository.name}/${encodedPath}?baseRefOid=${ref.baseRefOid}&to=${toRef}` + }, + getPrUrl(repository: RepositoryWithRefs, ref: RepositoryRef): string | undefined { + if (!ref.prNumber) { + return undefined + } + return `https://github.com/${repository.owner}/${repository.name}/pull/${ref.prNumber}` + } +} + export default class GitHubProjectDataSource implements IProjectDataSource { private readonly repositoryDataSource: IGitHubRepositoryDataSource - private readonly repositoryNameSuffix: string - private readonly encryptionService: IEncryptionService - private readonly remoteConfigEncoder: IRemoteConfigEncoder - + private readonly projectMapper: ProjectMapper + constructor(config: { repositoryDataSource: IGitHubRepositoryDataSource repositoryNameSuffix: string @@ -27,269 +55,16 @@ export default class GitHubProjectDataSource implements IProjectDataSource { remoteConfigEncoder: IRemoteConfigEncoder }) { this.repositoryDataSource = config.repositoryDataSource - this.repositoryNameSuffix = config.repositoryNameSuffix - this.encryptionService = config.encryptionService - this.remoteConfigEncoder = config.remoteConfigEncoder - } - - async getProjects(): Promise { - const repositories = await this.repositoryDataSource.getRepositories() - return repositories.map(repository => { - return this.mapProject(repository) - }) - .filter((project: Project) => { - return project.versions.length > 0 - }) - .sort((a: Project, b: Project) => { - return a.name.localeCompare(b.name) + this.projectMapper = new ProjectMapper({ + repositoryNameSuffix: config.repositoryNameSuffix, + urlBuilders: gitHubURLBuilders, + encryptionService: config.encryptionService, + remoteConfigEncoder: config.remoteConfigEncoder }) } - - private mapProject(repository: GitHubRepository): Project { - const config = this.getConfig(repository) - let imageURL: string | undefined - if (config && config.image) { - imageURL = this.getGitHubBlobURL({ - ownerName: repository.owner, - repositoryName: repository.name, - path: config.image, - ref: repository.defaultBranchRef.id - }) - } - const versions = this.sortVersions( - this.addRemoteVersions( - this.getVersions(repository), - config?.remoteVersions || [] - ), - repository.defaultBranchRef.name - ).filter(version => { - return version.specifications.length > 0 - }) - .map(version => this.setDefaultSpecification(version, config?.defaultSpecificationName)) - const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") - return { - id: `${repository.owner}-${defaultName}`, - owner: repository.owner, - name: defaultName, - displayName: config?.name || defaultName, - versions, - imageURL: imageURL, - ownerUrl: `https://github.com/${repository.owner}`, - url: `https://github.com/${repository.owner}/${repository.name}` - } - } - - private getConfig(repository: GitHubRepository): IProjectConfig | null { - const yml = repository.configYml || repository.configYaml - if (!yml || !yml.text || yml.text.length == 0) { - return null - } - const parser = new ProjectConfigParser() - return parser.parse(yml.text) - } - - private getVersions(repository: GitHubRepository): Version[] { - const branchVersions = repository.branches.map(branch => { - const isDefaultRef = branch.name == repository.defaultBranchRef.name - return this.mapVersionFromRef({ - ownerName: repository.owner, - repositoryName: repository.name, - ref: branch, - isDefaultRef - }) - }) - const tagVersions = repository.tags.map(tag => { - return this.mapVersionFromRef({ - ownerName: repository.owner, - repositoryName: repository.name, - ref: tag - }) - }) - return branchVersions.concat(tagVersions) - } - - private mapVersionFromRef({ - ownerName, - repositoryName, - ref, - isDefaultRef - }: { - ownerName: string - repositoryName: string - ref: GitHubRepositoryRef - isDefaultRef?: boolean - }): Version { - const specifications = ref.files.filter(file => { - return this.isOpenAPISpecification(file.name) - }).map(file => { - const isFileChanged = ref.changedFiles?.includes(file.name) ?? false - return { - id: file.name, - name: file.name, - url: this.getGitHubBlobURL({ - ownerName, - repositoryName, - path: file.name, - ref: ref.id - }), - editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${encodeURIComponent(file.name)}`, - diffURL: isFileChanged ? this.getGitHubDiffURL({ - ownerName, - repositoryName, - path: file.name, - baseRefOid: ref.baseRefOid, - headRefOid: ref.id - }) : undefined, - diffBaseBranch: isFileChanged ? ref.baseRef : undefined, - diffBaseOid: isFileChanged ? ref.baseRefOid : undefined, - diffPrUrl: isFileChanged && ref.prNumber ? `https://github.com/${ownerName}/${repositoryName}/pull/${ref.prNumber}` : undefined, - isDefault: false // initial value - } - }).sort((a, b) => a.name.localeCompare(b.name)) - return { - id: ref.name, - name: ref.name, - specifications: specifications, - url: `https://github.com/${ownerName}/${repositoryName}/tree/${ref.name}`, - isDefault: isDefaultRef || false, - } - } - private isOpenAPISpecification(filename: string) { - return !filename.startsWith(".") && ( - filename.endsWith(".yml") || filename.endsWith(".yaml") - ) - } - - private getGitHubBlobURL({ - ownerName, - repositoryName, - path, - ref - }: { - ownerName: string - repositoryName: string - path: string - ref: string - }): string { - const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/') - return `/api/blob/${ownerName}/${repositoryName}/${encodedPath}?ref=${ref}` - } - - private getGitHubDiffURL({ - ownerName, - repositoryName, - path, - baseRefOid, - headRefOid - }: { - ownerName: string; - repositoryName: string; - path: string; - baseRefOid: string | undefined; - headRefOid: string } - ): string | undefined { - if (!baseRefOid) { - return undefined - } else { - const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/') - return `/api/diff/${ownerName}/${repositoryName}/${encodedPath}?baseRefOid=${baseRefOid}&to=${headRefOid}` - } - } - - private addRemoteVersions( - existingVersions: Version[], - remoteVersions: ProjectConfigRemoteVersion[] - ): Version[] { - const versions = [...existingVersions] - const versionIds = versions.map(e => e.id) - for (const remoteVersion of remoteVersions) { - const baseVersionId = this.makeURLSafeID( - (remoteVersion.id || remoteVersion.name).toLowerCase() - ) - // If the version ID exists then we suffix it with a number to ensure unique versions. - // E.g. if "foo" already exists, we make it "foo1". - const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length - const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") - const specifications = remoteVersion.specifications.map(e => { - const remoteConfig: RemoteConfig = { - url: e.url, - auth: this.tryDecryptAuth(e) - }; - - const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig); - - return { - id: this.makeURLSafeID((e.id || e.name).toLowerCase()), - name: e.name, - url: `/api/remotes/${encodedRemoteConfig}`, - isDefault: false // initial value - }; - }) - versions.push({ - id: versionId, - name: remoteVersion.name, - specifications, - isDefault: false - }) - versionIds.push(baseVersionId) - } - return versions - } - - private sortVersions(versions: Version[], defaultBranchName: string): Version[] { - const candidateDefaultBranches = [ - defaultBranchName, "main", "master", "develop", "development", "trunk" - ] - // Reverse them so the top-priority branches end up at the top of the list. - .reverse() - const copiedVersions = [...versions].sort((a, b) => { - return a.name.localeCompare(b.name) - }) - // Move the top-priority branches to the top of the list. - for (const candidateDefaultBranch of candidateDefaultBranches) { - const defaultBranchIndex = copiedVersions.findIndex(version => { - return version.name === candidateDefaultBranch - }) - if (defaultBranchIndex !== -1) { - const branchVersion = copiedVersions[defaultBranchIndex] - copiedVersions.splice(defaultBranchIndex, 1) - copiedVersions.splice(0, 0, branchVersion) - } - } - return copiedVersions - } - - private makeURLSafeID(str: string): string { - return str - .replace(/ /g, "-") - .replace(/[^A-Za-z0-9-]/g, "") - } - - private tryDecryptAuth(projectConfigRemoteSpec: ProjectConfigRemoteSpecification): { type: string, username: string, password: string } | undefined { - if (!projectConfigRemoteSpec.auth) { - return undefined - } - - try { - return { - type: projectConfigRemoteSpec.auth.type, - username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername), - password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword) - } - } catch (error) { - console.info(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.name} (${projectConfigRemoteSpec.url}). Perhaps a different public key was used?:`, error); - return undefined - } - } - - private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version { - return { - ...version, - specifications: version.specifications.map(spec => ({ - ...spec, - isDefault: spec.name === defaultSpecificationName - })) - } + async getProjects(): Promise { + const repositories = await this.repositoryDataSource.getRepositories() + return this.projectMapper.mapRepositories(repositories) } } diff --git a/src/features/projects/domain/ProjectMapper.ts b/src/features/projects/domain/ProjectMapper.ts new file mode 100644 index 00000000..efa27e2f --- /dev/null +++ b/src/features/projects/domain/ProjectMapper.ts @@ -0,0 +1,289 @@ +import { IEncryptionService } from "@/features/encrypt/EncryptionService" +import { + Project, + Version, + IProjectConfig, + ProjectConfigParser, + ProjectConfigRemoteVersion, + ProjectConfigRemoteSpecification +} from "." +import RemoteConfig from "./RemoteConfig" +import { IRemoteConfigEncoder } from "./RemoteConfigEncoder" + +type ConfigYml = { text: string } | null | undefined + +/** + * Common repository ref type + */ +export type RepositoryRef = { + readonly id?: string + readonly name: string + readonly files: { readonly name: string }[] + // Optional diff-related fields + readonly baseRefOid?: string + readonly baseRef?: string + readonly prNumber?: number +} + +/** + * Common repository type. + * Provider-specific fields should be added via intersection types in the data sources. + */ +export type RepositoryWithRefs = { + readonly name: string + readonly owner: string + readonly defaultBranchRef: { + readonly id?: string + readonly name: string + } + readonly configYml?: { readonly text: string } + readonly configYaml?: { readonly text: string } + readonly branches: RepositoryRef[] + readonly tags: RepositoryRef[] +} + +/** + * URL builders for provider-specific URL generation. + * Generic parameter T allows providers to use extended repository types. + */ +export type URLBuilders = { + /** Returns the ref to use for image URLs (e.g., defaultBranchRef.id or defaultBranchRef.name) */ + getImageRef(repository: T): string + /** Returns the ref to use for blob URLs (e.g., ref.id or ref.name) */ + getBlobRef(ref: RepositoryRef): string + /** Returns the owner URL (e.g., https://github.com/owner) */ + getOwnerUrl(owner: string): string + /** Returns the project URL */ + getProjectUrl(repository: T): string + /** Returns the version URL */ + getVersionUrl(repository: T, ref: RepositoryRef): string + /** Returns the specification edit URL */ + getSpecEditUrl(repository: T, ref: RepositoryRef, fileName: string): string + /** Optional: Returns the diff URL for a specification */ + getDiffUrl?(repository: T, ref: RepositoryRef, fileName: string): string | undefined + /** Optional: Returns the PR URL */ + getPrUrl?(repository: T, ref: RepositoryRef): string | undefined +} + +export interface IProjectMapper { + mapRepositories(repositories: T[]): Project[] +} + +export default class ProjectMapper implements IProjectMapper { + private readonly repositoryNameSuffix: string + private readonly urlBuilders: URLBuilders + private readonly encryptionService: IEncryptionService + private readonly remoteConfigEncoder: IRemoteConfigEncoder + + constructor(config: { + repositoryNameSuffix: string + urlBuilders: URLBuilders + encryptionService: IEncryptionService + remoteConfigEncoder: IRemoteConfigEncoder + }) { + this.repositoryNameSuffix = config.repositoryNameSuffix + this.urlBuilders = config.urlBuilders + this.encryptionService = config.encryptionService + this.remoteConfigEncoder = config.remoteConfigEncoder + } + + mapRepositories(repositories: T[]): Project[] { + return repositories + .map(repository => this.mapRepositoryToProject(repository)) + .filter(project => project.versions.length > 0) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + mapRepositoryToProject(repository: T): Project { + const config = this.parseConfig(repository.configYml, repository.configYaml) + let imageURL: string | undefined + if (config && config.image) { + imageURL = getBlobURL( + repository.owner, + repository.name, + config.image, + this.urlBuilders.getImageRef(repository) + ) + } + + const versions = this.sortVersions( + this.addRemoteVersions( + this.getVersions(repository), + config?.remoteVersions || [] + ), + repository.defaultBranchRef.name + ) + .filter(version => version.specifications.length > 0) + .map(version => this.setDefaultSpecification(version, config?.defaultSpecificationName)) + + const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") + return { + id: `${repository.owner}-${defaultName}`, + owner: repository.owner, + name: defaultName, + displayName: config?.name || defaultName, + versions, + imageURL: imageURL, + ownerUrl: this.urlBuilders.getOwnerUrl(repository.owner), + url: this.urlBuilders.getProjectUrl(repository) + } + } + + private parseConfig(configYml: ConfigYml, configYaml: ConfigYml): IProjectConfig | null { + const yml = configYml || configYaml + if (!yml || !yml.text || yml.text.length == 0) { + return null + } + const parser = new ProjectConfigParser() + return parser.parse(yml.text) + } + + private getVersions(repository: T): Version[] { + const branchVersions = repository.branches.map(branch => { + const isDefaultRef = branch.name === repository.defaultBranchRef.name + return this.mapVersionFromRef(repository, branch, isDefaultRef) + }) + const tagVersions = repository.tags.map(tag => { + return this.mapVersionFromRef(repository, tag, false) + }) + return branchVersions.concat(tagVersions) + } + + private mapVersionFromRef( + repository: T, + ref: RepositoryRef, + isDefaultRef: boolean + ): Version { + const specifications = ref.files + .filter(file => isOpenAPISpecification(file.name)) + .map(file => ({ + id: file.name, + name: file.name, + url: getBlobURL(repository.owner, repository.name, file.name, this.urlBuilders.getBlobRef(ref)), + editURL: this.urlBuilders.getSpecEditUrl(repository, ref, file.name), + diffURL: this.urlBuilders.getDiffUrl?.(repository, ref, file.name), + diffBaseBranch: ref.baseRef, + diffBaseOid: ref.baseRefOid, + diffPrUrl: this.urlBuilders.getPrUrl?.(repository, ref), + isDefault: false + })) + .sort((a, b) => a.name.localeCompare(b.name)) + + return { + id: ref.name, + name: ref.name, + specifications: specifications, + url: this.urlBuilders.getVersionUrl(repository, ref), + isDefault: isDefaultRef + } + } + + private sortVersions(versions: Version[], defaultBranchName: string): Version[] { + const candidateDefaultBranches = [ + defaultBranchName, "main", "master", "develop", "development", "trunk" + ] + // Reverse them so the top-priority branches end up at the top of the list. + .reverse() + const copiedVersions = [...versions].sort((a, b) => { + return a.name.localeCompare(b.name) + }) + // Move the top-priority branches to the top of the list. + for (const candidateDefaultBranch of candidateDefaultBranches) { + const defaultBranchIndex = copiedVersions.findIndex(version => { + return version.name === candidateDefaultBranch + }) + if (defaultBranchIndex !== -1) { + const branchVersion = copiedVersions[defaultBranchIndex] + copiedVersions.splice(defaultBranchIndex, 1) + copiedVersions.splice(0, 0, branchVersion) + } + } + return copiedVersions + } + + private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version { + return { + ...version, + specifications: version.specifications.map(spec => ({ + ...spec, + isDefault: spec.name === defaultSpecificationName + })) + } + } + + private addRemoteVersions( + existingVersions: Version[], + remoteVersions: ProjectConfigRemoteVersion[] + ): Version[] { + const versions = [...existingVersions] + const versionIds = versions.map(e => e.id) + for (const remoteVersion of remoteVersions) { + const baseVersionId = makeURLSafeID( + (remoteVersion.id || remoteVersion.name).toLowerCase() + ) + // If the version ID exists then we suffix it with a number to ensure unique versions. + // E.g. if "foo" already exists, we make it "foo1". + const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length + const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") + const specifications = remoteVersion.specifications.map(e => { + const remoteConfig: RemoteConfig = { + url: e.url, + auth: this.tryDecryptAuth(e) + } + + const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig) + + return { + id: makeURLSafeID((e.id || e.name).toLowerCase()), + name: e.name, + url: `/api/remotes/${encodedRemoteConfig}`, + isDefault: false + } + }) + versions.push({ + id: versionId, + name: remoteVersion.name, + specifications, + isDefault: false + }) + versionIds.push(baseVersionId) + } + return versions + } + + private tryDecryptAuth( + projectConfigRemoteSpec: ProjectConfigRemoteSpecification + ): { type: string, username: string, password: string } | undefined { + if (!projectConfigRemoteSpec.auth) { + return undefined + } + + try { + return { + type: projectConfigRemoteSpec.auth.type, + username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername), + password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword) + } + } catch (error) { + console.info(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.name} (${projectConfigRemoteSpec.url}). Perhaps a different public key was used?:`, error) + return undefined + } + } +} + +function isOpenAPISpecification(filename: string): boolean { + return !filename.startsWith(".") && ( + filename.endsWith(".yml") || filename.endsWith(".yaml") + ) +} + +function makeURLSafeID(str: string): string { + return str + .replace(/ /g, "-") + .replace(/[^A-Za-z0-9-]/g, "") +} + +export function getBlobURL(owner: string, repository: string, path: string, ref: string): string { + const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/') + return `/api/blob/${owner}/${repository}/${encodedPath}?ref=${ref}` +}