diff --git a/__test__/projects/CachingProjectDataSource.test.ts b/__test__/projects/CachingProjectDataSource.test.ts index 7f4974cf..bf4c0cea 100644 --- a/__test__/projects/CachingProjectDataSource.test.ts +++ b/__test__/projects/CachingProjectDataSource.test.ts @@ -27,17 +27,20 @@ test("It caches projects read from the data source", async () => { }] let cachedProjects: Project[] | undefined const sut = new CachingProjectDataSource({ - async getProjects() { - return projects - } - }, { - async get() { - return [] - }, - async set(projects) { - cachedProjects = projects + dataSource: { + async getProjects() { + return projects + } }, - async delete() {} + repository: { + async get() { + return [] + }, + async set(projects) { + cachedProjects = projects + }, + async delete() {} + } }) await sut.getProjects() expect(cachedProjects).toEqual(projects) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts new file mode 100644 index 00000000..874caf3d --- /dev/null +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -0,0 +1,1093 @@ +import { + GitHubProjectDataSource + } from "../../src/features/projects/data" + +test("It loads repositories from data source", async () => { + let didLoadRepositories = false + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + didLoadRepositories = true + return [] + } + } + }) + await sut.getProjects() + expect(didLoadRepositories).toBeTruthy() +}) + +test("It maps projects including branches and tags", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + 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).toEqual([{ + id: "foo", + name: "foo", + displayName: "foo", + versions: [{ + id: "main", + name: "main", + specifications: [{ + id: "openapi.yml", + name: "openapi.yml", + url: "/api/blob/acme/foo/openapi.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/main/openapi.yml" + }], + url: "https://github.com/acme/foo/tree/main", + isDefault: true + }, { + id: "1.0", + name: "1.0", + specifications: [{ + id: "openapi.yml", + name: "openapi.yml", + url: "/api/blob/acme/foo/openapi.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/1.0/openapi.yml" + }], + url: "https://github.com/acme/foo/tree/1.0", + isDefault: false + }] + }]) +}) + +test("It removes \"-openapi\" suffix from project name", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + 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("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({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + 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" + }] + } + } + } + }] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects).toEqual([{ + id: "foo", + name: "foo", + displayName: "foo", + versions: [{ + id: "main", + name: "main", + specifications: [{ + id: "foo-service.yml", + name: "foo-service.yml", + url: "/api/blob/acme/foo/foo-service.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/main/foo-service.yml" + }, { + id: "bar-service.yml", + name: "bar-service.yml", + url: "/api/blob/acme/foo/bar-service.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/main/bar-service.yml" + }, { + id: "baz-service.yml", + name: "baz-service.yml", + url: "/api/blob/acme/foo/baz-service.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/main/baz-service.yml" + }], + url: "https://github.com/acme/foo/tree/main", + isDefault: true + }, { + id: "1.0", + name: "1.0", + specifications: [{ + id: "openapi.yml", + name: "openapi.yml", + url: "/api/blob/acme/foo/openapi.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/1.0/openapi.yml" + }], + url: "https://github.com/acme/foo/tree/1.0", + isDefault: false + }] + }]) +}) + +test("It removes \"-openapi\" suffix from project name", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + 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("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({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects.length).toEqual(0) +}) + +test("It filters away branches with no specifications", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + 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: [] + } + }] + } + } + }) + 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({ + dataSource: { + async getRepositories() { + return [{ + 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 image from .shape-config.yml", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + 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: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + 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({ + dataSource: { + async getRepositories() { + return [{ + 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 .shape-config.yml", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + 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: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].id).toEqual("foo") + expect(projects[0].name).toEqual("foo") + expect(projects[0].displayName).toEqual("Hello World") +}) + +test("It reads image from .shape-config.yml", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + 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: [] + } + }] + } + } + }) + 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 .shape-config.yaml", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + 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: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].id).toEqual("foo") + expect(projects[0].name).toEqual("foo") + expect(projects[0].displayName).toEqual("Hello World") +}) + +test("It reads image from .shape-config.yaml", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + 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({ + dataSource: { + async getRepositories() { + return [{ + name: "cathrine", + 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", + 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", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + 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({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + 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" + }] + } + } + } + }] + } + }] + } + } + }) + 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") +}) + +test("It prioritizes main, master, develop, and development branch names when sorting verisons", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + 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" + }] + } + } + } + }] + } + }] + } + } + }) + 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") +}) + +test("It identifies the default branch in returned versions", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + 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: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + const defaultVersionNames = projects[0] + .versions + .filter(e => e.isDefault) + .map(e => e.name) + expect(defaultVersionNames).toEqual(["development"]) +}) diff --git a/__test__/projects/GitHubProjectRepositoryDataSource.test.ts b/__test__/projects/GitHubProjectRepositoryDataSource.test.ts new file mode 100644 index 00000000..7d8d79fd --- /dev/null +++ b/__test__/projects/GitHubProjectRepositoryDataSource.test.ts @@ -0,0 +1,20 @@ +import { + GitHubProjectRepositoryDataSource, + GitHubGraphQLClientRequest + } from "../../src/features/projects/data" + +test("It requests data for the specified organization", async () => { + let sentRequest: GitHubGraphQLClientRequest | undefined + const sut = new GitHubProjectRepositoryDataSource({ + organizationName: "foo", + graphQlClient: { + async graphql(request) { + sentRequest = request + return { search: { results: [] } } + } + } + }) + await sut.getRepositories() + expect(sentRequest).not.toBeUndefined() + expect(sentRequest?.variables.searchQuery).toContain("org:foo") +}) diff --git a/src/composition.ts b/src/composition.ts index 52e5e75d..926d2396 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -8,7 +8,8 @@ import { SessionMutexFactory } from "@/common" import { - GitHubProjectDataSource + GitHubProjectDataSource, + GitHubProjectRepositoryDataSource } from "@/features/projects/data" import { CachingProjectDataSource, @@ -181,13 +182,15 @@ export const projectRepository = new ProjectRepository( projectUserDataRepository ) -export const projectDataSource = new CachingProjectDataSource( - new GitHubProjectDataSource( - userGitHubClient, - GITHUB_ORGANIZATION_NAME - ), - projectRepository -) +export const projectDataSource = new CachingProjectDataSource({ + dataSource: new GitHubProjectDataSource({ + dataSource: new GitHubProjectRepositoryDataSource({ + graphQlClient: userGitHubClient, + organizationName: GITHUB_ORGANIZATION_NAME + }) + }), + repository: projectRepository +}) export const logInHandler = new CompositeLogInHandler([ new CredentialsTransferringLogInHandler({ diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index e055b4e3..bdf126c4 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,4 +1,6 @@ -import { IGitHubClient } from "@/common" +import GitHubProjectRepository, { + GitHubProjectRepositoryRef +} from "./GitHubProjectRepository" import { Project, IProjectConfig, @@ -7,128 +9,25 @@ import { ProjectConfigParser } from "../domain" -type SearchResult = { - 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 +interface IGitHubProjectRepositoryDataSource { + getRepositories(): Promise } -type EdgesContainer = { - readonly edges: Edge[] -} - -type Edge = { - readonly node: T -} - -type Ref = { - readonly name: string - readonly target: { - readonly oid: string - readonly tree: { - readonly entries: File[] - } - } -} - -type File = { - readonly name: string +type GitHubProjectDataSourceConfig = { + readonly dataSource: IGitHubProjectRepositoryDataSource } export default class GitHubProjectDataSource implements IProjectDataSource { - private gitHubClient: IGitHubClient - private organizationName: string + private dataSource: IGitHubProjectRepositoryDataSource - constructor(gitHubClient: IGitHubClient, organizationName: string) { - this.gitHubClient = gitHubClient - this.organizationName = organizationName + constructor(config: GitHubProjectDataSourceConfig) { + this.dataSource = config.dataSource } async getProjects(): Promise { - const request = { - query: ` - query Repositories($searchQuery: String!) { - search(query: $searchQuery, type: REPOSITORY, first: 100) { - results: nodes { - ... on Repository { - name - owner { - login - } - defaultBranchRef { - name - target { - ...on Commit { - oid - } - } - } - configYml: object(expression: "HEAD:.shape-docs.yml") { - ...ConfigParts - } - configYaml: object(expression: "HEAD:.shape-docs.yaml") { - ...ConfigParts - } - branches: refs(refPrefix: "refs/heads/", first: 100) { - ...RefConnectionParts - } - tags: refs(refPrefix: "refs/tags/", first: 100) { - ...RefConnectionParts - } - } - } - } - } - - 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: `org:${this.organizationName} openapi in:name` - } - } - const response = await this.gitHubClient.graphql(request) - return response.search.results.map((searchResult: SearchResult) => { - return this.mapProject(searchResult) + const repositories = await this.dataSource.getRepositories() + return repositories.map(repository => { + return this.mapProject(repository) }) .filter((project: Project) => { return project.versions.length > 0 @@ -138,31 +37,31 @@ export default class GitHubProjectDataSource implements IProjectDataSource { }) } - private mapProject(searchResult: SearchResult): Project { - const config = this.getConfig(searchResult) + private mapProject(repository: GitHubProjectRepository): Project { + const config = this.getConfig(repository) let imageURL: string | undefined if (config && config.image) { - imageURL = this.getGitHubBlobURL( - searchResult.owner.login, - searchResult.name, - config.image, - searchResult.defaultBranchRef.target.oid - ) + imageURL = this.getGitHubBlobURL({ + ownerName: repository.owner.login, + repositoryName: repository.name, + path: config.image, + ref: repository.defaultBranchRef.target.oid + }) } - const defaultName = searchResult.name.replace(/-openapi$/, "") + const defaultName = repository.name.replace(/-openapi$/, "") return { id: defaultName, name: defaultName, displayName: config?.name || defaultName, - versions: this.getVersions(searchResult).filter(version => { + versions: this.getVersions(repository).filter(version => { return version.specifications.length > 0 }), imageURL: imageURL } } - private getConfig(searchResult: SearchResult): IProjectConfig | null { - const yml = searchResult.configYml || searchResult.configYaml + private getConfig(repository: GitHubProjectRepository): IProjectConfig | null { + const yml = repository.configYml || repository.configYaml if (!yml || !yml.text || yml.text.length == 0) { return null } @@ -170,15 +69,24 @@ export default class GitHubProjectDataSource implements IProjectDataSource { return parser.parse(yml.text) } - private getVersions(searchResult: SearchResult): Version[] { - const branchVersions = searchResult.branches.edges.map((edge: Edge) => { - const isDefaultRef = edge.node.target.oid == searchResult.defaultBranchRef.target.oid - return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, edge.node, isDefaultRef) + private getVersions(repository: GitHubProjectRepository): Version[] { + const branchVersions = repository.branches.edges.map(edge => { + const isDefaultRef = edge.node.name == repository.defaultBranchRef.name + return this.mapVersionFromRef({ + ownerName: repository.owner.login, + repositoryName: repository.name, + ref: edge.node, + isDefaultRef + }) }) - const tagVersions = searchResult.tags.edges.map((edge: Edge) => { - return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, edge.node) + const tagVersions = repository.tags.edges.map(edge => { + return this.mapVersionFromRef({ + ownerName: repository.owner.login, + repositoryName: repository.name, + ref: edge.node + }) }) - const defaultBranchName = searchResult.defaultBranchRef.name + const defaultBranchName = repository.defaultBranchRef.name const candidateDefaultBranches = [ defaultBranchName, "main", "master", "develop", "development" ] @@ -201,33 +109,38 @@ export default class GitHubProjectDataSource implements IProjectDataSource { return allVersions } - private mapVersionFromRef( - owner: string, - repository: string, - ref: Ref, - isDefaultRef: boolean = false - ): Version { + private mapVersionFromRef({ + ownerName, + repositoryName, + ref, + isDefaultRef + }: { + ownerName: string + repositoryName: string + ref: GitHubProjectRepositoryRef + isDefaultRef?: boolean + }): Version { const specifications = ref.target.tree.entries.filter(file => { return this.isOpenAPISpecification(file.name) }).map(file => { return { id: file.name, name: file.name, - url: this.getGitHubBlobURL( - owner, - repository, - file.name, - ref.target.oid - ), - editURL: `https://github.com/${owner}/${repository}/edit/${ref.name}/${file.name}` + url: this.getGitHubBlobURL({ + ownerName, + repositoryName, + path: file.name, + ref: ref.target.oid + }), + editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${file.name}` } }) return { id: ref.name, name: ref.name, specifications: specifications, - url: `https://github.com/${owner}/${repository}/tree/${ref.name}`, - isDefault: isDefaultRef + url: `https://github.com/${ownerName}/${repositoryName}/tree/${ref.name}`, + isDefault: isDefaultRef || false } } @@ -237,7 +150,17 @@ export default class GitHubProjectDataSource implements IProjectDataSource { ) } - private getGitHubBlobURL(owner: string, repository: string, path: string, ref: string): string { - return `/api/blob/${owner}/${repository}/${path}?ref=${ref}` + private getGitHubBlobURL({ + ownerName, + repositoryName, + path, + ref + }: { + ownerName: string + repositoryName: string + path: string + ref: string + }): string { + return `/api/blob/${ownerName}/${repositoryName}/${path}?ref=${ref}` } } diff --git a/src/features/projects/data/GitHubProjectRepository.ts b/src/features/projects/data/GitHubProjectRepository.ts new file mode 100644 index 00000000..20b88af6 --- /dev/null +++ b/src/features/projects/data/GitHubProjectRepository.ts @@ -0,0 +1,44 @@ +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/GitHubProjectRepositoryDataSource.ts b/src/features/projects/data/GitHubProjectRepositoryDataSource.ts new file mode 100644 index 00000000..2373df7a --- /dev/null +++ b/src/features/projects/data/GitHubProjectRepositoryDataSource.ts @@ -0,0 +1,102 @@ +import GitHubProjectRepository from "./GitHubProjectRepository" + +export type GitHubGraphQLClientRequest = { + readonly query: string + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + readonly variables: {[key: string]: any} +} + +export type GitHubGraphQLClientResponse = { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + readonly [key: string]: any +} + +interface IGitHubGraphQLClient { + graphql(request: GitHubGraphQLClientRequest): Promise +} + +type GitHubProjectRepositoryDataSourceConfig = { + readonly graphQlClient: IGitHubGraphQLClient + readonly organizationName: string +} + +export default class GitHubProjectRepositoryDataSource { + private graphQlClient: IGitHubGraphQLClient + private organizationName: string + + constructor(config: GitHubProjectRepositoryDataSourceConfig) { + this.graphQlClient = config.graphQlClient + this.organizationName = config.organizationName + } + + async getRepositories(): Promise { + const request = { + query: ` + query Repositories($searchQuery: String!) { + search(query: $searchQuery, type: REPOSITORY, first: 100) { + results: nodes { + ... on Repository { + name + owner { + login + } + defaultBranchRef { + name + target { + ...on Commit { + oid + } + } + } + configYml: object(expression: "HEAD:.shape-docs.yml") { + ...ConfigParts + } + configYaml: object(expression: "HEAD:.shape-docs.yaml") { + ...ConfigParts + } + branches: refs(refPrefix: "refs/heads/", first: 100) { + ...RefConnectionParts + } + tags: refs(refPrefix: "refs/tags/", first: 100) { + ...RefConnectionParts + } + } + } + } + } + + 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: `org:${this.organizationName} openapi in:name` + } + } + const response = await this.graphQlClient.graphql(request) + return response.search.results + } +} diff --git a/src/features/projects/data/index.ts b/src/features/projects/data/index.ts index 8199a31a..936a7bd9 100644 --- a/src/features/projects/data/index.ts +++ b/src/features/projects/data/index.ts @@ -1,2 +1,4 @@ export { default as GitHubProjectDataSource } from "./GitHubProjectDataSource" +export { default as GitHubProjectRepositoryDataSource } from "./GitHubProjectRepositoryDataSource" +export * from "./GitHubProjectRepositoryDataSource" export { default as useProjects } from "./useProjects" diff --git a/src/features/projects/data/useProjects.ts b/src/features/projects/data/useProjects.ts index a0b2cb16..8f1d6f11 100644 --- a/src/features/projects/data/useProjects.ts +++ b/src/features/projects/data/useProjects.ts @@ -1,7 +1,7 @@ "use client" import useSWR from "swr" -import { fetcher } from "@/common" +import { fetcher } from "../../../common" import { Project } from "../domain" type ProjectContainer = { projects: Project[] } diff --git a/src/features/projects/domain/CachingProjectDataSource.ts b/src/features/projects/domain/CachingProjectDataSource.ts index a95dae13..f47f4160 100644 --- a/src/features/projects/domain/CachingProjectDataSource.ts +++ b/src/features/projects/domain/CachingProjectDataSource.ts @@ -2,16 +2,18 @@ import Project from "./Project" import IProjectDataSource from "./IProjectDataSource" import IProjectRepository from "./IProjectRepository" +type CachingProjectDataSourceConfig = { + readonly dataSource: IProjectDataSource + readonly repository: IProjectRepository +} + export default class CachingProjectDataSource implements IProjectDataSource { private dataSource: IProjectDataSource private repository: IProjectRepository - constructor( - dataSource: IProjectDataSource, - repository: IProjectRepository - ) { - this.dataSource = dataSource - this.repository = repository + constructor(config: CachingProjectDataSourceConfig) { + this.dataSource = config.dataSource + this.repository = config.repository } async getProjects(): Promise {