diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b5165a7..32b14341 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,10 +2,24 @@ name: Build on: workflow_dispatch: {} pull_request: - branches: [main] + branches: [main, develop] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + AUTH0_SECRET: 336f7a926310cff425cea29556dce2a98859b8d234aa27968696c2e6f1cb7d34 + AUTH0_BASE_URL: http://dev.local:3000 + AUTH0_ISSUER_BASE_URL: https://shape-docs-dev.eu.auth0.com + AUTH0_CLIENT_ID: this-is-our-client-id + AUTH0_CLIENT_SECRET: this-is-our-client-secret + AUTH0_MANAGEMENT_DOMAIN: shape-docs-dev.eu.auth0.com + AUTH0_MANAGEMENT_CLIENT_ID: this-is-our-management-client-id + AUTH0_MANAGEMENT_CLIENT_SECRET: this-is-our-management-client-secret + GITHUB_CLIENT_ID: this-is-our-github-client-id + GITHUB_CLIENT_SECRET: this-is-our-github-client-secret + GITHUB_APP_ID: 12345 + GITHUB_PRIVATE_KEY_BASE_64: LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KYjNCbGJuTnphQzFyWlhrdGRqRUFBQUFBQkc1dmJtVUFBQUFFYm05dVpRQUFBQUFBQUFBQkFBQUFNd0FBQUF0emMyZ3RaVwpReU5UVXhPUUFBQUNCS2NaL3UyL3dubDA4R1BjeXdiSVc2QVdKdnhVL2o0V0g2UnkxODVPSHZDd0FBQUpBYW01MzdHcHVkCit3QUFBQXR6YzJndFpXUXlOVFV4T1FBQUFDQktjWi91Mi93bmwwOEdQY3l3YklXNkFXSnZ4VS9qNFdINlJ5MTg1T0h2Q3cKQUFBRUJ6T2htRGpuY3R0QSt3ai9USHRnWnN6MklRWjdLM2ovb0Z1OEZBNTMxTDhrcHhuKzdiL0NlWFR3WTl6TEJzaGJvQgpZbS9GVCtQaFlmcEhMWHprNGU4TEFBQUFDMlp2YjBCb1lYQmxMbVJyQVFJPQotLS0tLUVORCBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0K + GITHUB_WEBHOOK_SECRET: super-duper-secret jobs: build: name: Build @@ -16,7 +30,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20 - name: Install Dependencies run: npm install - name: Build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a966d375..132067af 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: workflow_dispatch: {} pull_request: - branches: [main] + branches: [main, develop] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true diff --git a/README.md b/README.md index 632d828b..7aa13dfe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ +

+ +

+ # shape-docs Portal displaying our projects that are documented with OpenAPI. +[![Build](https://github.com/shapehq/shape-docs/actions/workflows/build.yml/badge.svg)](https://github.com/shapehq/shape-docs/actions/workflows/build.yml) [![Test](https://github.com/shapehq/shape-docs/actions/workflows/test.yml/badge.svg)](https://github.com/shapehq/shape-docs/actions/workflows/test.yml) ## Running the App Locally @@ -9,6 +14,7 @@ Portal displaying our projects that are documented with OpenAPI. Create a file named `.env.local` in the root of the project with the following contents. Make sure to replace any placeholders and generate a random secret using OpenSSL. ``` +SHAPE_DOCS_BASE_URL='https://docs.shapetools.io' AUTH0_SECRET='use [openssl rand -hex 32] to generate a 32 bytes value' AUTH0_BASE_URL='http://dev.local:3000' AUTH0_ISSUER_BASE_URL='https://shape-docs-dev.eu.auth0.com' @@ -22,9 +28,31 @@ GITHUB_CLIENT_SECRET='GitHub App client secret' GITHUB_APP_ID='the GitHub App id' GITHUB_PRIVATE_KEY_BASE_64='base 64 encoded version of the private key' GITHUB_WEBHOOK_SECRET='preshared secret also put in app conf in GitHub' +GITHUB_WEBHOK_REPOSITORY_ALLOWLIST='' +GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST='' ``` -Note that you need the following two Auth0 apps. +Each environment variable is described in the table below. + +|Environment Variable|Description| +|-|-| +|SHAPE_DOCS_BASE_URL|The URL where Shape Docs is hosted.| +|AUTH0_SECRET|A long secret value used to encrypt the session cookie. Generate it using `openssl rand -hex 32`.|AUTH0_BASE_URL|The base URL of your Auth0 application. `http://dev.local:3000` during development.| +|AUTH0_ISSUER_BASE_URL|The URL of your Auth0 tenant domain.| +|AUTH0_CLIENT_ID|The client ID of your default Auth0 application.| +|AUTH0_CLIENT_SECRET|The client secret of your default Auth0 application.| +|AUTH0_MANAGEMENT_DOMAIN|The URL of your Auth0 tenant domain. It is key that this does not contain "http" or "https".| +|AUTH0_MANAGEMENT_CLIENT_ID|The client ID of your Auth0 Machine to Machine application.| +|AUTH0_MANAGEMENT_CLIENT_SECRET|The client secret of your Machine to Machine Auth0 application.| +|GITHUB_CLIENT_ID|The client ID of your GitHub app.| +|GITHUB_CLIENT_SECRET|The client secret of your GitHub app.| +|GITHUB_APP_ID|The ID of your GitHub app.| +|GITHUB_PRIVATE_KEY_BASE_64|Your GitHub app's private key encoded to base 64. Can be created using `cat my-key.pem | base64 | pbcopy`.| +|GITHUB_WEBHOOK_SECRET|Secret shared with the GitHub app to validate a webhook call.| +|GITHUB_WEBHOK_REPOSITORY_ALLOWLIST|Comma-separated list of repositories from which webhook calls should be accepted. Leave empty to accept calls from all repositories.| +|GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST|Comma-separated list of repositories from which webhook calls should be ignored. The list of disallowed repositories takes precedence over the list of allowed repositories.| + +You need the following two Auth0 apps. | |Type|Description| |-|-|-| diff --git a/__test__/auth/AccessTokenService.test.ts b/__test__/auth/AccessTokenService.test.ts new file mode 100644 index 00000000..907e7914 --- /dev/null +++ b/__test__/auth/AccessTokenService.test.ts @@ -0,0 +1,104 @@ +import AccessTokenService from "../../src/features/auth/domain/AccessTokenService" +import { IOAuthToken } from "../../src/features/auth/domain/IOAuthTokenRepository" + +test("It reads the access token from the repository", async () => { + const sut = new AccessTokenService({ + async getOAuthToken() { + return { + accessToken: "foo", + refreshToken: "bar", + accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000), + refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000) + } + }, + async storeOAuthToken(_token: IOAuthToken) {} + }, { + async refreshAccessToken(_refreshToken: string) { + return { + accessToken: "foo", + refreshToken: "bar", + accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000), + refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000) + } + } + }) + const accessToken = await sut.getAccessToken() + expect(accessToken).toBe("foo") +}) + +test("It refreshes an expired access token", async () => { + const sut = new AccessTokenService({ + async getOAuthToken() { + return { + accessToken: "old", + refreshToken: "bar", + accessTokenExpiryDate: new Date(new Date().getTime() - 3600 * 1000), + refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000) + } + }, + async storeOAuthToken(_token: IOAuthToken) {} + }, { + async refreshAccessToken(_refreshToken: string) { + return { + accessToken: "new", + refreshToken: "bar", + accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000), + refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000) + } + } + }) + const accessToken = await sut.getAccessToken() + expect(accessToken).toBe("new") +}) + +test("It stores the refreshed access token", async () => { + let didStoreRefreshedToken = false + const sut = new AccessTokenService({ + async getOAuthToken() { + return { + accessToken: "old", + refreshToken: "bar", + accessTokenExpiryDate: new Date(new Date().getTime() - 3600 * 1000), + refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000) + } + }, + async storeOAuthToken(_token: IOAuthToken) { + didStoreRefreshedToken = true + } + }, { + async refreshAccessToken(_refreshToken: string) { + return { + accessToken: "new", + refreshToken: "bar", + accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000), + refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000) + } + } + }) + await sut.getAccessToken() + expect(didStoreRefreshedToken).toBeTruthy() +}) + +test("It errors when the refresh token has expired", async () => { + const sut = new AccessTokenService({ + async getOAuthToken() { + return { + accessToken: "old", + refreshToken: "bar", + accessTokenExpiryDate: new Date(new Date().getTime() - 3600 * 1000), + refreshTokenExpiryDate: new Date(new Date().getTime() - 3600 * 1000) + } + }, + async storeOAuthToken(_token: IOAuthToken) {} + }, { + async refreshAccessToken(_refreshToken: string) { + return { + accessToken: "new", + refreshToken: "bar", + accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000), + refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000) + } + } + }) + await expect(sut.getAccessToken()).rejects.toThrow() +}) \ No newline at end of file diff --git a/__test__/auth/IdentityAccessTokenProvider.test.ts b/__test__/auth/IdentityAccessTokenProvider.test.ts deleted file mode 100644 index 120e005f..00000000 --- a/__test__/auth/IdentityAccessTokenProvider.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IdentityAccessTokenProvider } from "../../src/lib/auth/IdentityAccessTokenProvider" - -test("It finds the access token for the specified provider", async () => { - const sut = new IdentityAccessTokenProvider({ - async getUserDetails() { - return {identities: [{provider: "github", accessToken: "foo"}]} - } - }, "github") - const accessToken = await sut.getAccessToken() - expect(accessToken).toBe("foo") -}) - -test("It errors when access token could not be found for provider", async () => { - const sut = new IdentityAccessTokenProvider({ - async getUserDetails() { - return {identities: [{provider: "google", accessToken: "foo"}]} - } - }, "github") - expect(sut.getAccessToken()).rejects.toThrow() -}) - -test("It errors when there are no identities", async () => { - const sut = new IdentityAccessTokenProvider({ - async getUserDetails() { - return {identities: []} - } - }, "github") - expect(sut.getAccessToken()).rejects.toThrow() -}) diff --git a/__test__/hooks/ExistingCommentCheckingPullRequestEventHandler.test.ts b/__test__/hooks/ExistingCommentCheckingPullRequestEventHandler.test.ts new file mode 100644 index 00000000..4b80d11e --- /dev/null +++ b/__test__/hooks/ExistingCommentCheckingPullRequestEventHandler.test.ts @@ -0,0 +1,119 @@ +import ExistingCommentCheckingPullRequestEventHandler from "../../src/features/hooks/domain/ExistingCommentCheckingPullRequestEventHandler" + +test("It fetches comments from the repository", async () => { + let didFetchComments = false + const sut = new ExistingCommentCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) {} + }, { + async getComments(_operation) { + didFetchComments = true + return [] + }, + async addComment(_operation) {} + }, "https://docs.shapetools.io") + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didFetchComments).toBeTruthy() +}) + +test("It does calls decorated event handler if a comment does not exist in the repository", async () => { + let didCallEventHandler = false + const sut = new ExistingCommentCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) { + didCallEventHandler = true + } + }, { + async getComments(_operation) { + return [] + }, + async addComment(_operation) {} + }, "https://docs.shapetools.io") + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didCallEventHandler).toBeTruthy() +}) + +test("It does not call the event handler if a comment already exists in the repository", async () => { + let didCallEventHandler = false + const sut = new ExistingCommentCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) { + didCallEventHandler = true + } + }, { + async getComments(_operation) { + return [{ + body: "The documentation is available on https://docs.shapetools.io", + isFromBot: true + }] + }, + async addComment(_operation) {} + }, "https://docs.shapetools.io") + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didCallEventHandler).toBeFalsy() +}) + +test("It calls the event handler if a comment exists matching the needle domain but that comment is not from a bot", async () => { + let didCallEventHandler = false + const sut = new ExistingCommentCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) { + didCallEventHandler = true + } + }, { + async getComments(_operation) { + return [{ + body: "The documentation is available on https://docs.shapetools.io", + isFromBot: false + }] + }, + async addComment(_operation) {} + }, "https://docs.shapetools.io") + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didCallEventHandler).toBeTruthy() +}) + +test("It calls the event handler if the repository contains a comment from a bot but that comment does not contain the needle domain", async () => { + let didCallEventHandler = false + const sut = new ExistingCommentCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) { + didCallEventHandler = true + } + }, { + async getComments(_operation) { + return [{ + body: "Hello world!", + isFromBot: true + }] + }, + async addComment(_operation) {} + }, "https://docs.shapetools.io") + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didCallEventHandler).toBeTruthy() +}) diff --git a/__test__/hooks/GitHubCommentFactory.test.ts b/__test__/hooks/GitHubCommentFactory.test.ts new file mode 100644 index 00000000..8347ab03 --- /dev/null +++ b/__test__/hooks/GitHubCommentFactory.test.ts @@ -0,0 +1,8 @@ +import GitHubCommentFactory from "../../src/features/hooks/domain/GitHubCommentFactory" + +test("It includes a link to the documentation", async () => { + const text = GitHubCommentFactory.makeDocumentationPreviewReadyComment( + "https://docs.shapetools.io/foo/bar" + ) + expect(text).toContain("https://docs.shapetools.io/foo/bar") +}) \ No newline at end of file diff --git a/__test__/hooks/PostCommentPullRequestEventHandler.test.ts b/__test__/hooks/PostCommentPullRequestEventHandler.test.ts new file mode 100644 index 00000000..2b2fe619 --- /dev/null +++ b/__test__/hooks/PostCommentPullRequestEventHandler.test.ts @@ -0,0 +1,61 @@ +import PostCommentPullRequestEventHandler from "../../src/features/hooks/domain/PostCommentPullRequestEventHandler" + +test("It adds a comment to the repository", async () => { + let didAddComment = false + const sut = new PostCommentPullRequestEventHandler({ + async getComments(_operation) { + return [] + }, + async addComment(_operation) { + didAddComment = true + } + }, "https://docs.shapetools.io") + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didAddComment).toBeTruthy() +}) + +test("It adds a comment containing a link to the documentation", async () => { + let commentBody: string | undefined + const sut = new PostCommentPullRequestEventHandler({ + async getComments(_operation) { + return [] + }, + async addComment(operation) { + commentBody = operation.body + } + }, "https://docs.shapetools.io") + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(commentBody).toContain("https://docs.shapetools.io/foo/bar") +}) + +test("It removes the \"openapi\" suffix of the repository name", async () => { + let commentBody: string | undefined + const sut = new PostCommentPullRequestEventHandler({ + async getComments(_operation) { + return [] + }, + async addComment(operation) { + commentBody = operation.body + } + }, "https://docs.shapetools.io") + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo-openapi", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(commentBody).toContain("https://docs.shapetools.io/foo/bar") +}) diff --git a/__test__/hooks/RepositoryNameCheckingPullRequestEventHandler.test.ts b/__test__/hooks/RepositoryNameCheckingPullRequestEventHandler.test.ts new file mode 100644 index 00000000..f527577f --- /dev/null +++ b/__test__/hooks/RepositoryNameCheckingPullRequestEventHandler.test.ts @@ -0,0 +1,103 @@ +import RepositoryNameCheckingPullRequestEventHandler from "../../src/features/hooks/domain/RepositoryNameCheckingPullRequestEventHandler" + +test("It does not call event handler when repository name does not have \"-openapi\" suffix", async () => { + let didCallEventHandler = false + const sut = new RepositoryNameCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) { + didCallEventHandler = true + } + }, [], []) + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didCallEventHandler).toBeFalsy() +}) + +test("It does not call event handler when repository name contains \"-openapi\" but it is not the last part of the repository name", async () => { + let didCallEventHandler = false + const sut = new RepositoryNameCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) { + didCallEventHandler = true + } + }, [], []) + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo-openapi-bar", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didCallEventHandler).toBeFalsy() +}) + +test("It calls event handler when no repositories have been allowed or disallowed", async () => { + let didCallEventHandler = false + const sut = new RepositoryNameCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) { + didCallEventHandler = true + } + }, [], []) + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo-openapi", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didCallEventHandler).toBeTruthy() +}) + +test("It does not call event handler for repository that is not on the allowlist", async () => { + let didCallEventHandler = false + const sut = new RepositoryNameCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) { + didCallEventHandler = true + } + }, ["example-openapi"], []) + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "foo", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didCallEventHandler).toBeFalsy() +}) + +test("It does not call event handler for repository that is on the disallowlist", async () => { + let didCallEventHandler = false + const sut = new RepositoryNameCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) { + didCallEventHandler = true + } + }, [], ["example-openapi"]) + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "example-openapi", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didCallEventHandler).toBeFalsy() +}) + +test("It lets the disallowlist takes precedence over the allowlist", async () => { + let didCallEventHandler = false + const sut = new RepositoryNameCheckingPullRequestEventHandler({ + async pullRequestOpened(_event) { + didCallEventHandler = true + } + }, ["example-openapi"], ["example-openapi"]) + await sut.pullRequestOpened({ + appInstallationId: 42, + repositoryOwner: "shapehq", + repositoryName: "example-openapi", + ref: "bar", + pullRequestNumber: 1337 + }) + expect(didCallEventHandler).toBeFalsy() +}) diff --git a/__test__/projects/ProjectConfigParser.test.ts b/__test__/projects/ProjectConfigParser.test.ts index ccb07fee..ba5c5fdc 100644 --- a/__test__/projects/ProjectConfigParser.test.ts +++ b/__test__/projects/ProjectConfigParser.test.ts @@ -1,4 +1,4 @@ -import { ProjectConfigParser } from "../../src/lib/projects/ProjectConfigParser" +import ProjectConfigParser from "../../src/features/projects/domain/ProjectConfigParser" test("It parses an empty string", async () => { const sut = new ProjectConfigParser() diff --git a/__test__/projects/ProjectPageState.test.ts b/__test__/projects/ProjectPageState.test.ts new file mode 100644 index 00000000..a67bf3dd --- /dev/null +++ b/__test__/projects/ProjectPageState.test.ts @@ -0,0 +1,276 @@ +import { + getProjectPageState, + ProjectPageState +} from "../../src/features/projects/domain/ProjectPageState" + +test("It enters the loading state", async () => { + const sut = getProjectPageState({ isLoading: true }) + expect(sut.state).toEqual(ProjectPageState.LOADING) +}) + +test("It enters the error state", async () => { + const sut = getProjectPageState({ + isLoading: false, + error: new Error("foo") + }) + expect(sut.state).toEqual(ProjectPageState.ERROR) + expect(sut.error).toEqual(new Error("foo")) +}) + +test("It gracefully errors when no project has been selected", async () => { + const sut = getProjectPageState({ + projects: [{ + id: "foo", + name: "foo", + versions: [] + }, { + id: "bar", + name: "bar", + versions: [] + }] + }) + expect(sut.state).toEqual(ProjectPageState.NO_PROJECT_SELECTED) +}) + +test("It selects the first project when there is only one project", async () => { + const sut = getProjectPageState({ + projects: [{ + id: "foo", + name: "foo", + versions: [{ + id: "bar", + name: "bar", + specifications: [{ + id: "hello", + name: "hello.yml", + url: "https://example.com/hello.yml" + }] + }] + }] + }) + expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION) + expect(sut.selection!.project.id).toEqual("foo") + expect(sut.selection!.version.id).toEqual("bar") + expect(sut.selection!.specification.id).toEqual("hello") +}) + +test("It selects the first version and specification of the specified project", async () => { + const sut = getProjectPageState({ + selectedProjectId: "bar", + projects: [{ + id: "foo", + name: "foo", + versions: [] + }, { + id: "bar", + name: "bar", + versions: [{ + id: "baz1", + name: "baz1", + specifications: [{ + id: "hello1", + name: "hello1.yml", + url: "https://example.com/hello.yml" + }, { + id: "hello2", + name: "hello2.yml", + url: "https://example.com/hello.yml" + }] + }, { + id: "baz2", + name: "baz2", + specifications: [] + }] + }] + }) + expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION) + expect(sut.selection!.project.id).toEqual("bar") + expect(sut.selection!.version.id).toEqual("baz1") + expect(sut.selection!.specification.id).toEqual("hello1") +}) + +test("It selects the first specification of the specified project and version", async () => { + const sut = getProjectPageState({ + selectedProjectId: "bar", + selectedVersionId: "baz2", + projects: [{ + id: "foo", + name: "foo", + versions: [] + }, { + id: "bar", + name: "bar", + versions: [{ + id: "baz1", + name: "baz1", + specifications: [] + }, { + id: "baz2", + name: "baz2", + specifications: [{ + id: "hello1", + name: "hello1.yml", + url: "https://example.com/hello.yml" + }] + }] + }] + }) + expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION) + expect(sut.selection!.project.id).toEqual("bar") + expect(sut.selection!.version.id).toEqual("baz2") + expect(sut.selection!.specification.id).toEqual("hello1") +}) + +test("It selects the specification of the specified version", async () => { + const sut = getProjectPageState({ + selectedProjectId: "bar", + selectedVersionId: "baz2", + projects: [{ + id: "foo", + name: "foo", + versions: [] + }, { + id: "bar", + name: "bar", + versions: [{ + id: "baz1", + name: "baz1", + specifications: [] + }, { + id: "baz2", + name: "baz2", + specifications: [{ + id: "hello1", + name: "hello1.yml", + url: "https://example.com/hello.yml" + }, { + id: "hello2", + name: "hello2.yml", + url: "https://example.com/hello.yml" + }] + }] + }] + }) + expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION) + expect(sut.selection!.project.id).toEqual("bar") + expect(sut.selection!.version.id).toEqual("baz2") + expect(sut.selection!.specification.id).toEqual("hello1") +}) + +test("It selects the specified project, version, and specification", async () => { + const sut = getProjectPageState({ + selectedProjectId: "bar", + selectedVersionId: "baz2", + selectedSpecificationId: "hello2", + projects: [{ + id: "foo", + name: "foo", + versions: [] + }, { + id: "bar", + name: "bar", + versions: [{ + id: "baz1", + name: "baz1", + specifications: [] + }, { + id: "baz2", + name: "baz2", + specifications: [{ + id: "hello1", + name: "hello1.yml", + url: "https://example.com/hello.yml" + }, { + id: "hello2", + name: "hello2.yml", + url: "https://example.com/hello.yml" + }] + }] + }] + }) + expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION) + expect(sut.selection!.project.id).toEqual("bar") + expect(sut.selection!.version.id).toEqual("baz2") + expect(sut.selection!.specification.id).toEqual("hello2") +}) + +test("It errors when the selected project cannot be found", async () => { + const sut = getProjectPageState({ + selectedProjectId: "foo", + projects: [{ + id: "bar", + name: "bar", + versions: [] + }] + }) + expect(sut.state).toEqual(ProjectPageState.PROJECT_NOT_FOUND) +}) + +test("It errors when the selected version cannot be found", async () => { + const sut = getProjectPageState({ + selectedProjectId: "foo", + selectedVersionId: "bar", + projects: [{ + id: "foo", + name: "foo", + versions: [{ + id: "baz", + name: "baz", + specifications: [] + }] + }] + }) + expect(sut.state).toEqual(ProjectPageState.VERSION_NOT_FOUND) +}) + +test("It errors when the selected specification cannot be found", async () => { + const sut = getProjectPageState({ + selectedProjectId: "foo", + selectedVersionId: "bar", + selectedSpecificationId: "baz", + projects: [{ + id: "foo", + name: "foo", + versions: [{ + id: "bar", + name: "bar", + specifications: [{ + id: "hello", + name: "hello.yml", + url: "https://example.com/hello.yml" + }] + }] + }] + }) + expect(sut.state).toEqual(ProjectPageState.SPECIFICATION_NOT_FOUND) +}) + +test("It errors when the selected project has no versions", async () => { + const sut = getProjectPageState({ + selectedProjectId: "foo", + projects: [{ + id: "foo", + name: "foo", + versions: [] + }] + }) + expect(sut.state).toEqual(ProjectPageState.VERSION_NOT_FOUND) +}) + +test("It errors when the selected version has no specifications", async () => { + const sut = getProjectPageState({ + selectedProjectId: "foo", + selectedVersionId: "bar", + projects: [{ + id: "foo", + name: "foo", + versions: [{ + id: "bar", + name: "bar", + specifications: [] + }] + }] + }) + expect(sut.state).toEqual(ProjectPageState.SPECIFICATION_NOT_FOUND) +}) + diff --git a/__test__/projects/projectNavigator.test.ts b/__test__/projects/projectNavigator.test.ts new file mode 100644 index 00000000..9e601d2c --- /dev/null +++ b/__test__/projects/projectNavigator.test.ts @@ -0,0 +1,181 @@ +import ProjectPageSelection from "../../src/features/projects/domain/ProjectPageSelection" +import projectNavigator from "../../src/features/projects/domain/projectNavigator" + +test("It navigates to first specification when changing version", async () => { + const selection: ProjectPageSelection = { + project: { + id: "foo", + name: "foo", + versions: [{ + id: "bar", + name: "bar", + specifications: [{ + id: "baz.yml", + name: "baz.yml", + url: "https://example.com/baz.yml" + }] + }, { + id: "hello", + name: "hello", + specifications: [{ + id: "world.yml", + name: "world.yml", + url: "https://example.com/world.yml" + }] + }] + }, + version: { + id: "bar", + name: "bar", + specifications: [] + }, + specification: { + id: "baz.yml", + name: "baz.yml", + url: "https://example.com/baz.yml" + } + } + let pushedPath: string | undefined + const router = { + push: (path: string) => { + pushedPath = path + }, + replace: (_path: string) => {} + } + projectNavigator.navigateToVersion(selection, "hello", router) + expect(pushedPath).toEqual("/foo/hello/world.yml") +}) + +test("It navigates when selecting specification", async () => { + const selection: ProjectPageSelection = { + project: { + id: "foo", + name: "foo", + versions: [{ + id: "bar", + name: "bar", + specifications: [{ + id: "hello.yml", + name: "hello.yml", + url: "https://example.com/hello.yml" + }] + }] + }, + version: { + id: "bar", + name: "bar", + specifications: [{ + id: "hello.yml", + name: "hello.yml", + url: "https://example.com/hello.yml" + }] + }, + specification: { + id: "baz.yml", + name: "baz.yml", + url: "https://example.com/baz.yml" + } + } + let pushedPath: string | undefined + const router = { + push: (path: string) => { + pushedPath = path + }, + replace: (_path: string) => {} + } + projectNavigator.navigateToSpecification(selection, "hello.yml", router) + expect(pushedPath).toEqual("/foo/bar/hello.yml") +}) + +test("It navigates even when new specification could not be found", async () => { + // We allow navigating to a specification that could not be found + // and rely on the view to show an error saying that the specification + // could not be found. + const selection: ProjectPageSelection = { + project: { + id: "foo", + name: "foo", + versions: [] + }, + version: { + id: "bar", + name: "bar", + specifications: [] + }, + specification: { + id: "baz.yml", + name: "baz.yml", + url: "https://example.com/baz.yml" + } + } + let pushedPath: string | undefined + const router = { + push: (path: string) => { + pushedPath = path + }, + replace: (_path: string) => {} + } + projectNavigator.navigateToSpecification(selection, "hello.yml", router) + expect(pushedPath).toBe("/foo/bar/hello.yml") +}) + +test("It finds a specification with the same name when changing version", async () => { + const selection: ProjectPageSelection = { + project: { + id: "foo", + name: "foo", + versions: [{ + id: "bar", + name: "bar", + specifications: [{ + id: "hello.yml", + name: "hello.yml", + url: "https://example.com/hello.yml" + }, { + id: "earth.yml", + name: "earth.yml", + url: "https://example.com/earth.yml" + }] + }, { + id: "baz", + name: "baz", + specifications: [{ + id: "moon.yml", + name: "moon.yml", + url: "https://example.com/moon.yml" + }, { + id: "saturn.yml", + name: "saturn.yml", + url: "https://example.com/saturn.yml" + }, { + id: "earth.yml", + name: "earth.yml", + url: "https://example.com/earth.yml" + }, { + id: "jupiter.yml", + name: "jupiter.yml", + url: "https://example.com/jupiter.yml" + }] + }] + }, + version: { + id: "bar", + name: "bar", + specifications: [] + }, + specification: { + id: "earth.yml", + name: "earth.yml", + url: "https://example.com/earth.yml" + } + } + let pushedPath: string | undefined + const router = { + push: (path: string) => { + pushedPath = path + }, + replace: (_path: string) => {} + } + projectNavigator.navigateToVersion(selection, "baz", router) + expect(pushedPath).toEqual("/foo/baz/earth.yml") +}) \ No newline at end of file diff --git a/logo.png b/logo.png new file mode 100644 index 00000000..9895edf6 Binary files /dev/null and b/logo.png differ diff --git a/package-lock.json b/package-lock.json index 6d8f1b8e..9916eef2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,8 @@ "@octokit/core": "^5.0.1", "@octokit/webhooks": "^12.0.3", "auth0": "^4.0.1", - "axios": "^1.5.1", "core-js": "^3.33.0", + "encoding": "^0.1.13", "mobx": "^6.10.2", "next": "13.5.4", "octokit": "^3.1.1", @@ -27,6 +27,8 @@ "redoc": "^2.1.2", "styled-components": "^6.0.8", "swagger-ui-react": "^5.9.0", + "swr": "^2.2.4", + "usehooks-ts": "^2.9.1", "yaml": "^2.3.2" }, "devDependencies": { @@ -42,6 +44,10 @@ "tailwindcss": "^3", "ts-jest": "^29.1.1", "typescript": "^5" + }, + "engines": { + "node": "20.8.1", + "npm": "10.1.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -5995,6 +6001,14 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -7293,6 +7307,17 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -11040,6 +11065,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -11742,6 +11772,18 @@ "node": ">= 6" } }, + "node_modules/swr": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.4.tgz", + "integrity": "sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==", + "dependencies": { + "client-only": "^0.0.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/tailwindcss": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz", @@ -12323,6 +12365,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/usehooks-ts": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.9.1.tgz", + "integrity": "sha512-2FAuSIGHlY+apM9FVlj8/oNhd+1y+Uwv5QNkMQz1oSfdHk4PXo1qoCw9I5M7j0vpH8CSWFJwXbVPeYDjLCx9PA==", + "engines": { + "node": ">=16.15.0", + "npm": ">=8" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 034fbdcc..ccf78b39 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "shape-docs", "version": "0.1.0", "private": true, + "engines": { + "node": "20.8.1", + "npm": "10.1.0" + }, "scripts": { "dev": "next dev", "build": "next build", @@ -19,8 +23,8 @@ "@octokit/core": "^5.0.1", "@octokit/webhooks": "^12.0.3", "auth0": "^4.0.1", - "axios": "^1.5.1", "core-js": "^3.33.0", + "encoding": "^0.1.13", "mobx": "^6.10.2", "next": "13.5.4", "octokit": "^3.1.1", @@ -29,6 +33,8 @@ "redoc": "^2.1.2", "styled-components": "^6.0.8", "swagger-ui-react": "^5.9.0", + "swr": "^2.2.4", + "usehooks-ts": "^2.9.1", "yaml": "^2.3.2" }, "devDependencies": { diff --git a/public/redocly.png b/public/redocly.png deleted file mode 100644 index bc3c35dc..00000000 Binary files a/public/redocly.png and /dev/null differ diff --git a/public/shape2023.svg b/public/shape2023.svg deleted file mode 100644 index 2f9aece0..00000000 --- a/public/shape2023.svg +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/swagger.png b/public/swagger.png deleted file mode 100644 index 9776be93..00000000 Binary files a/public/swagger.png and /dev/null differ diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx index aa4c7d52..5c2cf968 100644 --- a/src/app/[...slug]/page.tsx +++ b/src/app/[...slug]/page.tsx @@ -1,91 +1,26 @@ -import ProjectListComponent from "@/lib/components/ProjectListComponent"; -import UserComponent from "@/lib/components/UserComponent"; -import DocumentationViewerPage from "@/lib/pages/DocumentationViewerPage"; -import { - userProvider, - projectRepository, - gitHubOpenApiSpecificationRepository, - githubVersionRepository, -} from "../startup"; -import App from "@/lib/pages/App"; -import OpenApiSpecificationsComponent from "@/lib/components/OpenApiSpecificationsComponent"; -import VersionsComponent from "@/lib/components/VersionsComponent"; -import { getProject, getSpecification, getVersion } from "@/lib/utils/UrlUtils"; -import { IOpenApiSpecification } from "@/lib/projects/IOpenAPISpecification"; -import WelcomePage from "@/lib/pages/WelcomePage"; +import { getProjectId, getSpecificationId, getVersionId } from "@/common/UrlUtils" +import ProjectsPage from "@/features/projects/view/ProjectsPage" -export default async function Page({ - params, -}: { - params: { slug: string | string[] }; -}) { - const user = await userProvider.getUser(); - let url: string; +type PageParams = { slug: string | string[] } + +export default function Page({ params }: { params: PageParams }) { + const url = getURL(params) + return ( + + ) +} + +function getURL(params: PageParams) { if (typeof params.slug === "string") { - url = "/" + params.slug; + return "/" + params.slug } else { - url = params.slug.reduce( + return params.slug.reduce( (previousValue, currentValue) => `${previousValue}/${currentValue}`, "" - ); + ) } - let openApiSpecification: IOpenApiSpecification | undefined; - const projectName = getProject(url); - const versionName = getVersion(url); - const specificationName = getSpecification(url); - if (projectName && versionName && specificationName) { - const specifications = - await gitHubOpenApiSpecificationRepository.getOpenAPISpecifications({ - name: versionName, - owner: "shapehq", - repository: projectName, - }); - openApiSpecification = specifications.find( - (x) => x.name == specificationName - ); - } - - return ( - } - projectListComponent={ - - } - {...(projectName && projectName.length > 0 - ? { - versionSelectorComponent: ( - - ), - } - : {})} - {...(projectName && - projectName.length > 0 && - versionName && - versionName.length > 0 - ? { - openApiSpecificationsComponent: ( - - ), - } - : {})} - > - {openApiSpecification ? ( - - ) : ( - - )} - - ); -} +} \ No newline at end of file diff --git a/src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts new file mode 100644 index 00000000..3c0aca58 --- /dev/null +++ b/src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts @@ -0,0 +1,26 @@ +import { Octokit } from "octokit" +import { NextRequest, NextResponse } from "next/server" +import { accessTokenService } from "@/common/startup" + +type GitHubContentItem = {download_url: string} + +interface GetBlobParams { + owner: string + repository: string + path: [string] +} + +export async function GET(req: NextRequest, { params }: { params: GetBlobParams }) { + const accessToken = await accessTokenService.getAccessToken() + const octokit = new Octokit({ auth: accessToken }) + const fullPath = params.path.join("/") + const ref = req.nextUrl.searchParams.get("ref") + const response = await octokit.rest.repos.getContent({ + owner: params.owner, + repo: params.repository, + path: fullPath, + ref: ref || undefined + }) + let item = response.data as GitHubContentItem + return NextResponse.redirect(new URL(item.download_url)) +} diff --git a/src/app/api/hooks/github/route.ts b/src/app/api/hooks/github/route.ts index c445cbc7..5d227f36 100644 --- a/src/app/api/hooks/github/route.ts +++ b/src/app/api/hooks/github/route.ts @@ -1,123 +1,52 @@ -import { createAppAuth } from "@octokit/auth-app"; -import { Webhooks } from "@octokit/webhooks"; import { NextRequest, NextResponse } from "next/server" -import { Octokit } from "octokit"; -import { Repository, PullRequest } from "@octokit/webhooks-types"; +import GitHubHookHandler from "@/features/hooks/data/GitHubHookHandler" +import GitHubPullRequestCommentRepository from "@/features/hooks/data/GitHubPullRequestCommentRepository" +import PostCommentPullRequestEventHandler from "@/features/hooks/domain/PostCommentPullRequestEventHandler" +import RepositoryNameCheckingPullRequestEventHandler from "@/features/hooks/domain/RepositoryNameCheckingPullRequestEventHandler" +import ExistingCommentCheckingPullRequestEventHandler from "@/features/hooks/domain/ExistingCommentCheckingPullRequestEventHandler" -// load env vars const { - GITHUB_APP_ID, - GITHUB_CLIENT_ID, - GITHUB_CLIENT_SECRET, - GITHUB_PRIVATE_KEY_BASE_64, - GITHUB_WEBHOOK_SECRET + SHAPE_DOCS_BASE_URL, + GITHUB_APP_ID, + GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET, + GITHUB_PRIVATE_KEY_BASE_64, + GITHUB_WEBHOOK_SECRET, + GITHUB_WEBHOK_REPOSITORY_ALLOWLIST, + GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST } = process.env -const privateKey = Buffer.from(GITHUB_PRIVATE_KEY_BASE_64!!, 'base64').toString('utf-8') -// we will only add comments in these repos for now -const repositoryWhitelist = [ - 'example-openapi', - 'test-openapi', - 'moonboon-openapi' -] - -const webhooks = new Webhooks({ secret: GITHUB_WEBHOOK_SECRET!! }) - -const auth = createAppAuth({ - appId: GITHUB_APP_ID!!, - privateKey: privateKey, - clientId: GITHUB_CLIENT_ID!!, - clientSecret: GITHUB_CLIENT_SECRET!!, +const privateKey = Buffer.from(GITHUB_PRIVATE_KEY_BASE_64, "base64").toString("utf-8") +const allowedRepositoryNames = (GITHUB_WEBHOK_REPOSITORY_ALLOWLIST || "") + .split(",") + .map(e => e.trim()) +const disallowedRepositoryNames = (GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST || "") + .split(",") + .map(e => e.trim()) + +const commentRepository = new GitHubPullRequestCommentRepository({ + appId: GITHUB_APP_ID, + privateKey: privateKey, + clientId: GITHUB_CLIENT_ID, + clientSecret: GITHUB_WEBHOOK_SECRET +}) +const hookHandler = new GitHubHookHandler({ + secret: GITHUB_WEBHOOK_SECRET, + pullRequestEventHandler: new RepositoryNameCheckingPullRequestEventHandler( + new ExistingCommentCheckingPullRequestEventHandler( + new PostCommentPullRequestEventHandler( + commentRepository, + SHAPE_DOCS_BASE_URL + ), + commentRepository, + SHAPE_DOCS_BASE_URL + ), + allowedRepositoryNames, + disallowedRepositoryNames + ) }) - -const generateCommentBody = (repository: Repository, ref: string) => { - const link = `https://docs.shapetools.io/${repository.name.replace("-openapi", "")}/${ref}` - - return `### 📖 Documentation Preview - -These edits are available for preview at [Shape Docs](${link}). - - - - - - - - -
Status:✅ Ready!
Preview URL:${link}
` -} - -const createComment = async (repository: Repository, pullRequest: PullRequest, appInstallationId: number) => { - const installationAuthentication = await auth({ - type: "installation", - installationId: appInstallationId, - }); - - const octokit = new Octokit({ auth: installationAuthentication.token }) - - const isOpenApiRepo = repository.name.includes("openapi") && repositoryWhitelist.includes(repository.name) - - if (isOpenApiRepo) { - console.log("This is an openapi repo") - console.log("Checking if we need to create a comment") - - const comments = await octokit.rest.issues.listComments({ // TODO: We need to fetch all pages here - owner: repository.owner.login, - repo: repository.name, - issue_number: pullRequest.number, - }) - - const containsOurComment = comments.data.filter(comment => comment.body?.includes("docs.shapetools.io")).length > 0 - - if (!containsOurComment) { - console.log("Comment does not exist - creating one") - await octokit.rest.issues.createComment({ - owner: repository.owner.login, - repo: repository.name, - issue_number: pullRequest.number, - body: generateCommentBody(repository, pullRequest.head.ref) - }) - } else { - console.log("Comment exists - not creating") - } - } -} - -// keep track of processed events -// TODO: Add cleanup for this set. It will grow forever otherwise. -const alreadyProcessedEvents = new Set() export const POST = async (req: NextRequest): Promise => { - await webhooks.verifyAndReceive({ - id: req.headers.get('X-GitHub-Delivery') as string, - name: req.headers.get('X-GitHub-Event') as any, - payload: await req.text(), - signature: req.headers.get('X-Hub-Signature') as string, - }).catch((error) => { - console.error(`Error: ${error.message}`) - return false - }) - - webhooks.on("pull_request.opened", async ({ id, name, payload }) => { - console.log(`Received event ${name}#${id} with action`, payload.action) - if (!alreadyProcessedEvents.has(id)) { - console.log("Processing event") - alreadyProcessedEvents.add(id) - await createComment(payload.repository, payload.pull_request, payload.installation!.id) - } else { - console.log("Already processed this event") - } - }) - webhooks.on("pull_request.reopened", async ({ id, name, payload }) => { - console.log(`Received event ${name}#${id} with action`, payload.action) - if (!alreadyProcessedEvents.has(id)) { - console.log("Processing event") - alreadyProcessedEvents.add(id) - await createComment(payload.repository, payload.pull_request, payload.installation!.id) - } else { - console.log("Already processed this event") - } - }) - - return NextResponse.json({ status: "OK" }) + await hookHandler.handle(req) + return NextResponse.json({ status: "OK" }) } diff --git a/src/app/api/user/projects/route.ts b/src/app/api/user/projects/route.ts new file mode 100644 index 00000000..0d959f1f --- /dev/null +++ b/src/app/api/user/projects/route.ts @@ -0,0 +1,7 @@ +import { NextRequest, NextResponse } from "next/server" +import { projectRepository } from "@/common/startup" + +export async function GET(_req: NextRequest) { + const projects = await projectRepository.getProjects() + return NextResponse.json({projects}) +} diff --git a/src/app/globals.css b/src/app/globals.css index 9d1a5955..0a70c413 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,28 +2,6 @@ @tailwind components; @tailwind utilities; -:root { - --foreground-rgb: 0, 0, 0; - --background-rgb: 255, 255, 255; - --primary-tint: 255, 77, 91; +html, body { + height: 100%; } - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-rgb: 0, 0, 0; - } -} - -body { - color: rgb(var(--foreground-rgb)); - background: rgb(var(--background-rgb)); -} - -a { - color: rgb(var(--primary-tint)); -} - -a:hover { - text-decoration: underline; -} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d38019aa..53d809fb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,22 +1,28 @@ -import './globals.css' -import type { Metadata } from 'next' -import { Inter } from 'next/font/google' +import "./globals.css" +import type { Metadata } from "next" +import { Inter } from "next/font/google" +import { CssBaseline } from "@mui/material" +import ThemeRegistry from "@/common/client/ThemeRegistry" +import { UserProvider } from '@auth0/nextjs-auth0/client' -const inter = Inter({ subsets: ['latin'] }) +const inter = Inter({ subsets: ["latin"] }) export const metadata: Metadata = { - title: 'Shape Docs', - description: 'Documentation for Shape\'s APIs', + title: "Shape Docs", + description: "Documentation for Shape\"s APIs", } -export default function RootLayout({ - children, - }: { - children: React.ReactNode -}) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + + + + {children} + + + ) } diff --git a/src/app/page.tsx b/src/app/page.tsx index 6c1515d7..94c29ddc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,19 +1,5 @@ -import UserComponent from "@/lib/components/UserComponent"; -import ProjectListComponent from "@/lib/components/ProjectListComponent"; -import App from "@/lib/pages/App"; -import WelcomePage from "@/lib/pages/WelcomePage"; -import { projectRepository, userProvider } from "./startup"; +import ProjectsPage from "@/features/projects/view/ProjectsPage" export default async function Page() { - const user = await userProvider.getUser(); - return ( - } - projectListComponent={ - - } - > - - - ); + return } diff --git a/src/app/startup.ts b/src/app/startup.ts deleted file mode 100644 index df53122a..00000000 --- a/src/app/startup.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Auth0UserDetailsProvider } from "@/lib/auth/Auth0UserDetailsProvider"; -import { Auth0UserProvider } from "@/lib/auth/Auth0UserProvider"; -import { IdentityAccessTokenProvider } from "@/lib/auth/IdentityAccessTokenProvider"; -import { DeferredGitHubClient } from "@/lib/github/DeferredGitHubClient"; -import { HardcodedGitHubOrganizationNameProvider } from "@/lib/github/HardcodedGitHubOrganizationNameProvider"; -import { OctokitGitHubClient } from "@/lib/github/OctokitGitHubClient"; -import { AxiosNetworkClient } from "@/lib/networking/AxiosNetworkClient"; -import { GitHubOpenApiSpecificationRepository } from "@/lib/projects/GitHubOpenAPISpecificationRepository"; -import { GitHubProjectRepository } from "@/lib/projects/GitHubProjectRepository"; -import { GitHubVersionRepository } from "@/lib/projects/GitHubVersionRepository"; - -export const organizationNameProvider = new HardcodedGitHubOrganizationNameProvider( - "shapehq" -); -export const userProvider = new Auth0UserProvider(); -export const userDetailsProvider = new Auth0UserDetailsProvider(userProvider, { - domain: process.env.AUTH0_MANAGEMENT_DOMAIN, - clientId: process.env.AUTH0_MANAGEMENT_CLIENT_ID, - clientSecret: process.env.AUTH0_MANAGEMENT_CLIENT_SECRET, -}); -export const accessTokenProvider = new IdentityAccessTokenProvider( - userDetailsProvider, - "github" -); -export const gitHubClientFactory = ( - accessToken: string, - organizationName: string -) => { - return new OctokitGitHubClient(accessToken, organizationName); -}; -export const gitHubClient = new DeferredGitHubClient( - organizationNameProvider, - accessTokenProvider, - gitHubClientFactory -); -export const networkClient = new AxiosNetworkClient(); -export const projectRepository = new GitHubProjectRepository(gitHubClient, networkClient); -export const githubVersionRepository = new GitHubVersionRepository(gitHubClient); -export const gitHubOpenApiSpecificationRepository = - new GitHubOpenApiSpecificationRepository(gitHubClient); \ No newline at end of file diff --git a/src/common/SidebarContainer.tsx b/src/common/SidebarContainer.tsx new file mode 100644 index 00000000..985b8c70 --- /dev/null +++ b/src/common/SidebarContainer.tsx @@ -0,0 +1,59 @@ +import dynamic from "next/dynamic" +import { ReactNode } from "react" +import Image from "next/image" +import { Box, Stack } from "@mui/material" +import { useTheme } from "@mui/material/styles" +import { useSessionStorage } from "usehooks-ts" +import ClientSidebarContainer from "./client/SidebarContainer" +import SidebarContent from "./SidebarContent" + +interface SidebarContainerProps { + readonly primary: ReactNode + readonly secondary: ReactNode + readonly toolbarTrailing?: ReactNode +} + +const SidebarContainer: React.FC = ({ + primary, + secondary, + toolbarTrailing +}) => { + const [open, setOpen] = useSessionStorage("isDrawerOpen", true) + const theme = useTheme() + return ( + + Duck + + } + primary={ + + {primary} + + } + secondaryHeader={ + <> + {toolbarTrailing != undefined && + + {toolbarTrailing} + + } + + } + secondary={secondary} + /> + ) +} + +// Disable server-side rendering as this component uses the window instance to manage its state. +export default dynamic(() => Promise.resolve(SidebarContainer), { + ssr: false +}) diff --git a/src/common/SidebarContent.tsx b/src/common/SidebarContent.tsx new file mode 100644 index 00000000..e755d0f5 --- /dev/null +++ b/src/common/SidebarContent.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from "react" +import { Box } from "@mui/material" +import UserListItem from "@/features/user/view/UserListItem" +import SettingsButton from "@/features/settings/view/SettingsButton" + +const SidebarContent: React.FC<{ + readonly children: ReactNode +}> = ({ + children +}) => { + return ( + <> + + {children} + + } /> + + ) +} + +export default SidebarContent diff --git a/src/lib/utils/UrlUtils.ts b/src/common/UrlUtils.ts similarity index 84% rename from src/lib/utils/UrlUtils.ts rename to src/common/UrlUtils.ts index 47f54b23..d2350aa3 100644 --- a/src/lib/utils/UrlUtils.ts +++ b/src/common/UrlUtils.ts @@ -1,4 +1,4 @@ -export function getProject(url?: string) { +export function getProjectId(url?: string) { if (typeof window !== 'undefined') { url = window.location.pathname;// remove first slash } @@ -8,11 +8,11 @@ export function getProject(url?: string) { if (firstSlash != -1 && url) { project = decodeURI(url.substring(0, firstSlash)); } - return project ? project + "-openapi" : project; + return project } function getVersionAndSpecification(url?: string) { - const project = getProject(url)?.replace('-openapi', ''); + const project = getProjectId(url)?.replace('-openapi', ''); if (url && project) { const versionAndSpecification = url.substring(project.length + 2)// remove first slash let specification: string | undefined = undefined; @@ -35,7 +35,7 @@ function getVersionAndSpecification(url?: string) { return {}; } -export function getVersion(url?: string) { +export function getVersionId(url?: string) { if (typeof window !== 'undefined') { url = window.location.pathname } @@ -43,12 +43,10 @@ export function getVersion(url?: string) { return version ? decodeURI(version) : undefined; } -export function getSpecification(url?: string) { +export function getSpecificationId(url?: string) { if (typeof window !== 'undefined') { url = window.location.pathname - console.log(url, url) } const specification = getVersionAndSpecification(url).specification; - console.log(specification, specification) return specification ? decodeURI(specification) : undefined; } \ No newline at end of file diff --git a/src/common/client/SidebarContainer.tsx b/src/common/client/SidebarContainer.tsx new file mode 100644 index 00000000..b41a9cb4 --- /dev/null +++ b/src/common/client/SidebarContainer.tsx @@ -0,0 +1,161 @@ +import { ReactNode } from "react" +import { Box, Drawer, Divider, IconButton, Toolbar } from "@mui/material" +import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar" +import { ChevronLeft, Menu } from "@mui/icons-material" +import { styled, useTheme } from "@mui/material/styles" + +const drawerWidth = 320 + +const Main = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{ + open?: boolean +}>(({ theme, open }) => ({ + display: "flex", + flexDirection: "column", + flexGrow: 1, + overflowY: "auto", + transition: theme.transitions.create("margin", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + marginLeft: `-${drawerWidth}px`, + ...(open && { + transition: theme.transitions.create("margin", { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + marginLeft: 0, + }) +})) + +const DrawerHeaderWrapper = styled("div")(({ theme }) => ({ + display: "flex", + alignItems: "center", + // necessary for content to be below app bar + ...theme.mixins.toolbar +})) + +const DrawerHeader = ({ + primaryHeader, + handleDrawerClose +}: { + primaryHeader: ReactNode, + handleDrawerClose: () => void +}) => { + return ( + + + + + {primaryHeader != null && + + {primaryHeader} + + } + + ) +} + +interface AppBarProps extends MuiAppBarProps { + open?: boolean +} + +const AppBar = styled(MuiAppBar, { + shouldForwardProp: (prop) => prop !== "open", +})(({ theme, open }) => ({ + transition: theme.transitions.create(["margin", "width"], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + ...(open && { + width: `calc(100% - ${drawerWidth}px)`, + marginLeft: `${drawerWidth}px`, + transition: theme.transitions.create(["margin", "width"], { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + }), +})) + +interface SidebarContainerProps { + isDrawerOpen: boolean + onToggleDrawerOpen: (isDrawerOpen: boolean) => void + primaryHeader?: ReactNode + primary: ReactNode + secondaryHeader?: ReactNode + secondary: ReactNode +} + +const SidebarContainer: React.FC = ({ + isDrawerOpen, + onToggleDrawerOpen, + primaryHeader, + primary, + secondaryHeader, + secondary +}) => { + const theme = useTheme() + return ( + + + + onToggleDrawerOpen(true)} + edge="start" + sx={{ + mr: 2, + color: theme.palette.text.primary, + ...(isDrawerOpen && { display: "none" }) + }} + > + + + {secondaryHeader != null && secondaryHeader} + + + + + onToggleDrawerOpen(false)} + primaryHeader={primaryHeader} + /> + {primary} + +
+ + + {secondary} + +
+ + ) +} + +export default SidebarContainer diff --git a/src/common/client/ThemeRegistry.tsx b/src/common/client/ThemeRegistry.tsx new file mode 100644 index 00000000..18e71bff --- /dev/null +++ b/src/common/client/ThemeRegistry.tsx @@ -0,0 +1,65 @@ +"use client" + +import { useState } from "react" +import createCache from "@emotion/cache" +import { useServerInsertedHTML } from "next/navigation" +import { CacheProvider } from "@emotion/react" +import { ThemeProvider } from "@mui/material/styles" +import CssBaseline from "@mui/material/CssBaseline" +import theme from "./theme" +import useMediaQuery from "@mui/material/useMediaQuery" + +// This implementation is from emotion-js +// https://github.com/emotion-js/emotion/issues/2928#issuecomment-1319747902 +export default function ThemeRegistry(props: any) { + const { options, children } = props; + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); + const [{ cache, flush }] = useState(() => { + const cache = createCache(options); + cache.compat = true; + const prevInsert = cache.insert; + let inserted: string[] = []; + cache.insert = (...args) => { + const serialized = args[1]; + if (cache.inserted[serialized.name] === undefined) { + inserted.push(serialized.name); + } + return prevInsert(...args); + }; + const flush = () => { + const prevInserted = inserted; + inserted = []; + return prevInserted; + }; + return { cache, flush }; + }); + + useServerInsertedHTML(() => { + const names = flush(); + if (names.length === 0) { + return null; + } + let styles = ''; + for (const name of names) { + styles += cache.inserted[name]; + } + return ( +