diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index b4aeb857..e000a115 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -6,20 +6,10 @@ test("It loads repositories from data source", async () => { let didLoadRepositories = false const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { + repositoryDataSource: { + async getRepositories() { didLoadRepositories = true - return { - search: { - results: [] - } - } + return [] } } }) @@ -30,60 +20,30 @@ test("It loads repositories from data source", async () => { test("It maps projects including branches and tags", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [{ - node: { - name: "1.0", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - } + 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" + }] + }] + }] } } }) @@ -121,63 +81,33 @@ test("It maps projects including branches and tags", async () => { }]) }) -test("It removes \"-openapi\" suffix from project name", async () => { +test("It removes suffix from project name", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [{ - node: { - name: "1.0", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - } + 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" + }] + }] + }] } } }) @@ -190,64 +120,34 @@ test("It removes \"-openapi\" suffix from project name", async () => { test("It supports multiple OpenAPI specifications on a branch", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "foo-service.yml" - }, { - name: "bar-service.yml" - }, { - name: "baz-service.yml" - }] - } - } - } - }] - }, - tags: { - edges: [{ - node: { - name: "1.0", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - } + 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" + }] + }] + }] } } }) @@ -295,105 +195,21 @@ test("It supports multiple OpenAPI specifications on a branch", async () => { }]) }) -test("It removes \"-openapi\" suffix from project name", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [{ - node: { - name: "1.0", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - } - }] - } - } - } - } - }) - 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 filters away projects with no versions", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [] - }, - tags: { - edges: [] - } - }] - } - } + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "acme", + name: "foo-openapi", + defaultBranchRef: { + id: "12345678", + name: "main" + }, + branches: [], + tags: [] + }] } } }) @@ -404,60 +220,30 @@ test("It filters away projects with no versions", async () => { test("It filters away branches with no specifications", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "bugfix", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "foo.txt" - }] - } - } - } - }] - }, - tags: { - edges: [] - } + 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: [] + }] } } }) @@ -468,72 +254,36 @@ test("It filters away branches with no specifications", async () => { test("It filters away tags with no specifications", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [{ - node: { - name: "1.0", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "1.1", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "foo.txt" - }] - } - } - } - }] - } + 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" + }] + }] + }] } } }) @@ -544,51 +294,27 @@ test("It filters away tags with no specifications", async () => { test("It reads image from configuration file with .yml extension", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - configYml: { - text: "image: icon.png" - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [] - } + 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: [] + }] } } }) @@ -596,130 +322,30 @@ test("It reads image from configuration file with .yml extension", async () => { expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") }) -test("It filters away tags with no specifications", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [{ - node: { - name: "1.0", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "1.1", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "foo.txt" - }] - } - } - } - }] - } - }] - } - } - } - } - }) - const projects = await sut.getProjects() - expect(projects[0].versions.length).toEqual(2) -}) - test("It reads display name from configuration file with .yml extension", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - configYml: { - text: "name: Hello World" - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [] - } + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "acme", + name: "foo-openapi", + defaultBranchRef: { + id: "12345678", + name: "main" + }, + configYml: { + text: "name: Hello World" + }, + branches: [{ + id: "12345678", + name: "main", + files: [{ + name: "openapi.yml", }] - } - } + }], + tags: [] + }] } } }) @@ -729,54 +355,30 @@ test("It reads display name from configuration file with .yml extension", async expect(projects[0].displayName).toEqual("Hello World") }) -test("It reads image from configuration file with .yml extension", async () => { +test("It reads image from configuration file with .yaml extension", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - configYml: { - text: "image: icon.png" - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [] - } + 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: [] + }] } } }) @@ -787,51 +389,27 @@ test("It reads image from configuration file with .yml extension", async () => { test("It reads display name from configuration file with .yaml extension", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - configYaml: { - text: "name: Hello World" - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [] - } + 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: [] + }] } } }) @@ -841,164 +419,66 @@ test("It reads display name from configuration file with .yaml extension", async expect(projects[0].displayName).toEqual("Hello World") }) -test("It reads image from configuration file with .yaml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - configYaml: { - text: "image: icon.png" - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [] - } - }] - } - } - } - } - }) - const projects = await sut.getProjects() - expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") -}) - test("It sorts projects alphabetically", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "cathrine-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [] - } - }, { - name: "anne-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [] - } - }, { - name: "bobby-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [] - } + 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", + 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: [] + }] } } }) @@ -1011,84 +491,45 @@ test("It sorts projects alphabetically", async () => { test("It sorts versions alphabetically", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "bobby", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "anne", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [{ - node: { - name: "1.0", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "cathrine", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - } + 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", + }] + }], + tags: [{ + id: "12345678", + name: "cathrine", + files: [{ + name: "openapi.yml", + }] + }, { + id: "12345678", + name: "1.0", + files: [{ + name: "openapi.yml", }] - } - } + }] + }] } } }) @@ -1102,108 +543,57 @@ test("It sorts versions alphabetically", async () => { test("It prioritizes main, master, develop, and development branch names when sorting verisons", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "anne", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "develop", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "development", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "master", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [{ - node: { - name: "1.0", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - } + 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", + }] + }] + }] } } }) @@ -1219,72 +609,39 @@ test("It prioritizes main, master, develop, and development branch names when so test("It identifies the default branch in returned versions", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "development", - target: { - oid: "12345678" - } - }, - branches: { - edges: [{ - node: { - name: "anne", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "main", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }, { - node: { - name: "development", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [] - } + 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: [] + }] } } }) @@ -1297,54 +654,35 @@ test("It identifies the default branch in returned versions", async () => { }) test("It adds remote versions from the project configuration", async () => { - const rawProjectConfig = ` - 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 - ` const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "main", - target: { - oid: "12345678" - } - }, - configYml: { - text: rawProjectConfig - }, - branches: { - edges: [] - }, - tags: { - edges: [] - } - }] - } - } + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "acme", + name: "foo-openapi", + defaultBranchRef: { + id: "12345678", + 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: [] + }] } } }) @@ -1375,64 +713,39 @@ test("It adds remote versions from the project configuration", async () => { }) test("It modifies ID of remote version if the ID already exists", async () => { - const rawProjectConfig = ` - remoteVersions: - - name: Bar - specifications: - - name: Baz - url: https://example.com/baz.yml - - name: Bar - specifications: - - name: Hello - url: https://example.com/hello.yml - ` const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "bar", - target: { - oid: "12345678" - } - }, - configYml: { - text: rawProjectConfig - }, - branches: { - edges: [{ - node: { - name: "bar", - target: { - oid: "12345678", - tree: { - entries: [{ - name: "openapi.yml" - }] - } - } - } - }] - }, - tags: { - edges: [] - } + 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" }] - } - } + }], + tags: [] + }] } } }) @@ -1470,49 +783,30 @@ test("It modifies ID of remote version if the ID already exists", async () => { }) test("It lets users specify the ID of a remote version", async () => { - const rawProjectConfig = ` - remoteVersions: - - id: some-version - name: Bar - specifications: - - name: Baz - url: https://example.com/baz.yml - ` const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "bar", - target: { - oid: "12345678" - } - }, - configYml: { - text: rawProjectConfig - }, - branches: { - edges: [] - }, - tags: { - edges: [] - } - }] - } - } + 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: [] + }] } } }) @@ -1530,49 +824,30 @@ test("It lets users specify the ID of a remote version", async () => { }) test("It lets users specify the ID of a remote specification", async () => { - const rawProjectConfig = ` - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - ` const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql() { - return { - search: { - results: [{ - name: "foo-openapi", - owner: { - login: "acme" - }, - defaultBranchRef: { - name: "bar", - target: { - oid: "12345678" - } - }, - configYml: { - text: rawProjectConfig - }, - branches: { - edges: [] - }, - tags: { - edges: [] - } - }] - } - } + 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: [] + }] } } }) @@ -1588,112 +863,3 @@ test("It lets users specify the ID of a remote specification", async () => { }] }]) }) - -test("It queries for both .yml and .yaml file extension with specifying .yml extension", async () => { - let query: string | undefined - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql(request) { - query = request.query - return { - search: { - results: [] - } - } - } - } - }) - await sut.getProjects() - expect(query).toContain(".demo-docs.yml") - expect(query).toContain(".demo-docs.yaml") -}) - -test("It queries for both .yml and .yaml file extension with specifying .yaml extension", async () => { - let query: string | undefined - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs.yml", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql(request) { - query = request.query - return { - search: { - results: [] - } - } - } - } - }) - await sut.getProjects() - expect(query).toContain(".demo-docs.yml") - expect(query).toContain(".demo-docs.yaml") -}) - -test("It queries for both .yml and .yaml file extension with no extension", async () => { - let query: string | undefined - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs", - loginsDataSource: { - async getLogins() { - return ["acme"] - } - }, - graphQlClient: { - async graphql(request) { - query = request.query - return { - search: { - results: [] - } - } - } - } - }) - await sut.getProjects() - expect(query).toContain(".demo-docs.yml") - expect(query).toContain(".demo-docs.yaml") -}) - -test("It loads projects for all logins", async () => { - let searchQueries: string[] = [] - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - projectConfigurationFilename: ".demo-docs", - loginsDataSource: { - async getLogins() { - return ["acme", "somecorp", "techsystems"] - } - }, - graphQlClient: { - async graphql(request) { - if (request.variables?.searchQuery) { - searchQueries.push(request.variables.searchQuery) - } - return { - search: { - results: [] - } - } - } - } - }) - await sut.getProjects() - expect(searchQueries.length).toEqual(4) - expect(searchQueries).toContain("\"-openapi\" in:name is:private") - expect(searchQueries).toContain("\"-openapi\" in:name user:acme is:public") - expect(searchQueries).toContain("\"-openapi\" in:name user:somecorp is:public") - expect(searchQueries).toContain("\"-openapi\" in:name user:techsystems is:public") -}) diff --git a/__test__/projects/GitHubRepositoryDataSource.test.ts b/__test__/projects/GitHubRepositoryDataSource.test.ts new file mode 100644 index 00000000..c6763ab2 --- /dev/null +++ b/__test__/projects/GitHubRepositoryDataSource.test.ts @@ -0,0 +1,222 @@ +import { + GitHubRepositoryDataSource + } from "../../src/features/projects/data" + +test("It loads repositories from data source", async () => { + let didLoadRepositories = false + const sut = new GitHubRepositoryDataSource({ + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".demo-docs.yml", + loginsDataSource: { + async getLogins() { + return ["acme"] + } + }, + graphQlClient: { + async graphql() { + didLoadRepositories = true + return { + search: { + results: [] + } + } + } + } + }) + await sut.getRepositories() + expect(didLoadRepositories).toBeTruthy() +}) + +test("It maps repositories from GraphQL to the GitHubRepository model", async () => { + const sut = new GitHubRepositoryDataSource({ + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".demo-docs.yml", + loginsDataSource: { + async getLogins() { + return ["acme"] + } + }, + graphQlClient: { + async graphql() { + return { + search: { + results: [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [{ + node: { + name: "1.0", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + } + }] + } + } + } + } + }) + const repositories = await sut.getRepositories() + expect(repositories).toEqual([{ + name: "foo-openapi", + owner: "acme", + defaultBranchRef: { + id: "12345678", + name: "main" + }, + branches: [{ + id: "12345678", + name: "main", + files: [{ + name: "openapi.yml" + }] + }], + tags: [{ + id: "12345678", + name: "1.0", + files: [{ + name: "openapi.yml" + }] + }] + }]) +}) + +test("It queries for both .yml and .yaml file extension with specifying .yml extension", async () => { + let query: string | undefined + const sut = new GitHubRepositoryDataSource({ + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".demo-docs.yml", + loginsDataSource: { + async getLogins() { + return ["acme"] + } + }, + graphQlClient: { + async graphql(request) { + query = request.query + return { + search: { + results: [] + } + } + } + } + }) + await sut.getRepositories() + expect(query).toContain(".demo-docs.yml") + expect(query).toContain(".demo-docs.yaml") +}) + +test("It queries for both .yml and .yaml file extension with specifying .yaml extension", async () => { + let query: string | undefined + const sut = new GitHubRepositoryDataSource({ + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".demo-docs.yml", + loginsDataSource: { + async getLogins() { + return ["acme"] + } + }, + graphQlClient: { + async graphql(request) { + query = request.query + return { + search: { + results: [] + } + } + } + } + }) + await sut.getRepositories() + expect(query).toContain(".demo-docs.yml") + expect(query).toContain(".demo-docs.yaml") +}) + +test("It queries for both .yml and .yaml file extension with no extension", async () => { + let query: string | undefined + const sut = new GitHubRepositoryDataSource({ + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".demo-docs", + loginsDataSource: { + async getLogins() { + return ["acme"] + } + }, + graphQlClient: { + async graphql(request) { + query = request.query + return { + search: { + results: [] + } + } + } + } + }) + await sut.getRepositories() + expect(query).toContain(".demo-docs.yml") + expect(query).toContain(".demo-docs.yaml") +}) + +test("It loads repositories for all logins", async () => { + let searchQueries: string[] = [] + const sut = new GitHubRepositoryDataSource({ + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".demo-docs", + loginsDataSource: { + async getLogins() { + return ["acme", "somecorp", "techsystems"] + } + }, + graphQlClient: { + async graphql(request) { + if (request.variables?.searchQuery) { + searchQueries.push(request.variables.searchQuery) + } + return { + search: { + results: [] + } + } + } + } + }) + await sut.getRepositories() + expect(searchQueries.length).toEqual(4) + expect(searchQueries).toContain("\"-openapi\" in:name is:private") + expect(searchQueries).toContain("\"-openapi\" in:name user:acme is:public") + expect(searchQueries).toContain("\"-openapi\" in:name user:somecorp is:public") + expect(searchQueries).toContain("\"-openapi\" in:name user:techsystems is:public") +}) diff --git a/src/composition.ts b/src/composition.ts index 0fdeb7e0..69806563 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -17,7 +17,8 @@ import { } from "@/common" import { GitHubLoginDataSource, - GitHubProjectDataSource + GitHubProjectDataSource, + GitHubRepositoryDataSource } from "@/features/projects/data" import { CachingProjectDataSource, @@ -157,12 +158,15 @@ export const projectRepository = new ProjectRepository({ export const projectDataSource = new CachingProjectDataSource({ dataSource: new GitHubProjectDataSource({ - loginsDataSource: new GitHubLoginDataSource({ - graphQlClient: userGitHubClient + repositoryDataSource: new GitHubRepositoryDataSource({ + loginsDataSource: new GitHubLoginDataSource({ + graphQlClient: userGitHubClient + }), + graphQlClient: userGitHubClient, + repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), + projectConfigurationFilename: env.getOrThrow("SHAPE_DOCS_PROJECT_CONFIGURATION_FILENAME") }), - graphQlClient: userGitHubClient, - repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), - projectConfigurationFilename: env.getOrThrow("SHAPE_DOCS_PROJECT_CONFIGURATION_FILENAME") + repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX") }), repository: projectRepository }) diff --git a/src/features/projects/data/GitHubLoginDataSource.ts b/src/features/projects/data/GitHubLoginDataSource.ts index 3d60d1bf..f0198b39 100644 --- a/src/features/projects/data/GitHubLoginDataSource.ts +++ b/src/features/projects/data/GitHubLoginDataSource.ts @@ -1,5 +1,4 @@ -import IGitHubLoginDataSource from "./IGitHubLoginDataSource" -import IGitHubGraphQLClient from "./IGitHubGraphQLClient" +import { IGitHubLoginDataSource, IGitHubGraphQLClient } from "../domain" export default class GitHubLoginDataSource implements IGitHubLoginDataSource { private readonly graphQlClient: IGitHubGraphQLClient diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index 848c4859..bc79fe25 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,38 +1,29 @@ -import GitHubProjectRepository, { - GitHubProjectRepositoryRef -} from "./GitHubProjectRepository" -import IGitHubLoginDataSource from "./IGitHubLoginDataSource" -import IGitHubGraphQLClient from "./IGitHubGraphQLClient" import { Project, Version, IProjectConfig, IProjectDataSource, ProjectConfigParser, - ProjectConfigRemoteVersion + ProjectConfigRemoteVersion, + IGitHubRepositoryDataSource, + GitHubRepository, + GitHubRepositoryRef } from "../domain" export default class GitHubProjectDataSource implements IProjectDataSource { - private readonly loginsDataSource: IGitHubLoginDataSource - private readonly graphQlClient: IGitHubGraphQLClient + private readonly repositoryDataSource: IGitHubRepositoryDataSource private readonly repositoryNameSuffix: string - private readonly projectConfigurationFilename: string constructor(config: { - loginsDataSource: IGitHubLoginDataSource, - graphQlClient: IGitHubGraphQLClient, - repositoryNameSuffix: string, - projectConfigurationFilename: string + repositoryDataSource: IGitHubRepositoryDataSource + repositoryNameSuffix: string }) { - this.loginsDataSource = config.loginsDataSource - this.graphQlClient = config.graphQlClient + this.repositoryDataSource = config.repositoryDataSource this.repositoryNameSuffix = config.repositoryNameSuffix - this.projectConfigurationFilename = config.projectConfigurationFilename.replace(/\.ya?ml$/, "") } async getProjects(): Promise { - const logins = await this.loginsDataSource.getLogins() - const repositories = await this.getRepositories({ logins }) + const repositories = await this.repositoryDataSource.getRepositories() return repositories.map(repository => { return this.mapProject(repository) }) @@ -44,15 +35,15 @@ export default class GitHubProjectDataSource implements IProjectDataSource { }) } - private mapProject(repository: GitHubProjectRepository): Project { + private mapProject(repository: GitHubRepository): Project { const config = this.getConfig(repository) let imageURL: string | undefined if (config && config.image) { imageURL = this.getGitHubBlobURL({ - ownerName: repository.owner.login, + ownerName: repository.owner, repositoryName: repository.name, path: config.image, - ref: repository.defaultBranchRef.target.oid + ref: repository.defaultBranchRef.id }) } const versions = this.sortVersions( @@ -66,18 +57,18 @@ export default class GitHubProjectDataSource implements IProjectDataSource { }) const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") return { - id: `${repository.owner.login}-${defaultName}`, - owner: repository.owner.login, + id: `${repository.owner}-${defaultName}`, + owner: repository.owner, name: defaultName, displayName: config?.name || defaultName, versions, imageURL: imageURL, - ownerUrl: `https://github.com/${repository.owner.login}`, - url: `https://github.com/${repository.owner.login}/${repository.name}` + ownerUrl: `https://github.com/${repository.owner}`, + url: `https://github.com/${repository.owner}/${repository.name}` } } - private getConfig(repository: GitHubProjectRepository): IProjectConfig | null { + private getConfig(repository: GitHubRepository): IProjectConfig | null { const yml = repository.configYml || repository.configYaml if (!yml || !yml.text || yml.text.length == 0) { return null @@ -86,21 +77,21 @@ export default class GitHubProjectDataSource implements IProjectDataSource { return parser.parse(yml.text) } - private getVersions(repository: GitHubProjectRepository): Version[] { - const branchVersions = repository.branches.edges.map(edge => { - const isDefaultRef = edge.node.name == repository.defaultBranchRef.name + private getVersions(repository: GitHubRepository): Version[] { + const branchVersions = repository.branches.map(branch => { + const isDefaultRef = branch.name == repository.defaultBranchRef.name return this.mapVersionFromRef({ - ownerName: repository.owner.login, + ownerName: repository.owner, repositoryName: repository.name, - ref: edge.node, + ref: branch, isDefaultRef }) }) - const tagVersions = repository.tags.edges.map(edge => { + const tagVersions = repository.tags.map(tag => { return this.mapVersionFromRef({ - ownerName: repository.owner.login, + ownerName: repository.owner, repositoryName: repository.name, - ref: edge.node + ref: tag }) }) return branchVersions.concat(tagVersions) @@ -114,10 +105,10 @@ export default class GitHubProjectDataSource implements IProjectDataSource { }: { ownerName: string repositoryName: string - ref: GitHubProjectRepositoryRef + ref: GitHubRepositoryRef isDefaultRef?: boolean }): Version { - const specifications = ref.target.tree.entries.filter(file => { + const specifications = ref.files.filter(file => { return this.isOpenAPISpecification(file.name) }).map(file => { return { @@ -127,7 +118,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { ownerName, repositoryName, path: file.name, - ref: ref.target.oid + ref: ref.id }), editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${file.name}` } @@ -221,128 +212,4 @@ export default class GitHubProjectDataSource implements IProjectDataSource { .replace(/ /g, "-") .replace(/[^A-Za-z0-9-]/g, "") } - - private async getRepositories({ logins }: { logins: string[] }): Promise { - let searchQueries: string[] = [] - // Search for all private repositories the user has access to. This is needed to find - // repositories for external collaborators who do not belong to an organization. - searchQueries.push(`"${this.repositoryNameSuffix}" in:name is:private`) - // Search for public repositories belonging to a user or organization. - searchQueries = searchQueries.concat(logins.map(login => { - return `"${this.repositoryNameSuffix}" in:name user:${login} is:public` - })) - return await Promise.all(searchQueries.map(searchQuery => { - return this.getRepositoriesForSearchQuery({ searchQuery }) - })) - .then(e => e.flat()) - .then(repositories => { - // GitHub's search API does not enable searching for repositories whose name ends with "-openapi", - // only repositories whose names include "openapi" so we filter the results ourselves. - return repositories.filter(repository => { - return repository.name.endsWith(this.repositoryNameSuffix) - }) - }) - .then(repositories => { - // Ensure we don't have duplicates in the resulting repositories. - const uniqueIdentifiers = new Set() - return repositories.filter(repository => { - const identifier = `${repository.owner.login}-${repository.name}` - const alreadyAdded = uniqueIdentifiers.has(identifier) - uniqueIdentifiers.add(identifier) - return !alreadyAdded - }) - }) - } - - private async getRepositoriesForSearchQuery(params: { - searchQuery: string, - cursor?: string - }): Promise { - const { searchQuery, cursor } = params - const request = { - query: ` - query Repositories($searchQuery: String!, $cursor: String) { - search(query: $searchQuery, type: REPOSITORY, first: 100, after: $cursor) { - results: nodes { - ... on Repository { - name - owner { - login - } - defaultBranchRef { - name - target { - ...on Commit { - oid - } - } - } - configYml: object(expression: "HEAD:${this.projectConfigurationFilename}.yml") { - ...ConfigParts - } - configYaml: object(expression: "HEAD:${this.projectConfigurationFilename}.yaml") { - ...ConfigParts - } - branches: refs(refPrefix: "refs/heads/", first: 100) { - ...RefConnectionParts - } - tags: refs(refPrefix: "refs/tags/", first: 100) { - ...RefConnectionParts - } - } - } - - pageInfo { - hasNextPage - endCursor - } - } - } - - fragment RefConnectionParts on RefConnection { - edges { - node { - name - ... on Ref { - name - target { - ... on Commit { - oid - tree { - entries { - name - } - } - } - } - } - } - } - } - - fragment ConfigParts on GitObject { - ... on Blob { - text - } - } - `, - variables: { searchQuery, cursor } - } - const response = await this.graphQlClient.graphql(request) - if (!response.search || !response.search.results) { - return [] - } - const pageInfo = response.search.pageInfo - if (!pageInfo) { - return response.search.results - } - if (!pageInfo.hasNextPage || !pageInfo.endCursor) { - return response.search.results - } - const nextResults = await this.getRepositoriesForSearchQuery({ - searchQuery, - cursor: pageInfo.endCursor - }) - return response.search.results.concat(nextResults) - } } diff --git a/src/features/projects/data/GitHubProjectRepository.ts b/src/features/projects/data/GitHubProjectRepository.ts deleted file mode 100644 index 20b88af6..00000000 --- a/src/features/projects/data/GitHubProjectRepository.ts +++ /dev/null @@ -1,44 +0,0 @@ -type GitHubProjectRepository = { - readonly name: string - readonly owner: { - readonly login: string - } - readonly defaultBranchRef: { - readonly name: string - readonly target: { - readonly oid: string - } - } - readonly configYml?: { - readonly text: string - } - readonly configYaml?: { - readonly text: string - } - readonly branches: EdgesContainer - readonly tags: EdgesContainer -} - -export default GitHubProjectRepository - -type EdgesContainer = { - readonly edges: Edge[] -} - -type Edge = { - readonly node: T -} - -export type GitHubProjectRepositoryRef = { - readonly name: string - readonly target: { - readonly oid: string - readonly tree: { - readonly entries: GitHubProjectRepositoryFile[] - } - } -} - -export type GitHubProjectRepositoryFile = { - readonly name: string -} \ No newline at end of file diff --git a/src/features/projects/data/GitHubRepositoryDataSource.ts b/src/features/projects/data/GitHubRepositoryDataSource.ts new file mode 100644 index 00000000..e94e228a --- /dev/null +++ b/src/features/projects/data/GitHubRepositoryDataSource.ts @@ -0,0 +1,228 @@ +import { + GitHubRepository, + IGitHubRepositoryDataSource, + IGitHubLoginDataSource, + IGitHubGraphQLClient +} from "../domain" + +type GraphQLGitHubRepository = { + readonly name: string + readonly owner: { + readonly login: string + } + readonly defaultBranchRef: { + readonly name: string + readonly target: { + readonly oid: string + } + } + readonly configYml?: { + readonly text: string + } + readonly configYaml?: { + readonly text: string + } + readonly branches: EdgesContainer + readonly tags: EdgesContainer +} + +type EdgesContainer = { + readonly edges: Edge[] +} + +type Edge = { + readonly node: T +} + +type GraphQLGitHubRepositoryRef = { + readonly name: string + readonly target: { + readonly oid: string + readonly tree: { + readonly entries: { + readonly name: string + }[] + } + } +} + +export default class GitHubProjectDataSource implements IGitHubRepositoryDataSource { + private readonly loginsDataSource: IGitHubLoginDataSource + private readonly graphQlClient: IGitHubGraphQLClient + private readonly repositoryNameSuffix: string + private readonly projectConfigurationFilename: string + + constructor(config: { + loginsDataSource: IGitHubLoginDataSource, + graphQlClient: IGitHubGraphQLClient, + repositoryNameSuffix: string, + projectConfigurationFilename: string + }) { + this.loginsDataSource = config.loginsDataSource + this.graphQlClient = config.graphQlClient + this.repositoryNameSuffix = config.repositoryNameSuffix + this.projectConfigurationFilename = config.projectConfigurationFilename.replace(/\.ya?ml$/, "") + } + + async getRepositories(): Promise { + const logins = await this.loginsDataSource.getLogins() + return await this.getRepositoriesForLogins({ logins }) + } + + private async getRepositoriesForLogins({ + logins + }: { + logins: string[] + }): Promise { + let searchQueries: string[] = [] + // Search for all private repositories the user has access to. This is needed to find + // repositories for external collaborators who do not belong to an organization. + searchQueries.push(`"${this.repositoryNameSuffix}" in:name is:private`) + // Search for public repositories belonging to a user or organization. + searchQueries = searchQueries.concat(logins.map(login => { + return `"${this.repositoryNameSuffix}" in:name user:${login} is:public` + })) + return await Promise.all(searchQueries.map(searchQuery => { + return this.getRepositoriesForSearchQuery({ searchQuery }) + })) + .then(e => e.flat()) + .then(repositories => { + // GitHub's search API does not enable searching for repositories whose name ends with "-openapi", + // only repositories whose names include "openapi" so we filter the results ourselves. + return repositories.filter(repository => { + return repository.name.endsWith(this.repositoryNameSuffix) + }) + }) + .then(repositories => { + // Ensure we don't have duplicates in the resulting repositories. + const uniqueIdentifiers = new Set() + return repositories.filter(repository => { + const identifier = `${repository.owner.login}-${repository.name}` + const alreadyAdded = uniqueIdentifiers.has(identifier) + uniqueIdentifiers.add(identifier) + return !alreadyAdded + }) + }) + .then(repositories => { + // Map from the internal model to the public model. + return repositories.map(repository => { + return { + name: repository.name, + owner: repository.owner.login, + defaultBranchRef: { + id: repository.defaultBranchRef.target.oid, + name: repository.defaultBranchRef.name + }, + configYml: repository.configYml, + configYaml: repository.configYaml, + branches: repository.branches.edges.map(branch => { + return { + id: branch.node.target.oid, + name: branch.node.name, + files: branch.node.target.tree.entries + } + }), + tags: repository.tags.edges.map(branch => { + return { + id: branch.node.target.oid, + name: branch.node.name, + files: branch.node.target.tree.entries + } + }) + } + }) + }) + } + + private async getRepositoriesForSearchQuery(params: { + searchQuery: string, + cursor?: string + }): Promise { + const { searchQuery, cursor } = params + const request = { + query: ` + query Repositories($searchQuery: String!, $cursor: String) { + search(query: $searchQuery, type: REPOSITORY, first: 100, after: $cursor) { + results: nodes { + ... on Repository { + name + owner { + login + } + defaultBranchRef { + name + target { + ...on Commit { + oid + } + } + } + configYml: object(expression: "HEAD:${this.projectConfigurationFilename}.yml") { + ...ConfigParts + } + configYaml: object(expression: "HEAD:${this.projectConfigurationFilename}.yaml") { + ...ConfigParts + } + branches: refs(refPrefix: "refs/heads/", first: 100) { + ...RefConnectionParts + } + tags: refs(refPrefix: "refs/tags/", first: 100) { + ...RefConnectionParts + } + } + } + + pageInfo { + hasNextPage + endCursor + } + } + } + + fragment RefConnectionParts on RefConnection { + edges { + node { + name + ... on Ref { + name + target { + ... on Commit { + oid + tree { + entries { + name + } + } + } + } + } + } + } + } + + fragment ConfigParts on GitObject { + ... on Blob { + text + } + } + `, + variables: { searchQuery, cursor } + } + const response = await this.graphQlClient.graphql(request) + if (!response.search || !response.search.results) { + return [] + } + const pageInfo = response.search.pageInfo + if (!pageInfo) { + return response.search.results + } + if (!pageInfo.hasNextPage || !pageInfo.endCursor) { + return response.search.results + } + const nextResults = await this.getRepositoriesForSearchQuery({ + searchQuery, + cursor: pageInfo.endCursor + }) + return response.search.results.concat(nextResults) + } +} diff --git a/src/features/projects/data/index.ts b/src/features/projects/data/index.ts index cf44de31..8155c986 100644 --- a/src/features/projects/data/index.ts +++ b/src/features/projects/data/index.ts @@ -1,6 +1,5 @@ export { default as GitHubProjectDataSource } from "./GitHubProjectDataSource" export * from "./GitHubProjectDataSource" export { default as useProjects } from "./useProjects" -export type { default as IGitHubLoginDataSource } from "./IGitHubLoginDataSource" export { default as GitHubLoginDataSource } from "./GitHubLoginDataSource" -export * from "./IGitHubGraphQLClient" +export { default as GitHubRepositoryDataSource } from "./GitHubRepositoryDataSource" diff --git a/src/features/projects/data/IGitHubGraphQLClient.ts b/src/features/projects/domain/IGitHubGraphQLClient.ts similarity index 100% rename from src/features/projects/data/IGitHubGraphQLClient.ts rename to src/features/projects/domain/IGitHubGraphQLClient.ts diff --git a/src/features/projects/data/IGitHubLoginDataSource.ts b/src/features/projects/domain/IGitHubLoginDataSource.ts similarity index 100% rename from src/features/projects/data/IGitHubLoginDataSource.ts rename to src/features/projects/domain/IGitHubLoginDataSource.ts diff --git a/src/features/projects/domain/IGitHubRepositoryDataSource.ts b/src/features/projects/domain/IGitHubRepositoryDataSource.ts new file mode 100644 index 00000000..b48ff43e --- /dev/null +++ b/src/features/projects/domain/IGitHubRepositoryDataSource.ts @@ -0,0 +1,32 @@ +export type GitHubRepository = { + 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: GitHubRepositoryRef[] + readonly tags: GitHubRepositoryRef[] +} + +export type GitHubRepositoryRef = { + readonly id: string + readonly name: string + readonly files: { + readonly name: string + }[] +} + +export default interface IGitHubRepositoryDataSource { + getRepositories(): Promise +} + +export default interface IGitHubRepositoryDataSource { + getRepositories(): Promise +} diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index bfce5ee2..8b493c0f 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -1,5 +1,10 @@ export { default as CachingProjectDataSource } from "./CachingProjectDataSource" export { default as getSelection } from "./getSelection" +export type { default as IGitHubLoginDataSource } from "./IGitHubLoginDataSource" +export type { default as IGitHubRepositoryDataSource } from "./IGitHubRepositoryDataSource" +export * from "./IGitHubRepositoryDataSource" +export type { default as IGitHubGraphQLClient } from "./IGitHubGraphQLClient" +export * from "./IGitHubGraphQLClient" export type { default as IProjectConfig } from "./IProjectConfig" export * from "./IProjectConfig" export type { default as IProjectDataSource } from "./IProjectDataSource"