From 3ca6039cece81f7baa30d4536cab65b6debe9362 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Thu, 15 Jan 2026 06:11:30 +0100 Subject: [PATCH 01/12] Implement two-phase project loading Split project loading into fast list query (ProjectSummary) for sidebar and on-demand details query (full Project) when viewing a project. Changes: - Add ProjectSummary type for lightweight project list - Add IProjectListDataSource and GitHubProjectListDataSource - Add IProjectDetailsDataSource and GitHubProjectDetailsDataSource - Add GET /api/projects and GET /api/projects/[owner]/[repo] endpoints - Add ProjectListContext and ProjectDetailsContext - Update sidebar to use ProjectListContext - Update project view to fetch details on-demand - Remove deprecated refresh-projects endpoint - Remove refresh spinner from header (loading is now fast) - Remove unused caching infrastructure (CachingProjectDataSource, ProjectRepository, GitHubProjectDataSource, GitHubRepositoryDataSource, FilteringGitHubRepositoryDataSource) --- .../projects/CachingProjectDataSource.test.ts | 49 - ...ilteringGitHubRepositoryDataSource.test.ts | 125 -- .../projects/GitHubProjectDataSource.test.ts | 1196 ----------------- .../GitHubRepositoryDataSource.test.ts | 220 --- jest.config.js | 2 +- .../(authed)/(project-doc)/[...slug]/page.tsx | 39 +- src/app/(authed)/layout.tsx | 20 +- src/app/api/projects/[owner]/[repo]/route.ts | 28 + src/app/api/projects/route.ts | 15 + src/app/api/refresh-projects/route.ts | 9 - src/common/context/ProjectsContext.ts | 13 - src/composition.ts | 45 +- .../projects/data/GitHubProjectDataSource.ts | 299 ----- .../data/GitHubProjectDetailsDataSource.ts | 341 +++++ .../data/GitHubProjectListDataSource.ts | 142 ++ .../data/GitHubRepositoryDataSource.ts | 330 ----- src/features/projects/data/index.ts | 5 +- .../projects/data/useProjectSelection.ts | 79 +- .../domain/CachingProjectDataSource.ts | 33 - .../FilteringGitHubRepositoryDataSource.ts | 30 - .../domain/IGitHubRepositoryDataSource.ts | 32 - .../projects/domain/IProjectDataSource.ts | 5 - .../domain/IProjectDetailsDataSource.ts | 5 + .../projects/domain/IProjectListDataSource.ts | 5 + .../projects/domain/IProjectRepository.ts | 7 - .../projects/domain/ProjectRepository.ts | 43 - .../projects/domain/ProjectSummary.ts | 15 + src/features/projects/domain/index.ts | 10 +- .../projects/view/ProjectDetailsContext.tsx | 21 + .../view/ProjectDetailsContextProvider.tsx | 86 ++ .../projects/view/ProjectListContext.tsx | 21 + .../view/ProjectListContextProvider.tsx | 57 + .../projects/view/ProjectsContextProvider.tsx | 66 - src/features/projects/view/index.ts | 4 + .../sidebar/view/internal/sidebar/Header.tsx | 61 +- .../sidebar/projects/PopulatedProjectList.tsx | 6 +- .../sidebar/projects/ProjectAvatar.tsx | 4 +- .../internal/sidebar/projects/ProjectList.tsx | 11 +- .../sidebar/projects/ProjectListFallback.tsx | 5 +- .../sidebar/projects/ProjectListItem.tsx | 8 +- 40 files changed, 874 insertions(+), 2618 deletions(-) delete mode 100644 __test__/projects/CachingProjectDataSource.test.ts delete mode 100644 __test__/projects/FilteringGitHubRepositoryDataSource.test.ts delete mode 100644 __test__/projects/GitHubProjectDataSource.test.ts delete mode 100644 __test__/projects/GitHubRepositoryDataSource.test.ts create mode 100644 src/app/api/projects/[owner]/[repo]/route.ts create mode 100644 src/app/api/projects/route.ts delete mode 100644 src/app/api/refresh-projects/route.ts delete mode 100644 src/features/projects/data/GitHubProjectDataSource.ts create mode 100644 src/features/projects/data/GitHubProjectDetailsDataSource.ts create mode 100644 src/features/projects/data/GitHubProjectListDataSource.ts delete mode 100644 src/features/projects/data/GitHubRepositoryDataSource.ts delete mode 100644 src/features/projects/domain/CachingProjectDataSource.ts delete mode 100644 src/features/projects/domain/FilteringGitHubRepositoryDataSource.ts delete mode 100644 src/features/projects/domain/IGitHubRepositoryDataSource.ts delete mode 100644 src/features/projects/domain/IProjectDataSource.ts create mode 100644 src/features/projects/domain/IProjectDetailsDataSource.ts create mode 100644 src/features/projects/domain/IProjectListDataSource.ts delete mode 100644 src/features/projects/domain/IProjectRepository.ts delete mode 100644 src/features/projects/domain/ProjectRepository.ts create mode 100644 src/features/projects/domain/ProjectSummary.ts create mode 100644 src/features/projects/view/ProjectDetailsContext.tsx create mode 100644 src/features/projects/view/ProjectDetailsContextProvider.tsx create mode 100644 src/features/projects/view/ProjectListContext.tsx create mode 100644 src/features/projects/view/ProjectListContextProvider.tsx delete mode 100644 src/features/projects/view/ProjectsContextProvider.tsx create mode 100644 src/features/projects/view/index.ts diff --git a/__test__/projects/CachingProjectDataSource.test.ts b/__test__/projects/CachingProjectDataSource.test.ts deleted file mode 100644 index 9365f53a..00000000 --- a/__test__/projects/CachingProjectDataSource.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Project, CachingProjectDataSource } from "@/features/projects/domain" - -test("It caches projects read from the data source", async () => { - const projects: Project[] = [{ - id: "foo", - name: "foo", - displayName: "foo", - versions: [{ - id: "bar", - name: "bar", - isDefault: false, - specifications: [{ - id: "baz.yml", - name: "baz.yml", - url: "https://example.com/baz.yml" - }] - }, { - id: "hello", - name: "hello", - isDefault: false, - specifications: [{ - id: "world.yml", - name: "world.yml", - url: "https://example.com/world.yml" - }] - }], - owner: "acme", - ownerUrl: "https://example.com/acme" - }] - let cachedProjects: Project[] | undefined - const sut = new CachingProjectDataSource({ - dataSource: { - async getProjects() { - return projects - } - }, - repository: { - async get() { - return [] - }, - async set(projects) { - cachedProjects = projects - }, - async delete() {} - } - }) - await sut.getProjects() - expect(cachedProjects).toEqual(projects) -}) diff --git a/__test__/projects/FilteringGitHubRepositoryDataSource.test.ts b/__test__/projects/FilteringGitHubRepositoryDataSource.test.ts deleted file mode 100644 index e834d7b0..00000000 --- a/__test__/projects/FilteringGitHubRepositoryDataSource.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { FilteringGitHubRepositoryDataSource } from "@/features/projects/domain" - -test("It returns all repositories when no hidden repositories are provided", async () => { - const sut = new FilteringGitHubRepositoryDataSource({ - hiddenRepositories: [], - dataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [], - tags: [] - }, { - owner: "acme", - name: "bar-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - branches: [], - tags: [] - }] - } - } - }) - const repositories = await sut.getRepositories() - expect(repositories.length).toEqual(2) -}) - -test("It removes hidden repository", async () => { - const sut = new FilteringGitHubRepositoryDataSource({ - hiddenRepositories: ["acme/foo-openapi"], - dataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [], - tags: [] - }, { - owner: "acme", - name: "bar-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - branches: [], - tags: [] - }] - } - } - }) - const repositories = await sut.getRepositories() - expect(repositories.length).toEqual(1) -}) - -test("It returns unmodified list when hidden repository was not found", async () => { - const sut = new FilteringGitHubRepositoryDataSource({ - hiddenRepositories: ["acme/baz-openapi"], - dataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [], - tags: [] - }, { - owner: "acme", - name: "bar-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - branches: [], - tags: [] - }] - } - } - }) - const repositories = await sut.getRepositories() - expect(repositories.length).toEqual(2) -}) - -test("It removes multiple hidden repositories", async () => { - const sut = new FilteringGitHubRepositoryDataSource({ - hiddenRepositories: ["acme/foo-openapi", "acme/bar-openapi"], - dataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [], - tags: [] - }, { - owner: "acme", - name: "bar-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - branches: [], - tags: [] - }] - } - } - }) - const repositories = await sut.getRepositories() - expect(repositories.length).toEqual(0) -}) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts deleted file mode 100644 index 586375bc..00000000 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ /dev/null @@ -1,1196 +0,0 @@ -import { GitHubProjectDataSource } from "@/features/projects/data" -import RemoteConfig from "@/features/projects/domain/RemoteConfig" - -/** - * Simple encryption service for testing. Does nothing. - */ -const noopEncryptionService = { - encrypt: function (data: string): string { - return data - }, - decrypt: function (encryptedDataBase64: string): string { - return encryptedDataBase64 - } -} - -/** - * Simple encoder for testing - */ -const base64RemoteConfigEncoder = { - encode: function (remoteConfig: RemoteConfig): string { - return Buffer.from(JSON.stringify(remoteConfig)).toString("base64") - }, - decode: function (encodedString: string): RemoteConfig { - return JSON.parse(Buffer.from(encodedString, "base64").toString()) - } -} - -test("It loads repositories from data source", async () => { - let didLoadRepositories = false - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - didLoadRepositories = true - return [] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - await sut.getProjects() - expect(didLoadRepositories).toBeTruthy() -}) - -test("It maps projects including branches and tags", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml" - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects).toEqual([{ - id: "acme-foo", - name: "foo", - displayName: "foo", - url: "https://github.com/acme/foo-openapi", - versions: [{ - id: "main", - name: "main", - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/openapi.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/main", - isDefault: true - }, { - id: "1.0", - name: "1.0", - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/1.0/openapi.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/1.0", - isDefault: false - }], - owner: "acme", - ownerUrl: "https://github.com/acme" - }]) -}) - -test("It removes suffix from project name", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml" - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].id).toEqual("acme-foo") - expect(projects[0].name).toEqual("foo") - expect(projects[0].displayName).toEqual("foo") -}) - -test("It supports multiple OpenAPI specifications on a branch", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "foo-service.yml", - }, { - name: "bar-service.yml", - }, { - name: "baz-service.yml", - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects).toEqual([{ - id: "acme-foo", - name: "foo", - displayName: "foo", - url: "https://github.com/acme/foo-openapi", - versions: [{ - id: "main", - name: "main", - specifications: [{ - id: "bar-service.yml", - name: "bar-service.yml", - url: "/api/blob/acme/foo-openapi/bar-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/bar-service.yml", - isDefault: false - }, { - id: "baz-service.yml", - name: "baz-service.yml", - url: "/api/blob/acme/foo-openapi/baz-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/baz-service.yml", - isDefault: false - }, - { - id: "foo-service.yml", - name: "foo-service.yml", - url: "/api/blob/acme/foo-openapi/foo-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/foo-service.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/main", - isDefault: true - }, { - id: "1.0", - name: "1.0", - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/1.0/openapi.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/1.0", - isDefault: false - }], - owner: "acme", - ownerUrl: "https://github.com/acme" - }]) -}) - -test("It filters away projects with no versions", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects.length).toEqual(0) -}) - -test("It filters away branches with no specifications", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "bugfix", - files: [{ - name: "README.md", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions.length).toEqual(1) -}) - -test("It filters away tags with no specifications", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "foo-service.yml", - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }, { - id: "12345678", - name: "0.1", - files: [{ - name: "README.md" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions.length).toEqual(2) -}) - -test("It reads image from configuration file with .yml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: "image: icon.png" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") -}) - -test("It reads display name from configuration file with .yml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].id).toEqual("acme-foo") - expect(projects[0].name).toEqual("foo") - expect(projects[0].displayName).toEqual("Hello World") -}) - -test("It reads image from configuration file with .yaml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "image: icon.png" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") -}) - -test("It reads display name from configuration file with .yaml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].id).toEqual("acme-foo") - expect(projects[0].name).toEqual("foo") - expect(projects[0].displayName).toEqual("Hello World") -}) - -test("It sorts projects alphabetically", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "cathrine-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }, { - owner: "acme", - name: "bobby-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }, { - owner: "acme", - name: "anne-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].name).toEqual("anne") - expect(projects[1].name).toEqual("bobby") - expect(projects[2].name).toEqual("cathrine") -}) - -test("It sorts versions alphabetically", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "bobby", - files: [{ - name: "openapi.yml", - }] - }], - tags: [{ - id: "12345678", - name: "cathrine", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml", - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].name).toEqual("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({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "develop", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "development", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "master", - files: [{ - name: "openapi.yml", - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml", - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].name).toEqual("main") - expect(projects[0].versions[1].name).toEqual("master") - expect(projects[0].versions[2].name).toEqual("develop") - expect(projects[0].versions[3].name).toEqual("development") - expect(projects[0].versions[4].name).toEqual("1.0") - expect(projects[0].versions[5].name).toEqual("anne") -}) - -test("It sorts file specifications alphabetically", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "z-openapi.yml", - }, { - name: "a-openapi.yml", - }, { - name: "1-openapi.yml", - }] - }], - tags: [{ - id: "12345678", - name: "cathrine", - files: [{ - name: "o-openapi.yml", - }, { - name: "2-openapi.yml", - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].specifications[0].name).toEqual("1-openapi.yml") - expect(projects[0].versions[0].specifications[1].name).toEqual("a-openapi.yml") - expect(projects[0].versions[0].specifications[2].name).toEqual("z-openapi.yml") - expect(projects[0].versions[1].specifications[0].name).toEqual("2-openapi.yml") - expect(projects[0].versions[1].specifications[1].name).toEqual("o-openapi.yml") -}) - -test("It maintains remote version specification ordering from config", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: ` - name: Hello World - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Zac - url: https://example.com/zac.yml - - id: another-spec - name: Bob - url: https://example.com/bob.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].specifications[0].name).toEqual("Zac") - expect(projects[0].versions[0].specifications[1].name).toEqual("Bob") -}) - -test("It identifies the default branch in returned versions", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "development" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "development", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const defaultVersionNames = projects[0] - .versions - .filter(e => e.isDefault) - .map(e => e.name) - expect(defaultVersionNames).toEqual(["development"]) -}) - -test("It adds remote versions from the project configuration", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - 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: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "anne", - name: "Anne", - isDefault: false, - specifications: [{ - id: "huey", - name: "Huey", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}`, - urlHash: "89ba381286214eec", - isDefault: false - }, { - id: "dewey", - name: "Dewey", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}`, - urlHash: "8f810fff152505f6", - isDefault: false - }] - }, { - id: "bobby", - name: "Bobby", - isDefault: false, - specifications: [{ - id: "louie", - name: "Louie", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}`, - urlHash: "b83ebf43ceede6bc", - isDefault: false - }] - }]) -}) - -test("It modifies ID of remote version if the ID already exists", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - name: Bar - specifications: - - name: Baz - url: https://example.com/baz.yml - - name: Bar - specifications: - - name: Hello - url: https://example.com/hello.yml - ` - }, - branches: [{ - id: "12345678", - name: "bar", - files: [{ - name: "openapi.yml" - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "bar", - name: "bar", - url: "https://github.com/acme/foo-openapi/tree/bar", - isDefault: true, - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/bar/openapi.yml", - isDefault: false - }] - }, { - id: "bar1", - name: "Bar", - isDefault: false, - specifications: [{ - id: "baz", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - urlHash: "25cb42ff63570cb5", - isDefault: false - }] - }, { - id: "bar2", - name: "Bar", - isDefault: false, - specifications: [{ - id: "hello", - name: "Hello", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/hello.yml" })}`, - urlHash: "d078bd689699d1f0", - isDefault: false - }] - }]) -}) - -test("It lets users specify the ID of a remote version", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - id: some-version - name: Bar - specifications: - - name: Baz - url: https://example.com/baz.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "some-version", - name: "Bar", - isDefault: false, - specifications: [{ - id: "baz", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - urlHash: "25cb42ff63570cb5", - isDefault: false - }] - }]) -}) - -test("It lets users specify the ID of a remote specification", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "bar", - name: "Bar", - isDefault: false, - specifications: [{ - id: "some-spec", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - urlHash: "25cb42ff63570cb5", - isDefault: false - }] - }]) -}) - -test("It sets isDefault on the correct specification based on defaultSpecificationName in config", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: ` - defaultSpecificationName: bar-service.yml - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - ` - }, - branches: [{ - id: "12345678", - name: "main", - files: [ - { name: "foo-service.yml" }, - { name: "bar-service.yml" }, - { name: "baz-service.yml" } - ] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const specs = projects[0].versions[0].specifications - expect(specs.find(s => s.name === "bar-service.yml")!.isDefault).toBe(true) - expect(specs.find(s => s.name === "foo-service.yml")!.isDefault).toBe(false) - expect(specs.find(s => s.name === "baz-service.yml")!.isDefault).toBe(false) - expect(projects[0].versions[1].specifications.find(s => s.name === "Baz")!.isDefault).toBe(false) -}) - -test("It sets a remote specification as the default if specified", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: ` - defaultSpecificationName: Baz - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - - id: another-spec - name: Qux - url: https://example.com/qux.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const remoteSpecs = projects[0].versions[0].specifications - expect(remoteSpecs.find(s => s.id === "some-spec")!.isDefault).toBe(true) - expect(remoteSpecs.find(s => s.id === "another-spec")!.isDefault).toBe(false) -}) - - -test("It sets isDefault to false for all specifications if defaultSpecificationName is not set", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: `` - }, - branches: [{ - id: "12345678", - name: "main", - files: [ - { name: "foo-service.yml" }, - { name: "bar-service.yml" }, - { name: "baz-service.yml" } - ] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const specs = projects[0].versions[0].specifications - expect(specs.every(s => s.isDefault === false)).toBe(true) -}) - -test("It silently ignores defaultSpecificationName if no matching spec is found", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: `defaultSpecificationName: non-existent.yml` - }, - branches: [{ - id: "12345678", - name: "main", - files: [ - { name: "foo-service.yml" }, - { name: "bar-service.yml" }, - { name: "baz-service.yml" } - ] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const specs = projects[0].versions[0].specifications - expect(specs.every(s => s.isDefault === false)).toBe(true) -}) diff --git a/__test__/projects/GitHubRepositoryDataSource.test.ts b/__test__/projects/GitHubRepositoryDataSource.test.ts deleted file mode 100644 index 59148fc0..00000000 --- a/__test__/projects/GitHubRepositoryDataSource.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { GitHubRepositoryDataSource } from "@/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 () => { - const 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/jest.config.js b/jest.config.js index 67d061c6..ada31bd3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,6 @@ module.exports = { clearMocks: true, - moduleFileExtensions: ["js", "ts"], + moduleFileExtensions: ["js", "ts", "tsx"], testEnvironment: "node", testMatch: ["**/*.test.ts"], extensionsToTreatAsEsm: [".ts"], diff --git a/src/app/(authed)/(project-doc)/[...slug]/page.tsx b/src/app/(authed)/(project-doc)/[...slug]/page.tsx index 02473315..ac97d1fb 100644 --- a/src/app/(authed)/(project-doc)/[...slug]/page.tsx +++ b/src/app/(authed)/(project-doc)/[...slug]/page.tsx @@ -1,21 +1,39 @@ "use client" -import { useContext, useEffect } from "react" +import { useEffect } from "react" +import { useParams } from "next/navigation" import ErrorMessage from "@/common/ui/ErrorMessage" import { updateWindowTitle } from "@/features/projects/domain" import { useProjectSelection } from "@/features/projects/data" import Documentation from "@/features/projects/view/Documentation" import NotFound from "@/features/projects/view/NotFound" -import { ProjectsContext } from "@/common/context/ProjectsContext" +import { useProjectDetails } from "@/features/projects/view/ProjectDetailsContext" import LoadingIndicator from "@/common/ui/LoadingIndicator" export default function Page() { + const params = useParams() + const slug = params.slug as string[] | undefined + const owner = slug?.[0] + const name = slug?.[1] + + const { fetchProject, isLoading, getError } = useProjectDetails() const { project, version, specification, navigateToSelectionIfNeeded } = useProjectSelection() - const { refreshing } = useContext(ProjectsContext) + + const loading = owner && name ? isLoading(owner, name) : false + const error = owner && name ? getError(owner, name) : null + + // Fetch project details when the page loads + useEffect(() => { + if (owner && name && !project) { + fetchProject(owner, name) + } + }, [owner, name, project, fetchProject]) + // Ensure the URL reflects the current selection of project, version, and specification. useEffect(() => { navigateToSelectionIfNeeded() }, [project, version, specification, navigateToSelectionIfNeeded]) + useEffect(() => { if (!project) { return @@ -28,18 +46,23 @@ export default function Page() { }) }, [project, version, specification]) + if (loading) { + return + } + + if (error) { + return + } + return ( <> {project && version && specification && } - {project && (!version || !specification) && !refreshing && + {project && (!version || !specification) && } - {!project && !version && !specification && refreshing && - - } - {!project && !refreshing && } + {!project && } ) } diff --git a/src/app/(authed)/layout.tsx b/src/app/(authed)/layout.tsx index 798d2fe5..f6699754 100644 --- a/src/app/(authed)/layout.tsx +++ b/src/app/(authed)/layout.tsx @@ -1,9 +1,10 @@ import { redirect } from "next/navigation"; import { SessionProvider } from "next-auth/react"; -import { session, projectDataSource } from "@/composition"; +import { session } from "@/composition"; import ErrorHandler from "@/common/ui/ErrorHandler"; import SessionBarrier from "@/features/auth/view/SessionBarrier"; -import ProjectsContextProvider from "@/features/projects/view/ProjectsContextProvider"; +import ProjectListContextProvider from "@/features/projects/view/ProjectListContextProvider"; +import ProjectDetailsContextProvider from "@/features/projects/view/ProjectDetailsContextProvider"; import { SidebarTogglableContextProvider, SplitView, @@ -19,18 +20,17 @@ export default async function Layout({ return redirect("/api/auth/signin"); } - const projects = await projectDataSource.getProjects(); - - return ( - - - {children} - - + + + + {children} + + + diff --git a/src/app/api/projects/[owner]/[repo]/route.ts b/src/app/api/projects/[owner]/[repo]/route.ts new file mode 100644 index 00000000..1022fda0 --- /dev/null +++ b/src/app/api/projects/[owner]/[repo]/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server" +import { projectDetailsDataSource } from "@/composition" + +export async function GET( + request: Request, + { params }: { params: Promise<{ owner: string; repo: string }> } +) { + const { owner, repo } = await params + + try { + const project = await projectDetailsDataSource.getProjectDetails(owner, repo) + + if (!project) { + return NextResponse.json( + { error: "Project not found" }, + { status: 404 } + ) + } + + return NextResponse.json({ project }) + } catch (error) { + console.error(`Failed to fetch project details for ${owner}/${repo}:`, error) + return NextResponse.json( + { error: "Failed to fetch project details" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 00000000..e9d11ccf --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server" +import { projectListDataSource } from "@/composition" + +export async function GET() { + try { + const projects = await projectListDataSource.getProjectList() + return NextResponse.json({ projects }) + } catch (error) { + console.error("Failed to fetch project list:", error) + return NextResponse.json( + { error: "Failed to fetch project list" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/refresh-projects/route.ts b/src/app/api/refresh-projects/route.ts deleted file mode 100644 index fbaedd4c..00000000 --- a/src/app/api/refresh-projects/route.ts +++ /dev/null @@ -1,9 +0,0 @@ - -import { NextResponse } from "next/server" -import { projectDataSource } from "@/composition"; - - -export async function POST() { - const projects = await projectDataSource.refreshProjects() - return NextResponse.json({ projects }) -} \ No newline at end of file diff --git a/src/common/context/ProjectsContext.ts b/src/common/context/ProjectsContext.ts index 4666e695..9ea9b620 100644 --- a/src/common/context/ProjectsContext.ts +++ b/src/common/context/ProjectsContext.ts @@ -1,18 +1,5 @@ "use client" import { createContext } from "react" -import { Project } from "@/features/projects/domain" export const SidebarTogglableContext = createContext(true) - -type ProjectsContextValue = { - refreshing: boolean, - projects: Project[], - refreshProjects: () => void, -} - -export const ProjectsContext = createContext({ - refreshing: false, - projects: [], - refreshProjects: () => {}, -}) diff --git a/src/composition.ts b/src/composition.ts index 8187b966..0d78e657 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -17,14 +17,9 @@ import { } from "@/common" import { GitHubLoginDataSource, - GitHubProjectDataSource, - GitHubRepositoryDataSource + GitHubProjectListDataSource, + GitHubProjectDetailsDataSource } from "@/features/projects/data" -import { - CachingProjectDataSource, - FilteringGitHubRepositoryDataSource, - ProjectRepository -} from "@/features/projects/domain" import { GitHubOAuthTokenRefresher } from "@/features/auth/data" @@ -175,11 +170,6 @@ const projectUserDataRepository = new KeyValueUserDataRepository({ baseKey: "projects" }) -export const projectRepository = new ProjectRepository({ - userIDReader: session, - repository: projectUserDataRepository -}) - export const encryptionService = new RsaEncryptionService({ publicKey: Buffer.from(env.getOrThrow("ENCRYPTION_PUBLIC_KEY_BASE_64"), "base64").toString("utf-8"), privateKey: Buffer.from(env.getOrThrow("ENCRYPTION_PRIVATE_KEY_BASE_64"), "base64").toString("utf-8") @@ -187,24 +177,21 @@ export const encryptionService = new RsaEncryptionService({ export const remoteConfigEncoder = new RemoteConfigEncoder(encryptionService) -export const projectDataSource = new CachingProjectDataSource({ - dataSource: new GitHubProjectDataSource({ - repositoryDataSource: new FilteringGitHubRepositoryDataSource({ - hiddenRepositories: listFromCommaSeparatedString(env.get("HIDDEN_REPOSITORIES")), - dataSource: new GitHubRepositoryDataSource({ - loginsDataSource: new GitHubLoginDataSource({ - graphQlClient: userGitHubClient - }), - graphQlClient: userGitHubClient, - repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), - projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME") - }) - }), - repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), - encryptionService: encryptionService, - remoteConfigEncoder: remoteConfigEncoder +export const projectListDataSource = new GitHubProjectListDataSource({ + loginsDataSource: new GitHubLoginDataSource({ + graphQlClient: userGitHubClient }), - repository: projectRepository + graphQlClient: userGitHubClient, + repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), + projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME") +}) + +export const projectDetailsDataSource = new GitHubProjectDetailsDataSource({ + graphQlClient: userGitHubClient, + repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), + projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME"), + encryptionService: encryptionService, + remoteConfigEncoder: remoteConfigEncoder }) export const logOutHandler = new ErrorIgnoringLogOutHandler( diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts deleted file mode 100644 index 9976ac45..00000000 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { createHash } from "crypto" -import { IEncryptionService } from "@/features/encrypt/EncryptionService" -import { - Project, - Version, - IProjectConfig, - IProjectDataSource, - ProjectConfigParser, - ProjectConfigRemoteVersion, - IGitHubRepositoryDataSource, - GitHubRepository, - GitHubRepositoryRef, - ProjectConfigRemoteSpecification -} from "../domain" -import RemoteConfig from "../domain/RemoteConfig" -import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder" - -export default class GitHubProjectDataSource implements IProjectDataSource { - private readonly repositoryDataSource: IGitHubRepositoryDataSource - private readonly repositoryNameSuffix: string - private readonly encryptionService: IEncryptionService - private readonly remoteConfigEncoder: IRemoteConfigEncoder - - constructor(config: { - repositoryDataSource: IGitHubRepositoryDataSource - repositoryNameSuffix: string - encryptionService: IEncryptionService - remoteConfigEncoder: IRemoteConfigEncoder - }) { - this.repositoryDataSource = config.repositoryDataSource - this.repositoryNameSuffix = config.repositoryNameSuffix - this.encryptionService = config.encryptionService - this.remoteConfigEncoder = config.remoteConfigEncoder - } - - async getProjects(): Promise { - const repositories = await this.repositoryDataSource.getRepositories() - return repositories.map(repository => { - return this.mapProject(repository) - }) - .filter((project: Project) => { - return project.versions.length > 0 - }) - .sort((a: Project, b: Project) => { - return a.name.localeCompare(b.name) - }) - } - - private mapProject(repository: GitHubRepository): Project { - const config = this.getConfig(repository) - let imageURL: string | undefined - if (config && config.image) { - imageURL = this.getGitHubBlobURL({ - ownerName: repository.owner, - repositoryName: repository.name, - path: config.image, - ref: repository.defaultBranchRef.id - }) - } - const versions = this.sortVersions( - this.addRemoteVersions( - this.getVersions(repository), - config?.remoteVersions || [] - ), - repository.defaultBranchRef.name - ).filter(version => { - return version.specifications.length > 0 - }) - .map(version => this.setDefaultSpecification(version, config?.defaultSpecificationName)) - const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") - return { - id: `${repository.owner}-${defaultName}`, - owner: repository.owner, - name: defaultName, - displayName: config?.name || defaultName, - versions, - imageURL: imageURL, - ownerUrl: `https://github.com/${repository.owner}`, - url: `https://github.com/${repository.owner}/${repository.name}` - } - } - - private getConfig(repository: GitHubRepository): IProjectConfig | null { - const yml = repository.configYml || repository.configYaml - if (!yml || !yml.text || yml.text.length == 0) { - return null - } - const parser = new ProjectConfigParser() - return parser.parse(yml.text) - } - - private getVersions(repository: GitHubRepository): Version[] { - const branchVersions = repository.branches.map(branch => { - const isDefaultRef = branch.name == repository.defaultBranchRef.name - return this.mapVersionFromRef({ - ownerName: repository.owner, - repositoryName: repository.name, - ref: branch, - isDefaultRef - }) - }) - const tagVersions = repository.tags.map(tag => { - return this.mapVersionFromRef({ - ownerName: repository.owner, - repositoryName: repository.name, - ref: tag - }) - }) - return branchVersions.concat(tagVersions) - } - - private mapVersionFromRef({ - ownerName, - repositoryName, - ref, - isDefaultRef - }: { - ownerName: string - repositoryName: string - ref: GitHubRepositoryRef - isDefaultRef?: boolean - }): Version { - const specifications = ref.files.filter(file => { - return this.isOpenAPISpecification(file.name) - }).map(file => { - const isFileChanged = ref.changedFiles?.includes(file.name) ?? false - return { - id: file.name, - name: file.name, - url: this.getGitHubBlobURL({ - ownerName, - repositoryName, - path: file.name, - ref: ref.id - }), - editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${encodeURIComponent(file.name)}`, - diffURL: isFileChanged ? this.getGitHubDiffURL({ - ownerName, - repositoryName, - path: file.name, - baseRefOid: ref.baseRefOid, - headRefOid: ref.id - }) : undefined, - diffBaseBranch: isFileChanged ? ref.baseRef : undefined, - diffBaseOid: isFileChanged ? ref.baseRefOid : undefined, - diffPrUrl: isFileChanged && ref.prNumber ? `https://github.com/${ownerName}/${repositoryName}/pull/${ref.prNumber}` : undefined, - isDefault: false // initial value - } - }).sort((a, b) => a.name.localeCompare(b.name)) - return { - id: ref.name, - name: ref.name, - specifications: specifications, - url: `https://github.com/${ownerName}/${repositoryName}/tree/${ref.name}`, - isDefault: isDefaultRef || false, - } - } - - private isOpenAPISpecification(filename: string) { - return !filename.startsWith(".") && ( - filename.endsWith(".yml") || filename.endsWith(".yaml") - ) - } - - private getGitHubBlobURL({ - ownerName, - repositoryName, - path, - ref - }: { - ownerName: string - repositoryName: string - path: string - ref: string - }): string { - const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/') - return `/api/blob/${ownerName}/${repositoryName}/${encodedPath}?ref=${ref}` - } - - private getGitHubDiffURL({ - ownerName, - repositoryName, - path, - baseRefOid, - headRefOid - }: { - ownerName: string; - repositoryName: string; - path: string; - baseRefOid: string | undefined; - headRefOid: string } - ): string | undefined { - if (!baseRefOid) { - return undefined - } else { - const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/') - return `/api/diff/${ownerName}/${repositoryName}/${encodedPath}?baseRefOid=${baseRefOid}&to=${headRefOid}` - } - } - - private addRemoteVersions( - existingVersions: Version[], - remoteVersions: ProjectConfigRemoteVersion[] - ): Version[] { - const versions = [...existingVersions] - const versionIds = versions.map(e => e.id) - for (const remoteVersion of remoteVersions) { - const baseVersionId = this.makeURLSafeID( - (remoteVersion.id || remoteVersion.name).toLowerCase() - ) - // If the version ID exists then we suffix it with a number to ensure unique versions. - // E.g. if "foo" already exists, we make it "foo1". - const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length - const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") - const specifications = remoteVersion.specifications.map(e => { - const remoteConfig: RemoteConfig = { - url: e.url, - auth: this.tryDecryptAuth(e) - }; - - const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig); - // 16 hex chars (64 bits) - sufficient for change detection, not cryptographic security - const configHash = createHash("sha256").update(JSON.stringify(remoteConfig)).digest("hex").slice(0, 16); - - return { - id: this.makeURLSafeID((e.id || e.name).toLowerCase()), - name: e.name, - url: `/api/remotes/${encodedRemoteConfig}`, - urlHash: configHash, - isDefault: false // initial value - }; - }) - versions.push({ - id: versionId, - name: remoteVersion.name, - specifications, - isDefault: false - }) - versionIds.push(baseVersionId) - } - return versions - } - - private sortVersions(versions: Version[], defaultBranchName: string): Version[] { - const candidateDefaultBranches = [ - defaultBranchName, "main", "master", "develop", "development", "trunk" - ] - // Reverse them so the top-priority branches end up at the top of the list. - .reverse() - const copiedVersions = [...versions].sort((a, b) => { - return a.name.localeCompare(b.name) - }) - // Move the top-priority branches to the top of the list. - for (const candidateDefaultBranch of candidateDefaultBranches) { - const defaultBranchIndex = copiedVersions.findIndex(version => { - return version.name === candidateDefaultBranch - }) - if (defaultBranchIndex !== -1) { - const branchVersion = copiedVersions[defaultBranchIndex] - copiedVersions.splice(defaultBranchIndex, 1) - copiedVersions.splice(0, 0, branchVersion) - } - } - return copiedVersions - } - - private makeURLSafeID(str: string): string { - return str - .replace(/ /g, "-") - .replace(/[^A-Za-z0-9-]/g, "") - } - - private tryDecryptAuth(projectConfigRemoteSpec: ProjectConfigRemoteSpecification): { type: string, username: string, password: string } | undefined { - if (!projectConfigRemoteSpec.auth) { - return undefined - } - - try { - return { - type: projectConfigRemoteSpec.auth.type, - username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername), - password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword) - } - } catch (error) { - console.info(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.name} (${projectConfigRemoteSpec.url}). Perhaps a different public key was used?:`, error); - return undefined - } - } - - private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version { - return { - ...version, - specifications: version.specifications.map(spec => ({ - ...spec, - isDefault: spec.name === defaultSpecificationName - })) - } - } -} diff --git a/src/features/projects/data/GitHubProjectDetailsDataSource.ts b/src/features/projects/data/GitHubProjectDetailsDataSource.ts new file mode 100644 index 00000000..bfce229d --- /dev/null +++ b/src/features/projects/data/GitHubProjectDetailsDataSource.ts @@ -0,0 +1,341 @@ +import { createHash } from "crypto" +import { IEncryptionService } from "@/features/encrypt/EncryptionService" +import { + Project, + Version, + IProjectDetailsDataSource, + IGitHubGraphQLClient, + ProjectConfigParser, + IProjectConfig, + ProjectConfigRemoteVersion, + ProjectConfigRemoteSpecification +} from "../domain" +import RemoteConfig from "../domain/RemoteConfig" +import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder" + +type GraphQLRef = { + name: string + target: { + oid: string + tree: { + entries: { name: string }[] + } + } +} + +type GraphQLPullRequest = { + number: number + headRefName: string + baseRefName: string + baseRefOid: string + files?: { + nodes?: { path: string }[] + } +} + +export default class GitHubProjectDetailsDataSource implements IProjectDetailsDataSource { + private readonly graphQlClient: IGitHubGraphQLClient + private readonly repositoryNameSuffix: string + private readonly projectConfigurationFilename: string + private readonly encryptionService: IEncryptionService + private readonly remoteConfigEncoder: IRemoteConfigEncoder + + constructor(config: { + graphQlClient: IGitHubGraphQLClient + repositoryNameSuffix: string + projectConfigurationFilename: string + encryptionService: IEncryptionService + remoteConfigEncoder: IRemoteConfigEncoder + }) { + this.graphQlClient = config.graphQlClient + this.repositoryNameSuffix = config.repositoryNameSuffix + this.projectConfigurationFilename = config.projectConfigurationFilename.replace(/\.ya?ml$/, "") + this.encryptionService = config.encryptionService + this.remoteConfigEncoder = config.remoteConfigEncoder + } + + async getProjectDetails(owner: string, repo: string): Promise { + const repoName = repo.endsWith(this.repositoryNameSuffix) + ? repo + : `${repo}${this.repositoryNameSuffix}` + + const response = await this.fetchRepository(owner, repoName) + if (!response.repository) { + return null + } + + const repository = response.repository + const pullRequests = this.mapPullRequests(repository.pullRequests?.edges || []) + + return this.mapToProject({ + owner, + name: repository.name, + defaultBranchRef: repository.defaultBranchRef, + configYml: repository.configYml, + configYaml: repository.configYaml, + branches: repository.branches?.edges?.map((e: { node: GraphQLRef }) => e.node) || [], + tags: repository.tags?.edges?.map((e: { node: GraphQLRef }) => e.node) || [], + pullRequests + }) + } + + private async fetchRepository(owner: string, name: string) { + const request = { + query: ` + query ProjectDetails($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + name + defaultBranchRef { + name + target { + ... on Commit { oid } + } + } + configYml: object(expression: "HEAD:${this.projectConfigurationFilename}.yml") { + ... on Blob { text } + } + configYaml: object(expression: "HEAD:${this.projectConfigurationFilename}.yaml") { + ... on Blob { text } + } + branches: refs(refPrefix: "refs/heads/", first: 100) { + edges { + node { + name + target { + ... on Commit { + oid + tree { entries { name } } + } + } + } + } + } + tags: refs(refPrefix: "refs/tags/", first: 100) { + edges { + node { + name + target { + ... on Commit { + oid + tree { entries { name } } + } + } + } + } + } + pullRequests(first: 100, states: [OPEN]) { + edges { + node { + number + headRefName + baseRefName + baseRefOid + files(first: 100) { + nodes { path } + } + } + } + } + } + } + `, + variables: { owner, name } + } + + return await this.graphQlClient.graphql(request) + } + + private mapPullRequests(edges: { node: GraphQLPullRequest }[]): Map { + const map = new Map() + for (const edge of edges) { + const pr = edge.node + map.set(pr.headRefName, { + number: pr.number, + baseRefName: pr.baseRefName, + baseRefOid: pr.baseRefOid, + changedFiles: pr.files?.nodes?.map(f => f.path) || [] + }) + } + return map + } + + private mapToProject(data: { + owner: string + name: string + defaultBranchRef: { name: string; target: { oid: string } } + configYml?: { text: string } + configYaml?: { text: string } + branches: GraphQLRef[] + tags: GraphQLRef[] + pullRequests: Map + }): Project { + const config = this.parseConfig(data.configYml, data.configYaml) + const defaultName = data.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") + + let imageURL: string | undefined + if (config?.image) { + imageURL = `/api/blob/${data.owner}/${data.name}/${encodeURIComponent(config.image)}?ref=${data.defaultBranchRef.target.oid}` + } + + const branchVersions = data.branches.map(branch => { + const pr = data.pullRequests.get(branch.name) + return this.mapVersion({ + owner: data.owner, + repoName: data.name, + ref: branch, + isDefault: branch.name === data.defaultBranchRef.name, + pr + }) + }) + + const tagVersions = data.tags.map(tag => + this.mapVersion({ owner: data.owner, repoName: data.name, ref: tag }) + ) + + const versions = this.sortVersions( + this.addRemoteVersions( + [...branchVersions, ...tagVersions], + config?.remoteVersions || [] + ), + data.defaultBranchRef.name + ) + .filter(v => v.specifications.length > 0) + .map(v => this.setDefaultSpecification(v, config?.defaultSpecificationName)) + + return { + id: `${data.owner}-${defaultName}`, + owner: data.owner, + name: defaultName, + displayName: config?.name || defaultName, + versions, + imageURL, + ownerUrl: `https://github.com/${data.owner}`, + url: `https://github.com/${data.owner}/${data.name}` + } + } + + private parseConfig(configYml?: { text: string }, configYaml?: { text: string }): IProjectConfig | null { + const yml = configYml || configYaml + if (!yml?.text) return null + return new ProjectConfigParser().parse(yml.text) + } + + private mapVersion(params: { + owner: string + repoName: string + ref: GraphQLRef + isDefault?: boolean + pr?: { number: number; baseRefName: string; baseRefOid: string; changedFiles: string[] } + }): Version { + const { owner, repoName, ref, isDefault, pr } = params + + const specifications = ref.target.tree.entries + .filter(f => this.isOpenAPISpec(f.name)) + .map(file => { + const isChanged = pr?.changedFiles.includes(file.name) ?? false + return { + id: file.name, + name: file.name, + url: `/api/blob/${owner}/${repoName}/${encodeURIComponent(file.name)}?ref=${ref.target.oid}`, + editURL: `https://github.com/${owner}/${repoName}/edit/${ref.name}/${encodeURIComponent(file.name)}`, + diffURL: isChanged ? `/api/diff/${owner}/${repoName}/${encodeURIComponent(file.name)}?baseRefOid=${pr!.baseRefOid}&to=${ref.target.oid}` : undefined, + diffBaseBranch: isChanged ? pr!.baseRefName : undefined, + diffBaseOid: isChanged ? pr!.baseRefOid : undefined, + diffPrUrl: isChanged ? `https://github.com/${owner}/${repoName}/pull/${pr!.number}` : undefined, + isDefault: false + } + }) + .sort((a, b) => a.name.localeCompare(b.name)) + + return { + id: ref.name, + name: ref.name, + specifications, + url: `https://github.com/${owner}/${repoName}/tree/${ref.name}`, + isDefault: isDefault || false + } + } + + private isOpenAPISpec(filename: string): boolean { + return !filename.startsWith(".") && (filename.endsWith(".yml") || filename.endsWith(".yaml")) + } + + private sortVersions(versions: Version[], defaultBranchName: string): Version[] { + const priority = [defaultBranchName, "main", "master", "develop", "development", "trunk"].reverse() + const sorted = [...versions].sort((a, b) => a.name.localeCompare(b.name)) + + for (const branch of priority) { + const idx = sorted.findIndex(v => v.name === branch) + if (idx !== -1) { + const [version] = sorted.splice(idx, 1) + sorted.unshift(version) + } + } + return sorted + } + + private addRemoteVersions(versions: Version[], remoteVersions: ProjectConfigRemoteVersion[]): Version[] { + const result = [...versions] + const ids = result.map(v => v.id) + + for (const rv of remoteVersions) { + const baseId = this.makeURLSafeID((rv.id || rv.name).toLowerCase()) + const count = ids.filter(id => id === baseId).length + const versionId = baseId + (count > 0 ? count : "") + + const specifications = rv.specifications.map(spec => { + const remoteConfig: RemoteConfig = { + url: spec.url, + auth: this.tryDecryptAuth(spec) + } + const encoded = this.remoteConfigEncoder.encode(remoteConfig) + const hash = createHash("sha256").update(JSON.stringify(remoteConfig)).digest("hex").slice(0, 16) + + return { + id: this.makeURLSafeID((spec.id || spec.name).toLowerCase()), + name: spec.name, + url: `/api/remotes/${encoded}`, + urlHash: hash, + isDefault: false + } + }) + + result.push({ id: versionId, name: rv.name, specifications, isDefault: false }) + ids.push(baseId) + } + + return result + } + + private makeURLSafeID(str: string): string { + return str.replace(/ /g, "-").replace(/[^A-Za-z0-9-]/g, "") + } + + private tryDecryptAuth(spec: ProjectConfigRemoteSpecification) { + if (!spec.auth) return undefined + try { + return { + type: spec.auth.type, + username: this.encryptionService.decrypt(spec.auth.encryptedUsername), + password: this.encryptionService.decrypt(spec.auth.encryptedPassword) + } + } catch { + return undefined + } + } + + private setDefaultSpecification(version: Version, defaultName?: string): Version { + return { + ...version, + specifications: version.specifications.map(spec => ({ + ...spec, + isDefault: spec.name === defaultName + })) + } + } +} diff --git a/src/features/projects/data/GitHubProjectListDataSource.ts b/src/features/projects/data/GitHubProjectListDataSource.ts new file mode 100644 index 00000000..2be4777b --- /dev/null +++ b/src/features/projects/data/GitHubProjectListDataSource.ts @@ -0,0 +1,142 @@ +import { + ProjectSummary, + IProjectListDataSource, + IGitHubLoginDataSource, + IGitHubGraphQLClient, + ProjectConfigParser +} from "../domain" + +type GraphQLProjectListRepository = { + readonly name: string + readonly owner: { + readonly login: string + } + readonly configYml?: { + readonly text: string + } + readonly configYaml?: { + readonly text: string + } +} + +export default class GitHubProjectListDataSource implements IProjectListDataSource { + 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 getProjectList(): Promise { + const logins = await this.loginsDataSource.getLogins() + const repositories = await this.getRepositoriesForLogins(logins) + return repositories + .map(repo => this.mapToSummary(repo)) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + private async getRepositoriesForLogins(logins: string[]): Promise { + const searchQueries: string[] = [ + `"${this.repositoryNameSuffix}" in:name is:private`, + ...logins.map(login => `"${this.repositoryNameSuffix}" in:name user:${login} is:public`) + ] + + const results = await Promise.all( + searchQueries.map(query => this.searchRepositories(query)) + ) + + const allRepos = results.flat() + const uniqueRepos = this.deduplicateRepositories(allRepos) + return uniqueRepos.filter(repo => repo.name.endsWith(this.repositoryNameSuffix)) + } + + private async searchRepositories( + searchQuery: string, + cursor?: string + ): Promise { + const request = { + query: ` + query ProjectList($searchQuery: String!, $cursor: String) { + search(query: $searchQuery, type: REPOSITORY, first: 100, after: $cursor) { + results: nodes { + ... on Repository { + name + owner { login } + configYml: object(expression: "HEAD:${this.projectConfigurationFilename}.yml") { + ... on Blob { text } + } + configYaml: object(expression: "HEAD:${this.projectConfigurationFilename}.yaml") { + ... on Blob { text } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + `, + variables: { searchQuery, cursor } + } + + const response = await this.graphQlClient.graphql(request) + if (!response.search?.results) { + return [] + } + + const pageInfo = response.search.pageInfo + if (!pageInfo?.hasNextPage || !pageInfo?.endCursor) { + return response.search.results + } + + const nextResults = await this.searchRepositories(searchQuery, pageInfo.endCursor) + return response.search.results.concat(nextResults) + } + + private deduplicateRepositories(repos: GraphQLProjectListRepository[]): GraphQLProjectListRepository[] { + const seen = new Set() + return repos.filter(repo => { + const key = `${repo.owner.login}/${repo.name}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + } + + private mapToSummary(repo: GraphQLProjectListRepository): ProjectSummary { + const config = this.parseConfig(repo) + const defaultName = repo.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") + + return { + id: `${repo.owner.login}-${defaultName}`, + name: defaultName, + displayName: config?.name || defaultName, + owner: repo.owner.login, + imageURL: config?.image ? this.makeImageURL(repo.owner.login, repo.name, config.image) : undefined, + url: `https://github.com/${repo.owner.login}/${repo.name}`, + ownerUrl: `https://github.com/${repo.owner.login}` + } + } + + private parseConfig(repo: GraphQLProjectListRepository) { + const yml = repo.configYml || repo.configYaml + if (!yml?.text) return null + const parser = new ProjectConfigParser() + return parser.parse(yml.text) + } + + private makeImageURL(owner: string, repo: string, imagePath: string): string { + return `/api/blob/${owner}/${repo}/${encodeURIComponent(imagePath)}?ref=HEAD` + } +} diff --git a/src/features/projects/data/GitHubRepositoryDataSource.ts b/src/features/projects/data/GitHubRepositoryDataSource.ts deleted file mode 100644 index 6a776e34..00000000 --- a/src/features/projects/data/GitHubRepositoryDataSource.ts +++ /dev/null @@ -1,330 +0,0 @@ -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 - }[] - } - } -} - -type GraphQLPullRequest = { - readonly number: number - readonly headRefName: string - readonly baseRefName: string - readonly baseRefOid: string - readonly changedFiles: 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(async repositories => { - // Fetch PRs for all repositories in a single query - const allPullRequests = await this.getOpenPullRequestsForRepositories( - repositories.map(repo => ({ - owner: repo.owner.login, - name: repo.name - })) - ) - - // Map from the internal model to the public model. - return repositories.map(repository => { - const repoKey = `${repository.owner.login}/${repository.name}` - const pullRequests = allPullRequests.get(repoKey) || new Map() - - const branches = repository.branches.edges.map(branch => { - const pr = pullRequests.get(branch.node.name) - - return { - id: branch.node.target.oid, - name: branch.node.name, - baseRef: pr?.baseRefName, - baseRefOid: pr?.baseRefOid, - prNumber: pr?.number, - files: branch.node.target.tree.entries, - changedFiles: pr?.changedFiles - } - }) - - 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: branches, - tags: repository.tags.edges.map(branch => { - return { - id: branch.node.target.oid, - name: branch.node.name, - files: branch.node.target.tree.entries - } - }) - } - }) - }) - } - - private async getOpenPullRequestsForRepositories( - repositories: Array<{ owner: string, name: string }> - ): Promise>> { - if (repositories.length === 0) { - return new Map() - } - - // Build a query that fetches PRs for all repositories - const repoQueries = repositories - .map((repo, index) => { - return ` - repo${index}: repository(owner: "${repo.owner}", name: "${repo.name}") { - pullRequests(first: 100, states: [OPEN]) { - edges { - node { - number - headRefName - baseRefName - baseRefOid - files(first: 100) { - nodes { - path - } - } - } - } - } - }` - }) - .join("\n") - - const request = { - query: ` - query PullRequests { - ${repoQueries} - } - `, - variables: {} - } - - const response = await this.graphQlClient.graphql(request) - const allPullRequests = new Map>() - - repositories.forEach((repo, index) => { - const repoKey = `${repo.owner}/${repo.name}` - const repoData = response[`repo${index}`] - const pullRequests = new Map() - - if (repoData?.pullRequests?.edges) { - type RawGraphQLPullRequest = { - number: number - headRefName: string - baseRefName: string - baseRefOid: string - files?: { - nodes?: { path: string }[] - } - } - const pullRequestEdges = repoData.pullRequests.edges as Edge[] - - pullRequestEdges.forEach(edge => { - const pr = edge.node - const changedFiles = pr.files?.nodes?.map(f => f.path) || [] - pullRequests.set(pr.headRefName, { - number: pr.number, - headRefName: pr.headRefName, - baseRefName: pr.baseRefName, - baseRefOid: pr.baseRefOid, - changedFiles - }) - }) - } - - allPullRequests.set(repoKey, pullRequests) - }) - - return allPullRequests - } - - 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 748a41b9..e8827b99 100644 --- a/src/features/projects/data/index.ts +++ b/src/features/projects/data/index.ts @@ -1,5 +1,4 @@ -export { default as GitHubProjectDataSource } from "./GitHubProjectDataSource" -export * from "./GitHubProjectDataSource" export { default as useProjectSelection } from "./useProjectSelection" export { default as GitHubLoginDataSource } from "./GitHubLoginDataSource" -export { default as GitHubRepositoryDataSource } from "./GitHubRepositoryDataSource" +export { default as GitHubProjectListDataSource } from "./GitHubProjectListDataSource" +export { default as GitHubProjectDetailsDataSource } from "./GitHubProjectDetailsDataSource" diff --git a/src/features/projects/data/useProjectSelection.ts b/src/features/projects/data/useProjectSelection.ts index de8327b4..dd773a00 100644 --- a/src/features/projects/data/useProjectSelection.ts +++ b/src/features/projects/data/useProjectSelection.ts @@ -2,70 +2,97 @@ import NProgress from "nprogress" import { useRouter, usePathname } from "next/navigation" -import { useContext } from "react" -import { ProjectsContext } from "@/common" import { Project, + ProjectSummary, ProjectNavigator, getProjectSelectionFromPath, getDefaultSpecification - } from "../domain" +import { useProjectDetails } from "../view/ProjectDetailsContext" export default function useProjectSelection() { const router = useRouter() const pathname = usePathname() - const { projects } = useContext(ProjectsContext) - const selection = getProjectSelectionFromPath({ projects, path: pathname }) + const { getProject } = useProjectDetails() + const pathnameReader = { get pathname() { return pathname } } const projectNavigator = new ProjectNavigator({ router, pathnameReader }) + + // Parse owner/name from URL to look up the project + const pathParts = pathname.split("/").filter(Boolean) + const owner = pathParts[0] + const name = pathParts[1] + const hasVersionInUrl = pathParts.length >= 3 + + // Get project from cache (if loaded) + const cachedProject = owner && name ? getProject(owner, name) : undefined + + // Use existing getProjectSelectionFromPath for full selection logic + // It handles complex cases like version IDs with slashes, remote versions, etc. + const selection = cachedProject + ? getProjectSelectionFromPath({ projects: [cachedProject], path: pathname }) + : { project: undefined, version: undefined, specification: undefined } + + const currentProject = selection.project + const currentVersion = selection.version + const currentSpecification = selection.specification + return { get project() { - return selection.project + return currentProject }, get version() { - return selection.version + return currentVersion }, get specification() { - return selection.specification + return currentSpecification }, - selectProject: (project: Project) => { - const version = project.versions[0] - const specification = getDefaultSpecification(version) + selectProject: (project: ProjectSummary | Project) => { NProgress.start() - projectNavigator.navigate( - project.owner, - project.name, - version.id, - specification.id - ) + // Navigate to project base - the page will handle loading details and redirecting + router.push(`/${project.owner}/${project.name}`) }, selectVersion: (versionId: string) => { + if (!currentProject || !currentSpecification) return NProgress.start() projectNavigator.navigateToVersion( - selection.project!, + currentProject, versionId, - selection.specification!.name + currentSpecification.name ) }, selectSpecification: (specificationId: string) => { + if (!currentProject || !currentVersion) return NProgress.start() projectNavigator.navigate( - selection.project!.owner, - selection.project!.name, - selection.version!.id, specificationId + currentProject.owner, + currentProject.name, + currentVersion.id, + specificationId ) }, navigateToSelectionIfNeeded: () => { + // Only redirect to defaults if URL has no version/spec at all + // (i.e., user navigated to just /owner/repo) + if (currentProject && !hasVersionInUrl) { + const defaultVersion = currentProject.versions[0] + if (defaultVersion) { + const defaultSpec = getDefaultSpecification(defaultVersion) + router.replace(`/${currentProject.owner}/${currentProject.name}/${encodeURIComponent(defaultVersion.id)}/${encodeURIComponent(defaultSpec.id)}`) + return + } + } + projectNavigator.navigateIfNeeded({ - projectOwner: selection.project?.owner, - projectName: selection.project?.name, - versionId: selection.version?.id, - specificationId: selection.specification?.id + projectOwner: currentProject?.owner, + projectName: currentProject?.name, + versionId: currentVersion?.id, + specificationId: currentSpecification?.id }) } } diff --git a/src/features/projects/domain/CachingProjectDataSource.ts b/src/features/projects/domain/CachingProjectDataSource.ts deleted file mode 100644 index 73f85281..00000000 --- a/src/features/projects/domain/CachingProjectDataSource.ts +++ /dev/null @@ -1,33 +0,0 @@ -import Project from "./Project"; -import IProjectDataSource from "./IProjectDataSource"; -import IProjectRepository from "./IProjectRepository"; - - -export default class CachingProjectDataSource implements IProjectDataSource { - private dataSource: IProjectDataSource; - private repository: IProjectRepository; - - constructor(config: { - dataSource: IProjectDataSource; - repository: IProjectRepository; - }) { - this.dataSource = config.dataSource; - this.repository = config.repository; - } - - async getProjects(): Promise { - const cache = await this.repository.get(); - if (cache && cache.length > 0) { - return cache; - } - const projects = await this.dataSource.getProjects(); - await this.repository.set(projects); - return projects; - } - - async refreshProjects(): Promise { - const projects = await this.dataSource.getProjects(); - await this.repository.set(projects); - return projects; - } -} diff --git a/src/features/projects/domain/FilteringGitHubRepositoryDataSource.ts b/src/features/projects/domain/FilteringGitHubRepositoryDataSource.ts deleted file mode 100644 index 647770c6..00000000 --- a/src/features/projects/domain/FilteringGitHubRepositoryDataSource.ts +++ /dev/null @@ -1,30 +0,0 @@ -import IGitHubRepositoryDataSource, { - GitHubRepository -} from "./IGitHubRepositoryDataSource" -import { splitOwnerAndRepository } from "@/common" - -export default class FilteringGitHubRepositoryDataSource implements IGitHubRepositoryDataSource { - private readonly dataSource: IGitHubRepositoryDataSource - private readonly rawHiddenRepositories: string[] - - constructor(config: { - dataSource: IGitHubRepositoryDataSource, - hiddenRepositories: string[] - }) { - this.dataSource = config.dataSource - this.rawHiddenRepositories = config.hiddenRepositories - } - - async getRepositories(): Promise { - const repositories = await this.dataSource.getRepositories() - const hiddenRepositories = this.rawHiddenRepositories - .map(splitOwnerAndRepository) - .filter(e => e !== undefined) - return repositories.filter(repository => { - const hiddenMatch = hiddenRepositories.find(e => - e.owner == repository.owner && e.repository == repository.name - ) - return hiddenMatch === undefined - }) - } -} \ No newline at end of file diff --git a/src/features/projects/domain/IGitHubRepositoryDataSource.ts b/src/features/projects/domain/IGitHubRepositoryDataSource.ts deleted file mode 100644 index c9c86e4e..00000000 --- a/src/features/projects/domain/IGitHubRepositoryDataSource.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 baseRef?: string - readonly baseRefOid?: string - readonly prNumber?: number - readonly files: { - readonly name: string - }[] - readonly changedFiles?: string[] -} - -export default interface IGitHubRepositoryDataSource { - getRepositories(): Promise -} diff --git a/src/features/projects/domain/IProjectDataSource.ts b/src/features/projects/domain/IProjectDataSource.ts deleted file mode 100644 index d89b9ff5..00000000 --- a/src/features/projects/domain/IProjectDataSource.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Project from "./Project" - -export default interface IProjectDataSource { - getProjects(): Promise -} diff --git a/src/features/projects/domain/IProjectDetailsDataSource.ts b/src/features/projects/domain/IProjectDetailsDataSource.ts new file mode 100644 index 00000000..3ae6bb1a --- /dev/null +++ b/src/features/projects/domain/IProjectDetailsDataSource.ts @@ -0,0 +1,5 @@ +import Project from "./Project" + +export default interface IProjectDetailsDataSource { + getProjectDetails(owner: string, repo: string): Promise +} diff --git a/src/features/projects/domain/IProjectListDataSource.ts b/src/features/projects/domain/IProjectListDataSource.ts new file mode 100644 index 00000000..98830c1e --- /dev/null +++ b/src/features/projects/domain/IProjectListDataSource.ts @@ -0,0 +1,5 @@ +import ProjectSummary from "./ProjectSummary" + +export default interface IProjectListDataSource { + getProjectList(): Promise +} diff --git a/src/features/projects/domain/IProjectRepository.ts b/src/features/projects/domain/IProjectRepository.ts deleted file mode 100644 index 52a4af69..00000000 --- a/src/features/projects/domain/IProjectRepository.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Project from "./Project" - -export default interface IProjectRepository { - get(): Promise - set(projects: Project[]): Promise - delete(): Promise -} diff --git a/src/features/projects/domain/ProjectRepository.ts b/src/features/projects/domain/ProjectRepository.ts deleted file mode 100644 index 0b68bfdb..00000000 --- a/src/features/projects/domain/ProjectRepository.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { IUserDataRepository, ZodJSONCoder } from "@/common" -import IProjectRepository from "./IProjectRepository" -import Project, { ProjectSchema } from "./Project" - -interface IUserIDReader { - getUserId(): Promise -} - -export default class ProjectRepository implements IProjectRepository { - private readonly userIDReader: IUserIDReader - private readonly repository: IUserDataRepository - - constructor(config: { userIDReader: IUserIDReader, repository: IUserDataRepository }) { - this.userIDReader = config.userIDReader - this.repository = config.repository - } - - async get(): Promise { - const userId = await this.userIDReader.getUserId() - const string = await this.repository.get(userId) - - if (!string) { - return undefined - } - try { - return ZodJSONCoder.decode(ProjectSchema.array(), string) - } catch { // swallow decode errors and treat as missing cache - console.warn("[ProjectRepository] Failed to decode cached projects – treating as cache miss") - return undefined - } - } - - async set(projects: Project[]): Promise { - const userId = await this.userIDReader.getUserId() - const string = ZodJSONCoder.encode(ProjectSchema.array(), projects) - await this.repository.setExpiring(userId, string, 30 * 24 * 3600) - } - - async delete(): Promise { - const userId = await this.userIDReader.getUserId() - await this.repository.delete(userId) - } -} diff --git a/src/features/projects/domain/ProjectSummary.ts b/src/features/projects/domain/ProjectSummary.ts new file mode 100644 index 00000000..8c5987e3 --- /dev/null +++ b/src/features/projects/domain/ProjectSummary.ts @@ -0,0 +1,15 @@ +import { z } from "zod" + +export const ProjectSummarySchema = z.object({ + id: z.string(), + name: z.string(), + displayName: z.string(), + owner: z.string(), + imageURL: z.string().optional(), + url: z.string().optional(), + ownerUrl: z.string() +}) + +type ProjectSummary = z.infer + +export default ProjectSummary diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index 2bf08445..33ce81a5 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -1,19 +1,17 @@ -export { default as CachingProjectDataSource } from "./CachingProjectDataSource" -export { default as FilteringGitHubRepositoryDataSource } from "./FilteringGitHubRepositoryDataSource" export { default as getProjectSelectionFromPath } from "./getProjectSelectionFromPath" 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" +export type { default as IProjectListDataSource } from "./IProjectListDataSource" +export type { default as IProjectDetailsDataSource } from "./IProjectDetailsDataSource" export type { default as OpenApiSpecification } from "./OpenApiSpecification" export type { default as Project } from "./Project" export { default as ProjectConfigParser } from "./ProjectConfigParser" +export type { default as ProjectSummary } from "./ProjectSummary" +export { ProjectSummarySchema } from "./ProjectSummary" export { default as ProjectNavigator } from "./ProjectNavigator" -export { default as ProjectRepository } from "./ProjectRepository" export { default as updateWindowTitle } from "./updateWindowTitle" export type { default as Version } from "./Version" export { getDefaultSpecification } from "./Version" diff --git a/src/features/projects/view/ProjectDetailsContext.tsx b/src/features/projects/view/ProjectDetailsContext.tsx new file mode 100644 index 00000000..e2070a22 --- /dev/null +++ b/src/features/projects/view/ProjectDetailsContext.tsx @@ -0,0 +1,21 @@ +"use client" + +import { createContext, useContext } from "react" +import { Project } from "@/features/projects/domain" + +export interface ProjectDetailsContextValue { + getProject: (owner: string, repo: string) => Project | undefined + fetchProject: (owner: string, repo: string) => Promise + isLoading: (owner: string, repo: string) => boolean + getError: (owner: string, repo: string) => string | null +} + +export const ProjectDetailsContext = createContext(null) + +export function useProjectDetails() { + const context = useContext(ProjectDetailsContext) + if (!context) { + throw new Error("useProjectDetails must be used within ProjectDetailsContextProvider") + } + return context +} diff --git a/src/features/projects/view/ProjectDetailsContextProvider.tsx b/src/features/projects/view/ProjectDetailsContextProvider.tsx new file mode 100644 index 00000000..ee9c73cd --- /dev/null +++ b/src/features/projects/view/ProjectDetailsContextProvider.tsx @@ -0,0 +1,86 @@ +"use client" + +import { useState, useCallback, useRef } from "react" +import { Project } from "@/features/projects/domain" +import { ProjectDetailsContext } from "./ProjectDetailsContext" + +type CacheEntry = { + project: Project | null + loading: boolean + error: string | null +} + +export default function ProjectDetailsContextProvider({ + children +}: { + children: React.ReactNode +}) { + const [cache, setCache] = useState>(new Map()) + const inFlightRef = useRef>>(new Map()) + + const makeKey = (owner: string, repo: string) => `${owner}/${repo}` + + const getProject = useCallback((owner: string, repo: string): Project | undefined => { + const entry = cache.get(makeKey(owner, repo)) + return entry?.project ?? undefined + }, [cache]) + + const isLoading = useCallback((owner: string, repo: string): boolean => { + return cache.get(makeKey(owner, repo))?.loading ?? false + }, [cache]) + + const getError = useCallback((owner: string, repo: string): string | null => { + return cache.get(makeKey(owner, repo))?.error ?? null + }, [cache]) + + const fetchProject = useCallback(async (owner: string, repo: string): Promise => { + const key = makeKey(owner, repo) + + // Return in-flight request if exists + const inFlight = inFlightRef.current.get(key) + if (inFlight) return inFlight + + // Mark as loading + setCache(prev => { + const next = new Map(prev) + next.set(key, { project: prev.get(key)?.project ?? null, loading: true, error: null }) + return next + }) + + const promise = fetch(`/api/projects/${owner}/${repo}`) + .then(res => { + if (res.status === 404) return { project: null } + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() + }) + .then(({ project }) => { + setCache(prev => { + const next = new Map(prev) + next.set(key, { project, loading: false, error: null }) + return next + }) + return project + }) + .catch(err => { + console.error(`Failed to fetch project ${key}:`, err) + setCache(prev => { + const next = new Map(prev) + next.set(key, { project: null, loading: false, error: "Failed to load project" }) + return next + }) + return null + }) + .finally(() => { + inFlightRef.current.delete(key) + }) + + inFlightRef.current.set(key, promise) + return promise + }, []) + + return ( + + {children} + + ) +} diff --git a/src/features/projects/view/ProjectListContext.tsx b/src/features/projects/view/ProjectListContext.tsx new file mode 100644 index 00000000..e6e547bc --- /dev/null +++ b/src/features/projects/view/ProjectListContext.tsx @@ -0,0 +1,21 @@ +"use client" + +import { createContext, useContext } from "react" +import { ProjectSummary } from "@/features/projects/domain" + +export interface ProjectListContextValue { + projects: ProjectSummary[] + loading: boolean + error: string | null + refresh: () => void +} + +export const ProjectListContext = createContext(null) + +export function useProjectList() { + const context = useContext(ProjectListContext) + if (!context) { + throw new Error("useProjectList must be used within ProjectListContextProvider") + } + return context +} diff --git a/src/features/projects/view/ProjectListContextProvider.tsx b/src/features/projects/view/ProjectListContextProvider.tsx new file mode 100644 index 00000000..e1e8f0e8 --- /dev/null +++ b/src/features/projects/view/ProjectListContextProvider.tsx @@ -0,0 +1,57 @@ +"use client" + +import { useState, useEffect, useCallback, useRef } from "react" +import { ProjectSummary } from "@/features/projects/domain" +import { ProjectListContext } from "./ProjectListContext" + +export default function ProjectListContextProvider({ + children +}: { + children: React.ReactNode +}) { + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const isLoadingRef = useRef(false) + const [refreshTrigger, setRefreshTrigger] = useState(0) + + const refresh = useCallback(() => { + setRefreshTrigger(prev => prev + 1) + }, []) + + useEffect(() => { + if (isLoadingRef.current) return + isLoadingRef.current = true + + const controller = new AbortController() + + fetch("/api/projects", { signal: controller.signal }) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() + }) + .then(({ projects }) => { + setProjects(projects || []) + setError(null) + }) + .catch(err => { + if (err.name === "AbortError") return + console.error("Failed to fetch project list:", err) + setError("Failed to load projects") + }) + .finally(() => { + isLoadingRef.current = false + setLoading(false) + }) + + return () => { + controller.abort() + } + }, [refreshTrigger]) + + return ( + + {children} + + ) +} diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx deleted file mode 100644 index 2d60c844..00000000 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef, useCallback } from "react"; -import { ProjectsContext } from "@/common"; -import { Project } from "@/features/projects/domain"; - -const ProjectsContextProvider = ({ - initialProjects, - children, -}: { - initialProjects?: Project[]; - children?: React.ReactNode; -}) => { - const [projects, setProjects] = useState(initialProjects || []); - const [refreshing, setRefreshing] = useState(false); - const isLoadingRef = useRef(false); - - // Fingerprint uses urlHash for remote specs (stable), URL for others (already stable) - const fingerprint = (list: Project[]) => - list.flatMap(p => p.versions.flatMap(v => v.specifications.map(s => s.urlHash ?? s.url))).sort().join(); - -const refreshProjects = useCallback(() => { - if (isLoadingRef.current) return; - isLoadingRef.current = true; - setRefreshing(true); - fetch("/api/refresh-projects", { method: "POST" }) - .then((res) => res.json()) - .then(({ projects: newProjects }) => { - if (newProjects) { - setProjects(prev => fingerprint(prev) === fingerprint(newProjects) ? prev : newProjects); - } - }) - .catch((error) => console.error("Failed to refresh projects", error)) - .finally(() => { - isLoadingRef.current = false; - setRefreshing(false); - }); -}, []); - - // Trigger background refresh after initial mount - -useEffect(() => { - // Initial refresh - const timeout = window.setTimeout(() => { - refreshProjects(); - }, 0); - const handleVisibilityChange = () => { - if (!document.hidden) refreshProjects(); - }; - document.addEventListener("visibilitychange", handleVisibilityChange); - window.addEventListener("focus", refreshProjects); - return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - window.removeEventListener("focus", refreshProjects); - window.clearTimeout(timeout); - }; -}, [refreshProjects]); - - return ( - - {children} - - ); -}; - -export default ProjectsContextProvider; diff --git a/src/features/projects/view/index.ts b/src/features/projects/view/index.ts new file mode 100644 index 00000000..d04532e2 --- /dev/null +++ b/src/features/projects/view/index.ts @@ -0,0 +1,4 @@ +export { ProjectListContext, useProjectList } from "./ProjectListContext" +export { default as ProjectListContextProvider } from "./ProjectListContextProvider" +export { ProjectDetailsContext, useProjectDetails } from "./ProjectDetailsContext" +export { default as ProjectDetailsContextProvider } from "./ProjectDetailsContextProvider" diff --git a/src/features/sidebar/view/internal/sidebar/Header.tsx b/src/features/sidebar/view/internal/sidebar/Header.tsx index 3b6a2258..e5cf8d44 100644 --- a/src/features/sidebar/view/internal/sidebar/Header.tsx +++ b/src/features/sidebar/view/internal/sidebar/Header.tsx @@ -1,48 +1,15 @@ "use client" -import { useContext, useEffect, useRef, useState } from "react" import Image from "next/image" -import { Box, Button, CircularProgress, Tooltip, Typography } from "@mui/material" -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faCheck } from "@fortawesome/free-solid-svg-icons" +import { Box, Button, Typography } from "@mui/material" import { useRouter } from "next/navigation" import * as NProgress from "nprogress" -import { ProjectsContext } from "@/common" import { useCloseSidebarOnSelection } from "@/features/sidebar/data" const Header = ({ siteName }: { siteName?: string }) => { const router = useRouter() const { closeSidebarIfNeeded } = useCloseSidebarOnSelection() - const { refreshing } = useContext(ProjectsContext) - const [showCheck, setShowCheck] = useState(false) - const [fadeOut, setFadeOut] = useState(false) - const wasRefreshing = useRef(false) - useEffect(() => { - if (refreshing) { - // Clear any existing checkmark when a new refresh starts - const clearTimeout_ = setTimeout(() => { - setShowCheck(false) - setFadeOut(false) - }, 0) - wasRefreshing.current = true - return () => clearTimeout(clearTimeout_) - } else if (wasRefreshing.current) { - wasRefreshing.current = false - // Delay checkmark appearance to let spinner fade out first - const showTimeout = setTimeout(() => { - setShowCheck(true) - setFadeOut(false) - }, 400) - const fadeTimeout = setTimeout(() => setFadeOut(true), 1600) - const hideTimeout = setTimeout(() => setShowCheck(false), 2200) - return () => { - clearTimeout(showTimeout) - clearTimeout(fadeTimeout) - clearTimeout(hideTimeout) - } - } - }, [refreshing]) return ( { paddingLeft: 2.1, minHeight: 64, display: "flex", - justifyContent: "space-between", alignItems: "center" }}> - - - - - - - - - - ) } diff --git a/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx b/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx index a9d16e39..2fa3815a 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx @@ -1,12 +1,12 @@ "use client" import SpacedList from "@/common/ui/SpacedList" -import { Project } from "@/features/projects/domain" +import { ProjectSummary } from "@/features/projects/domain" import ProjectListItem from "./ProjectListItem" -const PopulatedProjectList = ({ projects }: { projects: Project[] }) => { +const PopulatedProjectList = ({ projects }: { projects: ProjectSummary[] }) => { return ( - {projects.map(project => ( + {projects.map(project => ( ))} diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectAvatar.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectAvatar.tsx index fd2d3bcb..7cb1f5d0 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/ProjectAvatar.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectAvatar.tsx @@ -1,6 +1,6 @@ import { Box, SxProps } from "@mui/system" import { Avatar } from "@mui/material" -import { Project } from "@/features/projects/domain" +import { ProjectSummary } from "@/features/projects/domain" import { getSvgPath } from "figma-squircle" function ProjectAvatar({ @@ -8,7 +8,7 @@ function ProjectAvatar({ width, height }: { - project: Project, + project: ProjectSummary, width: number, height: number }) { diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx index 0215d9e8..658b4519 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx @@ -1,14 +1,17 @@ -'use client' +'use client' -import { Suspense, useContext } from "react" +import { Suspense } from "react" import { Typography } from "@mui/material" import ProjectListFallback from "./ProjectListFallback" import PopulatedProjectList from "./PopulatedProjectList" -import { ProjectsContext } from "@/common" +import { useProjectList } from "@/features/projects/view/ProjectListContext" const ProjectList = () => { + const { projects, loading } = useProjectList() - const { projects } = useContext(ProjectsContext) + if (loading && projects.length === 0) { + return + } return ( }> diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx index 9084ca2a..946d6607 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx @@ -1,13 +1,12 @@ "use client" -import { useContext } from "react" -import { ProjectsContext } from "@/common" import SpacedList from "@/common/ui/SpacedList" import PopulatedProjectList from "./PopulatedProjectList" import { Skeleton as ProjectListItemSkeleton } from "./ProjectListItem" +import { useProjectList } from "@/features/projects/view/ProjectListContext" const StaleProjectList = () => { - const { projects } = useContext(ProjectsContext) + const { projects } = useProjectList() if (projects.length > 0) { return } else { diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx index cfa9294b..a652fd3d 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx @@ -10,10 +10,8 @@ import { Typography, } from "@mui/material"; import MenuItemHover from "@/common/ui/MenuItemHover"; -import { Project } from "@/features/projects/domain"; +import { ProjectSummary } from "@/features/projects/domain"; import { useProjectSelection } from "@/features/projects/data"; -import { useContext } from "react"; -import { ProjectsContext } from "@/common"; import ProjectAvatar, { Squircle as ProjectAvatarSquircle, } from "./ProjectAvatar"; @@ -21,9 +19,8 @@ import { useCloseSidebarOnSelection } from "@/features/sidebar/data"; const AVATAR_SIZE = { width: 40, height: 40 }; -const ProjectListItem = ({ project }: { project: Project }) => { +const ProjectListItem = ({ project }: { project: ProjectSummary }) => { const { project: selectedProject, selectProject } = useProjectSelection(); - const { refreshProjects } = useContext(ProjectsContext); const selected = project.id === selectedProject?.id; const { closeSidebarIfNeeded } = useCloseSidebarOnSelection(); @@ -33,7 +30,6 @@ const ProjectListItem = ({ project }: { project: Project }) => { onSelect={() => { closeSidebarIfNeeded(); selectProject(project); - refreshProjects(); }} avatar={ Date: Thu, 15 Jan 2026 06:27:57 +0100 Subject: [PATCH 02/12] Add tests for GitHubProjectListDataSource and GitHubProjectDetailsDataSource Coverage includes: - Project list: filtering, deduplication, pagination, config parsing, image URLs - Project details: versions, specifications, PRs, remote versions, default spec --- .../GitHubProjectDetailsDataSource.test.ts | 478 ++++++++++++++++++ .../GitHubProjectListDataSource.test.ts | 290 +++++++++++ 2 files changed, 768 insertions(+) create mode 100644 __test__/projects/GitHubProjectDetailsDataSource.test.ts create mode 100644 __test__/projects/GitHubProjectListDataSource.test.ts diff --git a/__test__/projects/GitHubProjectDetailsDataSource.test.ts b/__test__/projects/GitHubProjectDetailsDataSource.test.ts new file mode 100644 index 00000000..212a0ef2 --- /dev/null +++ b/__test__/projects/GitHubProjectDetailsDataSource.test.ts @@ -0,0 +1,478 @@ +import { jest } from "@jest/globals" +import { GitHubProjectDetailsDataSource } from "@/features/projects/data" +import { IGitHubGraphQLClient } from "@/features/projects/domain" +import { IEncryptionService } from "@/features/encrypt/EncryptionService" +import { IRemoteConfigEncoder } from "@/features/projects/domain/RemoteConfigEncoder" + +const createMockGraphQLClient = (response: unknown = null): IGitHubGraphQLClient => ({ + graphql: jest.fn<() => Promise>().mockResolvedValue(response) +}) + +const createMockEncryptionService = (): IEncryptionService => ({ + encrypt: jest.fn<(data: string) => string>().mockImplementation(data => `encrypted:${data}`), + decrypt: jest.fn<(data: string) => string>().mockImplementation(data => data.replace("encrypted:", "")) +}) + +const createMockRemoteConfigEncoder = (): IRemoteConfigEncoder => ({ + encode: jest.fn<() => string>().mockReturnValue("encoded-config"), + decode: jest.fn() +}) + +const createSut = (overrides: { + graphQlClient?: IGitHubGraphQLClient + repositoryNameSuffix?: string + projectConfigurationFilename?: string + encryptionService?: IEncryptionService + remoteConfigEncoder?: IRemoteConfigEncoder +} = {}) => { + return new GitHubProjectDetailsDataSource({ + graphQlClient: overrides.graphQlClient || createMockGraphQLClient(), + repositoryNameSuffix: overrides.repositoryNameSuffix || "-openapi", + projectConfigurationFilename: overrides.projectConfigurationFilename || ".framna-docs.yml", + encryptionService: overrides.encryptionService || createMockEncryptionService(), + remoteConfigEncoder: overrides.remoteConfigEncoder || createMockRemoteConfigEncoder() + }) +} + +const createRepositoryResponse = (overrides: { + name?: string + defaultBranchRef?: { name: string; target: { oid: string } } + configYml?: { text: string } | null + branches?: { name: string; target: { oid: string; tree: { entries: { name: string }[] } } }[] + tags?: { name: string; target: { oid: string; tree: { entries: { name: string }[] } } }[] + pullRequests?: { number: number; headRefName: string; baseRefName: string; baseRefOid: string; files?: { nodes: { path: string }[] } }[] +} = {}) => ({ + repository: { + name: overrides.name || "my-project-openapi", + defaultBranchRef: overrides.defaultBranchRef || { name: "main", target: { oid: "abc123" } }, + configYml: overrides.configYml === null ? undefined : (overrides.configYml || undefined), + configYaml: undefined, + branches: { + edges: (overrides.branches || [{ name: "main", target: { oid: "abc123", tree: { entries: [{ name: "api.yml" }] } } }]) + .map(node => ({ node })) + }, + tags: { + edges: (overrides.tags || []).map(node => ({ node })) + }, + pullRequests: { + edges: (overrides.pullRequests || []).map(node => ({ node })) + } + } +}) + +describe("GitHubProjectDetailsDataSource", () => { + describe("getProjectDetails", () => { + test("It returns null when repository is not found", async () => { + const graphQlClient = createMockGraphQLClient({ repository: null }) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result).toBeNull() + }) + + test("It returns project with basic info", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse()) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result).not.toBeNull() + expect(result!.id).toBe("acme-my-project") + expect(result!.name).toBe("my-project") + expect(result!.owner).toBe("acme") + expect(result!.ownerUrl).toBe("https://github.com/acme") + expect(result!.url).toBe("https://github.com/acme/my-project-openapi") + }) + + test("It uses display name from config", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + configYml: { text: "name: My Awesome API" } + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.displayName).toBe("My Awesome API") + }) + + test("It uses project name when config has no name", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + configYml: null + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.displayName).toBe("my-project") + }) + + test("It generates image URL from config", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + configYml: { text: "image: logo.png" }, + defaultBranchRef: { name: "main", target: { oid: "abc123" } } + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.imageURL).toBe("/api/blob/acme/my-project-openapi/logo.png?ref=abc123") + }) + + test("It appends suffix to repo name if not present", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse()) + const sut = createSut({ graphQlClient }) + + await sut.getProjectDetails("acme", "my-project") + + expect(graphQlClient.graphql).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { owner: "acme", name: "my-project-openapi" } + }) + ) + }) + + test("It does not double-append suffix if already present", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse()) + const sut = createSut({ graphQlClient }) + + await sut.getProjectDetails("acme", "my-project-openapi") + + expect(graphQlClient.graphql).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { owner: "acme", name: "my-project-openapi" } + }) + ) + }) + }) + + describe("versions", () => { + test("It creates versions from branches", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + branches: [ + { name: "main", target: { oid: "abc123", tree: { entries: [{ name: "api.yml" }] } } }, + { name: "develop", target: { oid: "def456", tree: { entries: [{ name: "api.yml" }] } } } + ] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions).toHaveLength(2) + expect(result!.versions.map(v => v.name)).toContain("main") + expect(result!.versions.map(v => v.name)).toContain("develop") + }) + + test("It creates versions from tags", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + branches: [{ name: "main", target: { oid: "abc123", tree: { entries: [{ name: "api.yml" }] } } }], + tags: [{ name: "v1.0.0", target: { oid: "tag123", tree: { entries: [{ name: "api.yml" }] } } }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions.map(v => v.name)).toContain("v1.0.0") + }) + + test("It marks default branch as default", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + defaultBranchRef: { name: "main", target: { oid: "abc123" } }, + branches: [ + { name: "main", target: { oid: "abc123", tree: { entries: [{ name: "api.yml" }] } } }, + { name: "develop", target: { oid: "def456", tree: { entries: [{ name: "api.yml" }] } } } + ] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + const mainVersion = result!.versions.find(v => v.name === "main") + const developVersion = result!.versions.find(v => v.name === "develop") + expect(mainVersion!.isDefault).toBe(true) + expect(developVersion!.isDefault).toBe(false) + }) + + test("It sorts versions with default branch first", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + defaultBranchRef: { name: "main", target: { oid: "abc123" } }, + branches: [ + { name: "zebra", target: { oid: "z123", tree: { entries: [{ name: "api.yml" }] } } }, + { name: "main", target: { oid: "abc123", tree: { entries: [{ name: "api.yml" }] } } }, + { name: "alpha", target: { oid: "a123", tree: { entries: [{ name: "api.yml" }] } } } + ] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions[0].name).toBe("main") + }) + + test("It filters out versions without specifications", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + branches: [ + { name: "main", target: { oid: "abc123", tree: { entries: [{ name: "api.yml" }] } } }, + { name: "empty", target: { oid: "def456", tree: { entries: [{ name: "README.md" }] } } } + ] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions).toHaveLength(1) + expect(result!.versions[0].name).toBe("main") + }) + }) + + describe("specifications", () => { + test("It creates specifications from YAML files", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + branches: [{ + name: "main", + target: { + oid: "abc123", + tree: { entries: [{ name: "api.yml" }, { name: "other.yaml" }] } + } + }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions[0].specifications).toHaveLength(2) + expect(result!.versions[0].specifications.map(s => s.name)).toContain("api.yml") + expect(result!.versions[0].specifications.map(s => s.name)).toContain("other.yaml") + }) + + test("It ignores non-YAML files", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + branches: [{ + name: "main", + target: { + oid: "abc123", + tree: { entries: [{ name: "api.yml" }, { name: "README.md" }, { name: "script.js" }] } + } + }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions[0].specifications).toHaveLength(1) + expect(result!.versions[0].specifications[0].name).toBe("api.yml") + }) + + test("It ignores hidden files (starting with dot)", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + branches: [{ + name: "main", + target: { + oid: "abc123", + tree: { entries: [{ name: "api.yml" }, { name: ".framna-docs.yml" }] } + } + }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions[0].specifications).toHaveLength(1) + expect(result!.versions[0].specifications[0].name).toBe("api.yml") + }) + + test("It generates correct URLs for specifications", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + branches: [{ + name: "feature/test", + target: { oid: "abc123", tree: { entries: [{ name: "api.yml" }] } } + }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + const spec = result!.versions[0].specifications[0] + expect(spec.url).toBe("/api/blob/acme/my-project-openapi/api.yml?ref=abc123") + expect(spec.editURL).toBe("https://github.com/acme/my-project-openapi/edit/feature/test/api.yml") + }) + + test("It sorts specifications alphabetically", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + branches: [{ + name: "main", + target: { + oid: "abc123", + tree: { entries: [{ name: "zebra.yml" }, { name: "alpha.yml" }, { name: "middle.yml" }] } + } + }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions[0].specifications.map(s => s.name)).toEqual(["alpha.yml", "middle.yml", "zebra.yml"]) + }) + }) + + describe("pull requests", () => { + test("It adds diff URL for changed files in PRs", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + branches: [{ + name: "feature-branch", + target: { oid: "feature123", tree: { entries: [{ name: "api.yml" }] } } + }], + pullRequests: [{ + number: 42, + headRefName: "feature-branch", + baseRefName: "main", + baseRefOid: "base123", + files: { nodes: [{ path: "api.yml" }] } + }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + const spec = result!.versions.find(v => v.name === "feature-branch")!.specifications[0] + expect(spec.diffURL).toBe("/api/diff/acme/my-project-openapi/api.yml?baseRefOid=base123&to=feature123") + expect(spec.diffBaseBranch).toBe("main") + expect(spec.diffPrUrl).toBe("https://github.com/acme/my-project-openapi/pull/42") + }) + + test("It does not add diff URL for unchanged files", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + branches: [{ + name: "feature-branch", + target: { oid: "feature123", tree: { entries: [{ name: "api.yml" }, { name: "other.yml" }] } } + }], + pullRequests: [{ + number: 42, + headRefName: "feature-branch", + baseRefName: "main", + baseRefOid: "base123", + files: { nodes: [{ path: "api.yml" }] } + }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + const specs = result!.versions.find(v => v.name === "feature-branch")!.specifications + const changedSpec = specs.find(s => s.name === "api.yml") + const unchangedSpec = specs.find(s => s.name === "other.yml") + + expect(changedSpec!.diffURL).toBeDefined() + expect(unchangedSpec!.diffURL).toBeUndefined() + }) + }) + + describe("remote versions", () => { + test("It creates versions from remote config", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + configYml: { + text: ` +remoteVersions: + - name: External API + specifications: + - name: External Spec + url: https://example.com/spec.yml +` + }, + branches: [] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions).toHaveLength(1) + expect(result!.versions[0].name).toBe("External API") + expect(result!.versions[0].specifications).toHaveLength(1) + expect(result!.versions[0].specifications[0].name).toBe("External Spec") + }) + + test("It generates remote spec URLs through encoder", async () => { + const remoteConfigEncoder = createMockRemoteConfigEncoder() + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + configYml: { + text: ` +remoteVersions: + - name: External + specifications: + - name: Spec + url: https://example.com/spec.yml +` + }, + branches: [] + })) + const sut = createSut({ graphQlClient, remoteConfigEncoder }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions[0].specifications[0].url).toBe("/api/remotes/encoded-config") + }) + }) + + describe("default specification", () => { + test("It sets isDefault on the correct specification based on config", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + configYml: { text: "defaultSpecificationName: bar-service.yml" }, + branches: [{ + name: "main", + target: { + oid: "abc123", + tree: { entries: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ]} + } + }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + const specs = result!.versions[0].specifications + expect(specs.find(s => s.name === "bar-service.yml")!.isDefault).toBe(true) + expect(specs.find(s => s.name === "foo-service.yml")!.isDefault).toBe(false) + expect(specs.find(s => s.name === "baz-service.yml")!.isDefault).toBe(false) + }) + + test("It sets isDefault to false for all specifications when no default is configured", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + configYml: null, + branches: [{ + name: "main", + target: { + oid: "abc123", + tree: { entries: [{ name: "api.yml" }, { name: "other.yml" }] } + } + }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions[0].specifications.every(s => s.isDefault === false)).toBe(true) + }) + + test("It silently ignores defaultSpecificationName if no matching spec is found", async () => { + const graphQlClient = createMockGraphQLClient(createRepositoryResponse({ + configYml: { text: "defaultSpecificationName: non-existent.yml" }, + branches: [{ + name: "main", + target: { + oid: "abc123", + tree: { entries: [{ name: "api.yml" }] } + } + }] + })) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectDetails("acme", "my-project") + + expect(result!.versions[0].specifications.every(s => s.isDefault === false)).toBe(true) + }) + }) +}) diff --git a/__test__/projects/GitHubProjectListDataSource.test.ts b/__test__/projects/GitHubProjectListDataSource.test.ts new file mode 100644 index 00000000..bb4f2b81 --- /dev/null +++ b/__test__/projects/GitHubProjectListDataSource.test.ts @@ -0,0 +1,290 @@ +import { jest } from "@jest/globals" +import { GitHubProjectListDataSource } from "@/features/projects/data" +import { IGitHubLoginDataSource, IGitHubGraphQLClient } from "@/features/projects/domain" + +const createMockLoginsDataSource = (logins: string[] = []): IGitHubLoginDataSource => ({ + getLogins: jest.fn<() => Promise>().mockResolvedValue(logins) +}) + +const createMockGraphQLClient = (responses: Record[] = []): IGitHubGraphQLClient => { + let callIndex = 0 + return { + graphql: jest.fn<() => Promise>().mockImplementation(() => { + const response = responses[callIndex] || { search: { results: [], pageInfo: { hasNextPage: false } } } + callIndex++ + return Promise.resolve(response) + }) + } +} + +const createSut = (overrides: { + loginsDataSource?: IGitHubLoginDataSource + graphQlClient?: IGitHubGraphQLClient + repositoryNameSuffix?: string + projectConfigurationFilename?: string +} = {}) => { + return new GitHubProjectListDataSource({ + loginsDataSource: overrides.loginsDataSource || createMockLoginsDataSource(), + graphQlClient: overrides.graphQlClient || createMockGraphQLClient(), + repositoryNameSuffix: overrides.repositoryNameSuffix || "-openapi", + projectConfigurationFilename: overrides.projectConfigurationFilename || ".framna-docs.yml" + }) +} + +describe("GitHubProjectListDataSource", () => { + test("It returns an empty list when no repositories are found", async () => { + const graphQlClient = createMockGraphQLClient([ + { search: { results: [], pageInfo: { hasNextPage: false } } } + ]) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectList() + + expect(result).toEqual([]) + }) + + test("It returns project summaries for repositories with matching suffix", async () => { + const graphQlClient = createMockGraphQLClient([ + { + search: { + results: [ + { name: "my-project-openapi", owner: { login: "acme" } } + ], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectList() + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + id: "acme-my-project", + name: "my-project", + displayName: "my-project", + owner: "acme", + url: "https://github.com/acme/my-project-openapi", + ownerUrl: "https://github.com/acme" + }) + }) + + test("It filters out repositories without matching suffix", async () => { + const graphQlClient = createMockGraphQLClient([ + { + search: { + results: [ + { name: "my-project-openapi", owner: { login: "acme" } }, + { name: "other-repo", owner: { login: "acme" } } + ], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectList() + + expect(result).toHaveLength(1) + expect(result[0].name).toBe("my-project") + }) + + test("It uses display name from config when available", async () => { + const graphQlClient = createMockGraphQLClient([ + { + search: { + results: [ + { + name: "my-project-openapi", + owner: { login: "acme" }, + configYml: { text: "name: My Awesome Project" } + } + ], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectList() + + expect(result[0].displayName).toBe("My Awesome Project") + }) + + test("It uses configYaml when configYml is not present", async () => { + const graphQlClient = createMockGraphQLClient([ + { + search: { + results: [ + { + name: "my-project-openapi", + owner: { login: "acme" }, + configYaml: { text: "name: YAML Config Name" } + } + ], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectList() + + expect(result[0].displayName).toBe("YAML Config Name") + }) + + test("It generates image URL from config", async () => { + const graphQlClient = createMockGraphQLClient([ + { + search: { + results: [ + { + name: "my-project-openapi", + owner: { login: "acme" }, + configYml: { text: "image: logo.png" } + } + ], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectList() + + expect(result[0].imageURL).toBe("/api/blob/acme/my-project-openapi/logo.png?ref=HEAD") + }) + + test("It encodes special characters in image path", async () => { + const graphQlClient = createMockGraphQLClient([ + { + search: { + results: [ + { + name: "my-project-openapi", + owner: { login: "acme" }, + configYml: { text: "image: images/my logo.png" } + } + ], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectList() + + expect(result[0].imageURL).toBe("/api/blob/acme/my-project-openapi/images%2Fmy%20logo.png?ref=HEAD") + }) + + test("It deduplicates repositories from multiple search queries", async () => { + const loginsDataSource = createMockLoginsDataSource(["user1"]) + const graphQlClient = createMockGraphQLClient([ + // First query (private repos) + { + search: { + results: [{ name: "shared-openapi", owner: { login: "acme" } }], + pageInfo: { hasNextPage: false } + } + }, + // Second query (user1's public repos) + { + search: { + results: [{ name: "shared-openapi", owner: { login: "acme" } }], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ loginsDataSource, graphQlClient }) + + const result = await sut.getProjectList() + + expect(result).toHaveLength(1) + }) + + test("It sorts projects alphabetically by name", async () => { + const graphQlClient = createMockGraphQLClient([ + { + search: { + results: [ + { name: "zebra-openapi", owner: { login: "acme" } }, + { name: "alpha-openapi", owner: { login: "acme" } }, + { name: "middle-openapi", owner: { login: "acme" } } + ], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectList() + + expect(result.map(p => p.name)).toEqual(["alpha", "middle", "zebra"]) + }) + + test("It handles pagination", async () => { + const graphQlClient = createMockGraphQLClient([ + { + search: { + results: [{ name: "project-a-openapi", owner: { login: "acme" } }], + pageInfo: { hasNextPage: true, endCursor: "cursor1" } + } + }, + { + search: { + results: [{ name: "project-b-openapi", owner: { login: "acme" } }], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectList() + + expect(result).toHaveLength(2) + expect(result.map(p => p.name)).toEqual(["project-a", "project-b"]) + }) + + test("It handles empty search results gracefully", async () => { + const graphQlClient = createMockGraphQLClient([ + { search: { results: null, pageInfo: { hasNextPage: false } } } + ]) + const sut = createSut({ graphQlClient }) + + const result = await sut.getProjectList() + + expect(result).toEqual([]) + }) + + test("It strips .yml extension from config filename", async () => { + const graphQlClient = createMockGraphQLClient([]) + const sut = createSut({ + graphQlClient, + projectConfigurationFilename: ".framna-docs.yml" + }) + + await sut.getProjectList() + + expect(graphQlClient.graphql).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining("HEAD:.framna-docs.yml") + }) + ) + }) + + test("It strips .yaml extension from config filename", async () => { + const graphQlClient = createMockGraphQLClient([]) + const sut = createSut({ + graphQlClient, + projectConfigurationFilename: ".framna-docs.yaml" + }) + + await sut.getProjectList() + + expect(graphQlClient.graphql).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining("HEAD:.framna-docs.yml") + }) + ) + }) +}) From c49a0367431356b3afafdb28b161cfdc95d694b7 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Thu, 15 Jan 2026 16:54:08 +0100 Subject: [PATCH 03/12] Add defensive null checks and improve type clarity - Add null check for defaultSpec in useProjectSelection to prevent potential crash if a version has no specifications - Add explicit type annotations for 404 handling in fetchProject - Add comment explaining why cache is in callback dependencies --- src/features/projects/data/useProjectSelection.ts | 6 ++++-- .../projects/view/ProjectDetailsContextProvider.tsx | 11 ++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/features/projects/data/useProjectSelection.ts b/src/features/projects/data/useProjectSelection.ts index dd773a00..58026388 100644 --- a/src/features/projects/data/useProjectSelection.ts +++ b/src/features/projects/data/useProjectSelection.ts @@ -83,8 +83,10 @@ export default function useProjectSelection() { const defaultVersion = currentProject.versions[0] if (defaultVersion) { const defaultSpec = getDefaultSpecification(defaultVersion) - router.replace(`/${currentProject.owner}/${currentProject.name}/${encodeURIComponent(defaultVersion.id)}/${encodeURIComponent(defaultSpec.id)}`) - return + if (defaultSpec) { + router.replace(`/${currentProject.owner}/${currentProject.name}/${encodeURIComponent(defaultVersion.id)}/${encodeURIComponent(defaultSpec.id)}`) + return + } } } diff --git a/src/features/projects/view/ProjectDetailsContextProvider.tsx b/src/features/projects/view/ProjectDetailsContextProvider.tsx index ee9c73cd..63db1c5d 100644 --- a/src/features/projects/view/ProjectDetailsContextProvider.tsx +++ b/src/features/projects/view/ProjectDetailsContextProvider.tsx @@ -20,6 +20,8 @@ export default function ProjectDetailsContextProvider({ const makeKey = (owner: string, repo: string) => `${owner}/${repo}` + // Note: These callbacks intentionally include `cache` in deps to trigger + // re-renders in consuming components when cache updates const getProject = useCallback((owner: string, repo: string): Project | undefined => { const entry = cache.get(makeKey(owner, repo)) return entry?.project ?? undefined @@ -49,11 +51,14 @@ export default function ProjectDetailsContextProvider({ const promise = fetch(`/api/projects/${owner}/${repo}`) .then(res => { - if (res.status === 404) return { project: null } + if (res.status === 404) { + // Project not found - treat as null project, not an error + return { project: null } + } if (!res.ok) throw new Error(`HTTP ${res.status}`) - return res.json() + return res.json() as Promise<{ project: Project }> }) - .then(({ project }) => { + .then(({ project }: { project: Project | null }) => { setCache(prev => { const next = new Map(prev) next.set(key, { project, loading: false, error: null }) From ccfd6308ed94cdb31ce0e006f4a99cef6bfe3a7b Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Jan 2026 07:07:51 +0100 Subject: [PATCH 04/12] Add Redis caching for project list Cache project list in Redis with 1 minute TTL to eliminate loading skeleton on subsequent page loads. Returns cached data immediately and refreshes in background. --- src/composition.ts | 19 +++++++- .../domain/CachingProjectListDataSource.ts | 38 ++++++++++++++++ .../projects/domain/IProjectListRepository.ts | 7 +++ .../projects/domain/ProjectListRepository.ts | 43 +++++++++++++++++++ src/features/projects/domain/index.ts | 3 ++ 5 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/features/projects/domain/CachingProjectListDataSource.ts create mode 100644 src/features/projects/domain/IProjectListRepository.ts create mode 100644 src/features/projects/domain/ProjectListRepository.ts diff --git a/src/composition.ts b/src/composition.ts index 0d78e657..e5a045fa 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -20,6 +20,10 @@ import { GitHubProjectListDataSource, GitHubProjectDetailsDataSource } from "@/features/projects/data" +import { + CachingProjectListDataSource, + ProjectListRepository +} from "@/features/projects/domain" import { GitHubOAuthTokenRefresher } from "@/features/auth/data" @@ -177,7 +181,7 @@ export const encryptionService = new RsaEncryptionService({ export const remoteConfigEncoder = new RemoteConfigEncoder(encryptionService) -export const projectListDataSource = new GitHubProjectListDataSource({ +const gitHubProjectListDataSource = new GitHubProjectListDataSource({ loginsDataSource: new GitHubLoginDataSource({ graphQlClient: userGitHubClient }), @@ -186,6 +190,19 @@ export const projectListDataSource = new GitHubProjectListDataSource({ projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME") }) +const projectListRepository = new ProjectListRepository({ + userIDReader: session, + repository: new KeyValueUserDataRepository({ + store: new RedisKeyValueStore(env.getOrThrow("REDIS_URL")), + baseKey: "projectList" + }) +}) + +export const projectListDataSource = new CachingProjectListDataSource({ + dataSource: gitHubProjectListDataSource, + repository: projectListRepository +}) + export const projectDetailsDataSource = new GitHubProjectDetailsDataSource({ graphQlClient: userGitHubClient, repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), diff --git a/src/features/projects/domain/CachingProjectListDataSource.ts b/src/features/projects/domain/CachingProjectListDataSource.ts new file mode 100644 index 00000000..572f5396 --- /dev/null +++ b/src/features/projects/domain/CachingProjectListDataSource.ts @@ -0,0 +1,38 @@ +import ProjectSummary from "./ProjectSummary" +import IProjectListDataSource from "./IProjectListDataSource" +import IProjectListRepository from "./IProjectListRepository" + +export default class CachingProjectListDataSource implements IProjectListDataSource { + private dataSource: IProjectListDataSource + private repository: IProjectListRepository + + constructor(config: { + dataSource: IProjectListDataSource + repository: IProjectListRepository + }) { + this.dataSource = config.dataSource + this.repository = config.repository + } + + async getProjectList(): Promise { + const cache = await this.repository.get() + if (cache && cache.length > 0) { + // Return cached data immediately, refresh in background + this.refreshInBackground() + return cache + } + // No cache, fetch and store + const projects = await this.dataSource.getProjectList() + await this.repository.set(projects) + return projects + } + + private async refreshInBackground(): Promise { + try { + const projects = await this.dataSource.getProjectList() + await this.repository.set(projects) + } catch (err) { + console.warn("[CachingProjectListDataSource] Background refresh failed:", err) + } + } +} diff --git a/src/features/projects/domain/IProjectListRepository.ts b/src/features/projects/domain/IProjectListRepository.ts new file mode 100644 index 00000000..b18b46a9 --- /dev/null +++ b/src/features/projects/domain/IProjectListRepository.ts @@ -0,0 +1,7 @@ +import ProjectSummary from "./ProjectSummary" + +export default interface IProjectListRepository { + get(): Promise + set(projects: ProjectSummary[]): Promise + delete(): Promise +} diff --git a/src/features/projects/domain/ProjectListRepository.ts b/src/features/projects/domain/ProjectListRepository.ts new file mode 100644 index 00000000..e87bbe9a --- /dev/null +++ b/src/features/projects/domain/ProjectListRepository.ts @@ -0,0 +1,43 @@ +import { IUserDataRepository, ZodJSONCoder } from "@/common" +import IProjectListRepository from "./IProjectListRepository" +import ProjectSummary, { ProjectSummarySchema } from "./ProjectSummary" + +interface IUserIDReader { + getUserId(): Promise +} + +export default class ProjectListRepository implements IProjectListRepository { + private readonly userIDReader: IUserIDReader + private readonly repository: IUserDataRepository + + constructor(config: { userIDReader: IUserIDReader, repository: IUserDataRepository }) { + this.userIDReader = config.userIDReader + this.repository = config.repository + } + + async get(): Promise { + const userId = await this.userIDReader.getUserId() + const string = await this.repository.get(userId) + + if (!string) { + return undefined + } + try { + return ZodJSONCoder.decode(ProjectSummarySchema.array(), string) + } catch { + console.warn("[ProjectListRepository] Failed to decode cached project list – treating as cache miss") + return undefined + } + } + + async set(projects: ProjectSummary[]): Promise { + const userId = await this.userIDReader.getUserId() + const string = ZodJSONCoder.encode(ProjectSummarySchema.array(), projects) + await this.repository.setExpiring(userId, string, 60) // 1 minute TTL + } + + async delete(): Promise { + const userId = await this.userIDReader.getUserId() + await this.repository.delete(userId) + } +} diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index 33ce81a5..86351891 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -1,3 +1,4 @@ +export { default as CachingProjectListDataSource } from "./CachingProjectListDataSource" export { default as getProjectSelectionFromPath } from "./getProjectSelectionFromPath" export type { default as IGitHubLoginDataSource } from "./IGitHubLoginDataSource" export type { default as IGitHubGraphQLClient } from "./IGitHubGraphQLClient" @@ -5,7 +6,9 @@ export * from "./IGitHubGraphQLClient" export type { default as IProjectConfig } from "./IProjectConfig" export * from "./IProjectConfig" export type { default as IProjectListDataSource } from "./IProjectListDataSource" +export type { default as IProjectListRepository } from "./IProjectListRepository" export type { default as IProjectDetailsDataSource } from "./IProjectDetailsDataSource" +export { default as ProjectListRepository } from "./ProjectListRepository" export type { default as OpenApiSpecification } from "./OpenApiSpecification" export type { default as Project } from "./Project" export { default as ProjectConfigParser } from "./ProjectConfigParser" From e35376abc9ea3cf9bb77e67abb60c6da9d864521 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Jan 2026 07:20:26 +0100 Subject: [PATCH 05/12] Fix React hooks issues and restore decryption logging - Memoize navigateToSelectionIfNeeded with useCallback to prevent unnecessary effect runs on every render - Wrap projectNavigator in useMemo to stabilize dependencies - Reset isLoadingRef in cleanup to prevent race conditions - Restore console.info logging for decryption failures --- .../data/GitHubProjectDetailsDataSource.ts | 3 +- .../projects/data/useProjectSelection.ts | 57 ++++++++++--------- .../view/ProjectListContextProvider.tsx | 1 + 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/features/projects/data/GitHubProjectDetailsDataSource.ts b/src/features/projects/data/GitHubProjectDetailsDataSource.ts index bfce229d..8bc7505e 100644 --- a/src/features/projects/data/GitHubProjectDetailsDataSource.ts +++ b/src/features/projects/data/GitHubProjectDetailsDataSource.ts @@ -324,7 +324,8 @@ export default class GitHubProjectDetailsDataSource implements IProjectDetailsDa username: this.encryptionService.decrypt(spec.auth.encryptedUsername), password: this.encryptionService.decrypt(spec.auth.encryptedPassword) } - } catch { + } catch (error) { + console.info(`Failed to decrypt remote specification auth for ${spec.name} (${spec.url}). Perhaps a different public key was used?:`, error) return undefined } } diff --git a/src/features/projects/data/useProjectSelection.ts b/src/features/projects/data/useProjectSelection.ts index 58026388..9ed90ae4 100644 --- a/src/features/projects/data/useProjectSelection.ts +++ b/src/features/projects/data/useProjectSelection.ts @@ -1,5 +1,6 @@ "use client" +import { useCallback, useMemo } from "react" import NProgress from "nprogress" import { useRouter, usePathname } from "next/navigation" import { @@ -16,12 +17,14 @@ export default function useProjectSelection() { const pathname = usePathname() const { getProject } = useProjectDetails() - const pathnameReader = { - get pathname() { - return pathname + const projectNavigator = useMemo(() => { + const pathnameReader = { + get pathname() { + return pathname + } } - } - const projectNavigator = new ProjectNavigator({ router, pathnameReader }) + return new ProjectNavigator({ router, pathnameReader }) + }, [router, pathname]) // Parse owner/name from URL to look up the project const pathParts = pathname.split("/").filter(Boolean) @@ -42,6 +45,28 @@ export default function useProjectSelection() { const currentVersion = selection.version const currentSpecification = selection.specification + const navigateToSelectionIfNeeded = useCallback(() => { + // Only redirect to defaults if URL has no version/spec at all + // (i.e., user navigated to just /owner/repo) + if (currentProject && !hasVersionInUrl) { + const defaultVersion = currentProject.versions[0] + if (defaultVersion) { + const defaultSpec = getDefaultSpecification(defaultVersion) + if (defaultSpec) { + router.replace(`/${currentProject.owner}/${currentProject.name}/${encodeURIComponent(defaultVersion.id)}/${encodeURIComponent(defaultSpec.id)}`) + return + } + } + } + + projectNavigator.navigateIfNeeded({ + projectOwner: currentProject?.owner, + projectName: currentProject?.name, + versionId: currentVersion?.id, + specificationId: currentSpecification?.id + }) + }, [currentProject, currentVersion, currentSpecification, hasVersionInUrl, projectNavigator, router]) + return { get project() { return currentProject @@ -76,26 +101,6 @@ export default function useProjectSelection() { specificationId ) }, - navigateToSelectionIfNeeded: () => { - // Only redirect to defaults if URL has no version/spec at all - // (i.e., user navigated to just /owner/repo) - if (currentProject && !hasVersionInUrl) { - const defaultVersion = currentProject.versions[0] - if (defaultVersion) { - const defaultSpec = getDefaultSpecification(defaultVersion) - if (defaultSpec) { - router.replace(`/${currentProject.owner}/${currentProject.name}/${encodeURIComponent(defaultVersion.id)}/${encodeURIComponent(defaultSpec.id)}`) - return - } - } - } - - projectNavigator.navigateIfNeeded({ - projectOwner: currentProject?.owner, - projectName: currentProject?.name, - versionId: currentVersion?.id, - specificationId: currentSpecification?.id - }) - } + navigateToSelectionIfNeeded } } diff --git a/src/features/projects/view/ProjectListContextProvider.tsx b/src/features/projects/view/ProjectListContextProvider.tsx index e1e8f0e8..46a1f4f6 100644 --- a/src/features/projects/view/ProjectListContextProvider.tsx +++ b/src/features/projects/view/ProjectListContextProvider.tsx @@ -45,6 +45,7 @@ export default function ProjectListContextProvider({ }) return () => { + isLoadingRef.current = false controller.abort() } }, [refreshTrigger]) From 5e16377403b500b7270570c13e7dbc53e83e6e3d Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Jan 2026 07:24:16 +0100 Subject: [PATCH 06/12] Fix TypeScript type for 404 response in fetch chain --- src/features/projects/view/ProjectDetailsContextProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/projects/view/ProjectDetailsContextProvider.tsx b/src/features/projects/view/ProjectDetailsContextProvider.tsx index 63db1c5d..e87997bb 100644 --- a/src/features/projects/view/ProjectDetailsContextProvider.tsx +++ b/src/features/projects/view/ProjectDetailsContextProvider.tsx @@ -50,7 +50,7 @@ export default function ProjectDetailsContextProvider({ }) const promise = fetch(`/api/projects/${owner}/${repo}`) - .then(res => { + .then((res): Promise<{ project: Project | null }> | { project: null } => { if (res.status === 404) { // Project not found - treat as null project, not an error return { project: null } From c6512d650c382aae1ccafe7fd32255b7dbbd590e Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Jan 2026 07:29:08 +0100 Subject: [PATCH 07/12] Restore hidden repositories filtering for project list --- .../GitHubProjectListDataSource.test.ts | 50 ++++++++++++++++++- src/composition.ts | 3 +- .../data/GitHubProjectListDataSource.ts | 13 +++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/__test__/projects/GitHubProjectListDataSource.test.ts b/__test__/projects/GitHubProjectListDataSource.test.ts index bb4f2b81..6567f251 100644 --- a/__test__/projects/GitHubProjectListDataSource.test.ts +++ b/__test__/projects/GitHubProjectListDataSource.test.ts @@ -22,12 +22,14 @@ const createSut = (overrides: { graphQlClient?: IGitHubGraphQLClient repositoryNameSuffix?: string projectConfigurationFilename?: string + hiddenRepositories?: string[] } = {}) => { return new GitHubProjectListDataSource({ loginsDataSource: overrides.loginsDataSource || createMockLoginsDataSource(), graphQlClient: overrides.graphQlClient || createMockGraphQLClient(), repositoryNameSuffix: overrides.repositoryNameSuffix || "-openapi", - projectConfigurationFilename: overrides.projectConfigurationFilename || ".framna-docs.yml" + projectConfigurationFilename: overrides.projectConfigurationFilename || ".framna-docs.yml", + hiddenRepositories: overrides.hiddenRepositories || [] }) } @@ -287,4 +289,50 @@ describe("GitHubProjectListDataSource", () => { }) ) }) + + test("It filters out hidden repositories", async () => { + const graphQlClient = createMockGraphQLClient([ + { + search: { + results: [ + { name: "visible-openapi", owner: { login: "acme" } }, + { name: "hidden-openapi", owner: { login: "acme" } }, + { name: "also-visible-openapi", owner: { login: "other" } } + ], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ + graphQlClient, + hiddenRepositories: ["acme/hidden-openapi"] + }) + + const result = await sut.getProjectList() + + expect(result).toHaveLength(2) + expect(result.map(p => p.name)).toEqual(["also-visible", "visible"]) + }) + + test("It ignores invalid hidden repository entries", async () => { + const graphQlClient = createMockGraphQLClient([ + { + search: { + results: [ + { name: "project-openapi", owner: { login: "acme" } } + ], + pageInfo: { hasNextPage: false } + } + } + ]) + const sut = createSut({ + graphQlClient, + hiddenRepositories: ["invalid-entry", "", "also-invalid"] + }) + + const result = await sut.getProjectList() + + expect(result).toHaveLength(1) + expect(result[0].name).toBe("project") + }) }) diff --git a/src/composition.ts b/src/composition.ts index e5a045fa..339f4de2 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -187,7 +187,8 @@ const gitHubProjectListDataSource = new GitHubProjectListDataSource({ }), graphQlClient: userGitHubClient, repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), - projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME") + projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME"), + hiddenRepositories: listFromCommaSeparatedString(env.get("HIDDEN_REPOSITORIES")) }) const projectListRepository = new ProjectListRepository({ diff --git a/src/features/projects/data/GitHubProjectListDataSource.ts b/src/features/projects/data/GitHubProjectListDataSource.ts index 2be4777b..09e155e7 100644 --- a/src/features/projects/data/GitHubProjectListDataSource.ts +++ b/src/features/projects/data/GitHubProjectListDataSource.ts @@ -1,3 +1,4 @@ +import { splitOwnerAndRepository } from "@/common" import { ProjectSummary, IProjectListDataSource, @@ -24,27 +25,39 @@ export default class GitHubProjectListDataSource implements IProjectListDataSour private readonly graphQlClient: IGitHubGraphQLClient private readonly repositoryNameSuffix: string private readonly projectConfigurationFilename: string + private readonly hiddenRepositories: { owner: string; repository: string }[] constructor(config: { loginsDataSource: IGitHubLoginDataSource graphQlClient: IGitHubGraphQLClient repositoryNameSuffix: string projectConfigurationFilename: string + hiddenRepositories: string[] }) { this.loginsDataSource = config.loginsDataSource this.graphQlClient = config.graphQlClient this.repositoryNameSuffix = config.repositoryNameSuffix this.projectConfigurationFilename = config.projectConfigurationFilename.replace(/\.ya?ml$/, "") + this.hiddenRepositories = config.hiddenRepositories + .map(splitOwnerAndRepository) + .filter((e): e is { owner: string; repository: string } => e !== undefined) } async getProjectList(): Promise { const logins = await this.loginsDataSource.getLogins() const repositories = await this.getRepositoriesForLogins(logins) return repositories + .filter(repo => !this.isHidden(repo)) .map(repo => this.mapToSummary(repo)) .sort((a, b) => a.name.localeCompare(b.name)) } + private isHidden(repo: GraphQLProjectListRepository): boolean { + return this.hiddenRepositories.some( + hidden => hidden.owner === repo.owner.login && hidden.repository === repo.name + ) + } + private async getRepositoriesForLogins(logins: string[]): Promise { const searchQueries: string[] = [ `"${this.repositoryNameSuffix}" in:name is:private`, From 243864e122a48407b5fe4674660aaadc825e7e08 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Jan 2026 07:52:32 +0100 Subject: [PATCH 08/12] Restore visibility/focus refresh handlers for project list --- .../view/ProjectListContextProvider.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/features/projects/view/ProjectListContextProvider.tsx b/src/features/projects/view/ProjectListContextProvider.tsx index 46a1f4f6..25b22b2e 100644 --- a/src/features/projects/view/ProjectListContextProvider.tsx +++ b/src/features/projects/view/ProjectListContextProvider.tsx @@ -4,6 +4,10 @@ import { useState, useEffect, useCallback, useRef } from "react" import { ProjectSummary } from "@/features/projects/domain" import { ProjectListContext } from "./ProjectListContext" +// Fingerprint for change detection - avoids unnecessary re-renders +const fingerprint = (list: ProjectSummary[]) => + list.map(p => `${p.owner}/${p.name}`).sort().join() + export default function ProjectListContextProvider({ children }: { @@ -30,8 +34,10 @@ export default function ProjectListContextProvider({ if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }) - .then(({ projects }) => { - setProjects(projects || []) + .then(({ projects: newProjects }) => { + setProjects(prev => + fingerprint(prev) === fingerprint(newProjects || []) ? prev : (newProjects || []) + ) setError(null) }) .catch(err => { @@ -50,6 +56,21 @@ export default function ProjectListContextProvider({ } }, [refreshTrigger]) + // Refresh on visibility change and focus (restored from original implementation) + useEffect(() => { + const timeout = window.setTimeout(() => refresh(), 0) + const handleVisibilityChange = () => { + if (!document.hidden) refresh() + } + document.addEventListener("visibilitychange", handleVisibilityChange) + window.addEventListener("focus", refresh) + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange) + window.removeEventListener("focus", refresh) + window.clearTimeout(timeout) + } + }, [refresh]) + return ( {children} From 1618c979d8c5a028cb81422886168e4aeddfe7c5 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Jan 2026 07:57:28 +0100 Subject: [PATCH 09/12] Add ?refresh=true param and restore frontend-driven refresh - Add refresh option to IProjectListDataSource and CachingProjectListDataSource - API route passes ?refresh=true to bypass cache - Remove background refresh (frontend controls refresh) - TTL back to 30 days - Frontend uses ?refresh=true on visibility/focus events --- src/app/api/projects/route.ts | 7 +- .../domain/CachingProjectListDataSource.ts | 22 ++----- .../projects/domain/IProjectListDataSource.ts | 2 +- .../projects/domain/ProjectListRepository.ts | 2 +- .../view/ProjectListContextProvider.tsx | 64 +++++++++++++------ 5 files changed, 58 insertions(+), 39 deletions(-) diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index e9d11ccf..dcd6b6b6 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,9 +1,10 @@ -import { NextResponse } from "next/server" +import { NextRequest, NextResponse } from "next/server" import { projectListDataSource } from "@/composition" -export async function GET() { +export async function GET(request: NextRequest) { + const refresh = request.nextUrl.searchParams.get("refresh") === "true" try { - const projects = await projectListDataSource.getProjectList() + const projects = await projectListDataSource.getProjectList({ refresh }) return NextResponse.json({ projects }) } catch (error) { console.error("Failed to fetch project list:", error) diff --git a/src/features/projects/domain/CachingProjectListDataSource.ts b/src/features/projects/domain/CachingProjectListDataSource.ts index 572f5396..8e87a247 100644 --- a/src/features/projects/domain/CachingProjectListDataSource.ts +++ b/src/features/projects/domain/CachingProjectListDataSource.ts @@ -14,25 +14,15 @@ export default class CachingProjectListDataSource implements IProjectListDataSou this.repository = config.repository } - async getProjectList(): Promise { - const cache = await this.repository.get() - if (cache && cache.length > 0) { - // Return cached data immediately, refresh in background - this.refreshInBackground() - return cache + async getProjectList(options?: { refresh?: boolean }): Promise { + if (!options?.refresh) { + const cache = await this.repository.get() + if (cache && cache.length > 0) { + return cache + } } - // No cache, fetch and store const projects = await this.dataSource.getProjectList() await this.repository.set(projects) return projects } - - private async refreshInBackground(): Promise { - try { - const projects = await this.dataSource.getProjectList() - await this.repository.set(projects) - } catch (err) { - console.warn("[CachingProjectListDataSource] Background refresh failed:", err) - } - } } diff --git a/src/features/projects/domain/IProjectListDataSource.ts b/src/features/projects/domain/IProjectListDataSource.ts index 98830c1e..9412b98e 100644 --- a/src/features/projects/domain/IProjectListDataSource.ts +++ b/src/features/projects/domain/IProjectListDataSource.ts @@ -1,5 +1,5 @@ import ProjectSummary from "./ProjectSummary" export default interface IProjectListDataSource { - getProjectList(): Promise + getProjectList(options?: { refresh?: boolean }): Promise } diff --git a/src/features/projects/domain/ProjectListRepository.ts b/src/features/projects/domain/ProjectListRepository.ts index e87bbe9a..6defc7b6 100644 --- a/src/features/projects/domain/ProjectListRepository.ts +++ b/src/features/projects/domain/ProjectListRepository.ts @@ -33,7 +33,7 @@ export default class ProjectListRepository implements IProjectListRepository { async set(projects: ProjectSummary[]): Promise { const userId = await this.userIDReader.getUserId() const string = ZodJSONCoder.encode(ProjectSummarySchema.array(), projects) - await this.repository.setExpiring(userId, string, 60) // 1 minute TTL + await this.repository.setExpiring(userId, string, 30 * 24 * 3600) // 30 days TTL } async delete(): Promise { diff --git a/src/features/projects/view/ProjectListContextProvider.tsx b/src/features/projects/view/ProjectListContextProvider.tsx index 25b22b2e..f20d7ff9 100644 --- a/src/features/projects/view/ProjectListContextProvider.tsx +++ b/src/features/projects/view/ProjectListContextProvider.tsx @@ -17,19 +17,13 @@ export default function ProjectListContextProvider({ const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const isLoadingRef = useRef(false) - const [refreshTrigger, setRefreshTrigger] = useState(0) - const refresh = useCallback(() => { - setRefreshTrigger(prev => prev + 1) - }, []) - - useEffect(() => { + const fetchProjects = useCallback((forceRefresh: boolean) => { if (isLoadingRef.current) return isLoadingRef.current = true - const controller = new AbortController() - - fetch("/api/projects", { signal: controller.signal }) + const url = forceRefresh ? "/api/projects?refresh=true" : "/api/projects" + fetch(url) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() @@ -41,7 +35,6 @@ export default function ProjectListContextProvider({ setError(null) }) .catch(err => { - if (err.name === "AbortError") return console.error("Failed to fetch project list:", err) setError("Failed to load projects") }) @@ -49,16 +42,52 @@ export default function ProjectListContextProvider({ isLoadingRef.current = false setLoading(false) }) + }, []) - return () => { - isLoadingRef.current = false - controller.abort() - } - }, [refreshTrigger]) + const refresh = useCallback(() => { + fetchProjects(true) + }, [fetchProjects]) + + // Initial load (use cache), then refresh to get fresh data + useEffect(() => { + if (isLoadingRef.current) return + isLoadingRef.current = true + + fetch("/api/projects") + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() + }) + .then(({ projects: newProjects }) => { + setProjects(newProjects || []) + setError(null) + setLoading(false) + // After showing cached data, fetch fresh data + return fetch("/api/projects?refresh=true") + }) + .then(res => { + if (!res || !res.ok) return null + return res.json() + }) + .then(data => { + if (data?.projects) { + setProjects(prev => + fingerprint(prev) === fingerprint(data.projects) ? prev : data.projects + ) + } + }) + .catch(err => { + console.error("Failed to fetch project list:", err) + setError("Failed to load projects") + setLoading(false) + }) + .finally(() => { + isLoadingRef.current = false + }) + }, []) - // Refresh on visibility change and focus (restored from original implementation) + // Refresh on visibility change and focus useEffect(() => { - const timeout = window.setTimeout(() => refresh(), 0) const handleVisibilityChange = () => { if (!document.hidden) refresh() } @@ -67,7 +96,6 @@ export default function ProjectListContextProvider({ return () => { document.removeEventListener("visibilitychange", handleVisibilityChange) window.removeEventListener("focus", refresh) - window.clearTimeout(timeout) } }, [refresh]) From e337dad7efee4199549fbd4ec3cdc5282cfb7316 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Jan 2026 08:03:13 +0100 Subject: [PATCH 10/12] Fix UI not updating after refresh Remove fingerprint comparison entirely - it was preventing the UI from updating when new project data was fetched. --- .../projects/view/ProjectListContextProvider.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/features/projects/view/ProjectListContextProvider.tsx b/src/features/projects/view/ProjectListContextProvider.tsx index f20d7ff9..43972c31 100644 --- a/src/features/projects/view/ProjectListContextProvider.tsx +++ b/src/features/projects/view/ProjectListContextProvider.tsx @@ -4,10 +4,6 @@ import { useState, useEffect, useCallback, useRef } from "react" import { ProjectSummary } from "@/features/projects/domain" import { ProjectListContext } from "./ProjectListContext" -// Fingerprint for change detection - avoids unnecessary re-renders -const fingerprint = (list: ProjectSummary[]) => - list.map(p => `${p.owner}/${p.name}`).sort().join() - export default function ProjectListContextProvider({ children }: { @@ -29,9 +25,7 @@ export default function ProjectListContextProvider({ return res.json() }) .then(({ projects: newProjects }) => { - setProjects(prev => - fingerprint(prev) === fingerprint(newProjects || []) ? prev : (newProjects || []) - ) + setProjects(newProjects || []) setError(null) }) .catch(err => { @@ -71,9 +65,7 @@ export default function ProjectListContextProvider({ }) .then(data => { if (data?.projects) { - setProjects(prev => - fingerprint(prev) === fingerprint(data.projects) ? prev : data.projects - ) + setProjects(data.projects) } }) .catch(err => { From a2db07387d9d501d670afba41ac7fc65ae5874d4 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Jan 2026 08:10:43 +0100 Subject: [PATCH 11/12] Fix delayed selection highlight in sidebar Use owner/name from URL path directly for selection instead of requiring full project details to be loaded first. This makes selection immediate. --- src/features/projects/data/useProjectSelection.ts | 3 +++ .../view/internal/sidebar/projects/ProjectListItem.tsx | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/projects/data/useProjectSelection.ts b/src/features/projects/data/useProjectSelection.ts index 9ed90ae4..27cd07c1 100644 --- a/src/features/projects/data/useProjectSelection.ts +++ b/src/features/projects/data/useProjectSelection.ts @@ -77,6 +77,9 @@ export default function useProjectSelection() { get specification() { return currentSpecification }, + // Immediate selection from URL - doesn't require project details to be loaded + selectedOwner: owner, + selectedName: name, selectProject: (project: ProjectSummary | Project) => { NProgress.start() // Navigate to project base - the page will handle loading details and redirecting diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx index a652fd3d..72381868 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx @@ -20,8 +20,8 @@ import { useCloseSidebarOnSelection } from "@/features/sidebar/data"; const AVATAR_SIZE = { width: 40, height: 40 }; const ProjectListItem = ({ project }: { project: ProjectSummary }) => { - const { project: selectedProject, selectProject } = useProjectSelection(); - const selected = project.id === selectedProject?.id; + const { selectedOwner, selectedName, selectProject } = useProjectSelection(); + const selected = project.owner === selectedOwner && project.name === selectedName; const { closeSidebarIfNeeded } = useCloseSidebarOnSelection(); return ( From ab8144742a01f385f60176d5d7e36d28eee2c919 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Jan 2026 08:25:43 +0100 Subject: [PATCH 12/12] Consolidate fetch logic in ProjectListContextProvider Single fetchProjects function used for both initial load and refresh. Removes duplicate inline fetch logic. --- .../view/ProjectListContextProvider.tsx | 74 ++++++------------- 1 file changed, 22 insertions(+), 52 deletions(-) diff --git a/src/features/projects/view/ProjectListContextProvider.tsx b/src/features/projects/view/ProjectListContextProvider.tsx index 43972c31..65018213 100644 --- a/src/features/projects/view/ProjectListContextProvider.tsx +++ b/src/features/projects/view/ProjectListContextProvider.tsx @@ -12,71 +12,41 @@ export default function ProjectListContextProvider({ const [projects, setProjects] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const isLoadingRef = useRef(false) - - const fetchProjects = useCallback((forceRefresh: boolean) => { - if (isLoadingRef.current) return - isLoadingRef.current = true + const isRefreshingRef = useRef(false) + const fetchProjects = useCallback(async (forceRefresh: boolean): Promise => { const url = forceRefresh ? "/api/projects?refresh=true" : "/api/projects" - fetch(url) - .then(res => { - if (!res.ok) throw new Error(`HTTP ${res.status}`) - return res.json() - }) - .then(({ projects: newProjects }) => { - setProjects(newProjects || []) - setError(null) - }) - .catch(err => { - console.error("Failed to fetch project list:", err) - setError("Failed to load projects") - }) - .finally(() => { - isLoadingRef.current = false - setLoading(false) - }) + const res = await fetch(url) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const { projects: newProjects } = await res.json() + setProjects(newProjects || []) + setError(null) + return newProjects || [] }, []) const refresh = useCallback(() => { + if (isRefreshingRef.current) return + isRefreshingRef.current = true fetchProjects(true) + .catch(err => console.error("Failed to refresh project list:", err)) + .finally(() => { isRefreshingRef.current = false }) }, [fetchProjects]) // Initial load (use cache), then refresh to get fresh data useEffect(() => { - if (isLoadingRef.current) return - isLoadingRef.current = true - - fetch("/api/projects") - .then(res => { - if (!res.ok) throw new Error(`HTTP ${res.status}`) - return res.json() - }) - .then(({ projects: newProjects }) => { - setProjects(newProjects || []) - setError(null) - setLoading(false) - // After showing cached data, fetch fresh data - return fetch("/api/projects?refresh=true") - }) - .then(res => { - if (!res || !res.ok) return null - return res.json() - }) - .then(data => { - if (data?.projects) { - setProjects(data.projects) - } - }) - .catch(err => { + const load = async () => { + try { + await fetchProjects(false) + await fetchProjects(true) + } catch (err) { console.error("Failed to fetch project list:", err) setError("Failed to load projects") + } finally { setLoading(false) - }) - .finally(() => { - isLoadingRef.current = false - }) - }, []) + } + } + load() + }, [fetchProjects]) // Refresh on visibility change and focus useEffect(() => {