From f0b576eba3cdb6f42819d406fbaa4776a738e475 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Mon, 8 Dec 2025 10:19:01 +0100 Subject: [PATCH 1/8] Extract ProjectMapper class from GitHubProjectDataSource Move repository-to-project mapping logic into a dedicated ProjectMapper class. The mapper handles filtering, sorting, and all mapping transformations with generic type support for provider-specific repository types. This prepares the codebase for supporting multiple project source providers by decoupling the mapping logic from the data source implementation. --- .../projects/data/GitHubProjectDataSource.ts | 319 +++--------------- src/features/projects/domain/ProjectMapper.ts | 288 ++++++++++++++++ 2 files changed, 334 insertions(+), 273 deletions(-) create mode 100644 src/features/projects/domain/ProjectMapper.ts diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index f8e1f8d8..15a3fec2 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,25 +1,50 @@ import { IEncryptionService } from "@/features/encrypt/EncryptionService" import { Project, - Version, - IProjectConfig, IProjectDataSource, - ProjectConfigParser, - ProjectConfigRemoteVersion, - IGitHubRepositoryDataSource, - GitHubRepository, - GitHubRepositoryRef, - ProjectConfigRemoteSpecification + IGitHubRepositoryDataSource } from "../domain" -import RemoteConfig from "../domain/RemoteConfig" +import ProjectMapper, { type URLBuilders, type RepositoryWithRefs, type RepositoryRef } from "../domain/ProjectMapper" import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder" +const gitHubURLBuilders: URLBuilders = { + getImageRef(repository: RepositoryWithRefs): string { + return repository.defaultBranchRef.id! + }, + getBlobRef(ref: RepositoryRef): string { + return ref.id! + }, + getOwnerUrl(owner: string): string { + return `https://github.com/${owner}` + }, + getProjectUrl(repository: RepositoryWithRefs): string { + return `https://github.com/${repository.owner}/${repository.name}` + }, + getVersionUrl(repository: RepositoryWithRefs, ref: RepositoryRef): string { + return `https://github.com/${repository.owner}/${repository.name}/tree/${ref.name}` + }, + getSpecEditUrl(repository: RepositoryWithRefs, ref: RepositoryRef, fileName: string): string { + return `https://github.com/${repository.owner}/${repository.name}/edit/${ref.name}/${encodeURIComponent(fileName)}` + }, + getDiffUrl(repository: RepositoryWithRefs, ref: RepositoryRef, fileName: string): string | undefined { + if (!ref.baseRefOid || !ref.id) { + return undefined + } + const encodedPath = fileName.split('/').map(segment => encodeURIComponent(segment)).join('/') + return `/api/diff/${repository.owner}/${repository.name}/${encodedPath}?baseRefOid=${ref.baseRefOid}&to=${ref.id}` + }, + getPrUrl(repository: RepositoryWithRefs, ref: RepositoryRef): string | undefined { + if (!ref.prNumber) { + return undefined + } + return `https://github.com/${repository.owner}/${repository.name}/pull/${ref.prNumber}` + } +} + export default class GitHubProjectDataSource implements IProjectDataSource { private readonly repositoryDataSource: IGitHubRepositoryDataSource - private readonly repositoryNameSuffix: string - private readonly encryptionService: IEncryptionService - private readonly remoteConfigEncoder: IRemoteConfigEncoder - + private readonly projectMapper: ProjectMapper + constructor(config: { repositoryDataSource: IGitHubRepositoryDataSource repositoryNameSuffix: string @@ -27,268 +52,16 @@ export default class GitHubProjectDataSource implements IProjectDataSource { remoteConfigEncoder: IRemoteConfigEncoder }) { this.repositoryDataSource = config.repositoryDataSource - this.repositoryNameSuffix = config.repositoryNameSuffix - this.encryptionService = config.encryptionService - this.remoteConfigEncoder = config.remoteConfigEncoder - } - - async getProjects(): Promise { - const repositories = await this.repositoryDataSource.getRepositories() - return repositories.map(repository => { - return this.mapProject(repository) - }) - .filter((project: Project) => { - return project.versions.length > 0 - }) - .sort((a: Project, b: Project) => { - return a.name.localeCompare(b.name) + this.projectMapper = new ProjectMapper({ + repositoryNameSuffix: config.repositoryNameSuffix, + urlBuilders: gitHubURLBuilders, + encryptionService: config.encryptionService, + remoteConfigEncoder: config.remoteConfigEncoder }) } - - private mapProject(repository: GitHubRepository): Project { - const config = this.getConfig(repository) - let imageURL: string | undefined - if (config && config.image) { - imageURL = this.getGitHubBlobURL({ - ownerName: repository.owner, - repositoryName: repository.name, - path: config.image, - ref: repository.defaultBranchRef.id - }) - } - const versions = this.sortVersions( - this.addRemoteVersions( - this.getVersions(repository), - config?.remoteVersions || [] - ), - repository.defaultBranchRef.name - ).filter(version => { - return version.specifications.length > 0 - }) - .map(version => this.setDefaultSpecification(version, config?.defaultSpecificationName)) - const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") - return { - id: `${repository.owner}-${defaultName}`, - owner: repository.owner, - name: defaultName, - displayName: config?.name || defaultName, - versions, - imageURL: imageURL, - ownerUrl: `https://github.com/${repository.owner}`, - url: `https://github.com/${repository.owner}/${repository.name}` - } - } - - private getConfig(repository: GitHubRepository): IProjectConfig | null { - const yml = repository.configYml || repository.configYaml - if (!yml || !yml.text || yml.text.length == 0) { - return null - } - const parser = new ProjectConfigParser() - return parser.parse(yml.text) - } - - private getVersions(repository: GitHubRepository): Version[] { - const branchVersions = repository.branches.map(branch => { - const isDefaultRef = branch.name == repository.defaultBranchRef.name - return this.mapVersionFromRef({ - ownerName: repository.owner, - repositoryName: repository.name, - ref: branch, - isDefaultRef - }) - }) - const tagVersions = repository.tags.map(tag => { - return this.mapVersionFromRef({ - ownerName: repository.owner, - repositoryName: repository.name, - ref: tag - }) - }) - return branchVersions.concat(tagVersions) - } - - private mapVersionFromRef({ - ownerName, - repositoryName, - ref, - isDefaultRef - }: { - ownerName: string - repositoryName: string - ref: GitHubRepositoryRef - isDefaultRef?: boolean - }): Version { - const specifications = ref.files.filter(file => { - return this.isOpenAPISpecification(file.name) - }).map(file => { - 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: this.getGitHubDiffURL({ - ownerName, - repositoryName, - path: file.name, - baseRefOid: ref.baseRefOid, - headRefOid: ref.id - }), - diffBaseBranch: ref.baseRef, - diffBaseOid: ref.baseRefOid, - diffPrUrl: ref.prNumber ? `https://github.com/${ownerName}/${repositoryName}/pull/${ref.prNumber}` : undefined, - isDefault: false // initial value - } - }).sort((a, b) => a.name.localeCompare(b.name)) - return { - id: ref.name, - name: ref.name, - specifications: specifications, - url: `https://github.com/${ownerName}/${repositoryName}/tree/${ref.name}`, - isDefault: isDefaultRef || false, - } - } - private isOpenAPISpecification(filename: string) { - return !filename.startsWith(".") && ( - filename.endsWith(".yml") || filename.endsWith(".yaml") - ) - } - - private getGitHubBlobURL({ - ownerName, - repositoryName, - path, - ref - }: { - ownerName: string - repositoryName: string - path: string - ref: string - }): string { - const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/') - return `/api/blob/${ownerName}/${repositoryName}/${encodedPath}?ref=${ref}` - } - - private getGitHubDiffURL({ - ownerName, - repositoryName, - path, - baseRefOid, - headRefOid - }: { - ownerName: string; - repositoryName: string; - path: string; - baseRefOid: string | undefined; - headRefOid: string } - ): string | undefined { - if (!baseRefOid) { - return undefined - } else { - const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/') - return `/api/diff/${ownerName}/${repositoryName}/${encodedPath}?baseRefOid=${baseRefOid}&to=${headRefOid}` - } - } - - private addRemoteVersions( - existingVersions: Version[], - remoteVersions: ProjectConfigRemoteVersion[] - ): Version[] { - const versions = [...existingVersions] - const versionIds = versions.map(e => e.id) - for (const remoteVersion of remoteVersions) { - const baseVersionId = this.makeURLSafeID( - (remoteVersion.id || remoteVersion.name).toLowerCase() - ) - // If the version ID exists then we suffix it with a number to ensure unique versions. - // E.g. if "foo" already exists, we make it "foo1". - const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length - const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") - const specifications = remoteVersion.specifications.map(e => { - const remoteConfig: RemoteConfig = { - url: e.url, - auth: this.tryDecryptAuth(e) - }; - - const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig); - - return { - id: this.makeURLSafeID((e.id || e.name).toLowerCase()), - name: e.name, - url: `/api/remotes/${encodedRemoteConfig}`, - isDefault: false // initial value - }; - }) - versions.push({ - id: versionId, - name: remoteVersion.name, - specifications, - isDefault: false - }) - versionIds.push(baseVersionId) - } - return versions - } - - private sortVersions(versions: Version[], defaultBranchName: string): Version[] { - const candidateDefaultBranches = [ - defaultBranchName, "main", "master", "develop", "development", "trunk" - ] - // Reverse them so the top-priority branches end up at the top of the list. - .reverse() - const copiedVersions = [...versions].sort((a, b) => { - return a.name.localeCompare(b.name) - }) - // Move the top-priority branches to the top of the list. - for (const candidateDefaultBranch of candidateDefaultBranches) { - const defaultBranchIndex = copiedVersions.findIndex(version => { - return version.name === candidateDefaultBranch - }) - if (defaultBranchIndex !== -1) { - const branchVersion = copiedVersions[defaultBranchIndex] - copiedVersions.splice(defaultBranchIndex, 1) - copiedVersions.splice(0, 0, branchVersion) - } - } - return copiedVersions - } - - private makeURLSafeID(str: string): string { - return str - .replace(/ /g, "-") - .replace(/[^A-Za-z0-9-]/g, "") - } - - private tryDecryptAuth(projectConfigRemoteSpec: ProjectConfigRemoteSpecification): { type: string, username: string, password: string } | undefined { - if (!projectConfigRemoteSpec.auth) { - return undefined - } - - try { - return { - type: projectConfigRemoteSpec.auth.type, - username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername), - password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword) - } - } catch (error) { - console.info(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.name} (${projectConfigRemoteSpec.url}). Perhaps a different public key was used?:`, error); - return undefined - } - } - - private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version { - return { - ...version, - specifications: version.specifications.map(spec => ({ - ...spec, - isDefault: spec.name === defaultSpecificationName - })) - } + async getProjects(): Promise { + const repositories = await this.repositoryDataSource.getRepositories() + return this.projectMapper.mapRepositories(repositories) } } diff --git a/src/features/projects/domain/ProjectMapper.ts b/src/features/projects/domain/ProjectMapper.ts new file mode 100644 index 00000000..9ff0a837 --- /dev/null +++ b/src/features/projects/domain/ProjectMapper.ts @@ -0,0 +1,288 @@ +import { IEncryptionService } from "@/features/encrypt/EncryptionService" +import { + Project, + Version, + IProjectConfig, + ProjectConfigParser, + ProjectConfigRemoteVersion, + ProjectConfigRemoteSpecification +} from "." +import RemoteConfig from "./RemoteConfig" +import { IRemoteConfigEncoder } from "./RemoteConfigEncoder" + +type ConfigYml = { text: string } | null | undefined + +/** + * Common repository ref type + */ +export type RepositoryRef = { + readonly id?: string + readonly name: string + readonly files: { readonly name: string }[] + // Optional diff-related fields + readonly baseRefOid?: string + readonly baseRef?: string + readonly prNumber?: number +} + +/** + * Common repository type. + * Provider-specific fields should be added via intersection types in the data sources. + */ +export type RepositoryWithRefs = { + readonly name: string + readonly owner: string + readonly defaultBranchRef: { + readonly id?: string + readonly name: string + } + readonly configYml?: { readonly text: string } + readonly configYaml?: { readonly text: string } + readonly branches: RepositoryRef[] + readonly tags: RepositoryRef[] +} + +/** + * URL builders for provider-specific URL generation. + * Generic parameter T allows providers to use extended repository types. + */ +export type URLBuilders = { + /** Returns the ref to use for image URLs (e.g., defaultBranchRef.id or defaultBranchRef.name) */ + getImageRef(repository: T): string + /** Returns the ref to use for blob URLs (e.g., ref.id or ref.name) */ + getBlobRef(ref: RepositoryRef): string + /** Returns the owner URL (e.g., https://github.com/owner) */ + getOwnerUrl(owner: string): string + /** Returns the project URL */ + getProjectUrl(repository: T): string + /** Returns the version URL */ + getVersionUrl(repository: T, ref: RepositoryRef): string + /** Returns the specification edit URL */ + getSpecEditUrl(repository: T, ref: RepositoryRef, fileName: string): string + /** Optional: Returns the diff URL for a specification */ + getDiffUrl?(repository: T, ref: RepositoryRef, fileName: string): string | undefined + /** Optional: Returns the PR URL */ + getPrUrl?(repository: T, ref: RepositoryRef): string | undefined +} + +export interface IProjectMapper { + mapRepositories(repositories: T[]): Project[] +} + +export default class ProjectMapper implements IProjectMapper { + private readonly repositoryNameSuffix: string + private readonly urlBuilders: URLBuilders + private readonly encryptionService: IEncryptionService + private readonly remoteConfigEncoder: IRemoteConfigEncoder + + constructor(config: { + repositoryNameSuffix: string + urlBuilders: URLBuilders + encryptionService: IEncryptionService + remoteConfigEncoder: IRemoteConfigEncoder + }) { + this.repositoryNameSuffix = config.repositoryNameSuffix + this.urlBuilders = config.urlBuilders + this.encryptionService = config.encryptionService + this.remoteConfigEncoder = config.remoteConfigEncoder + } + + mapRepositories(repositories: T[]): Project[] { + return repositories + .map(repository => this.mapRepositoryToProject(repository)) + .filter(project => project.versions.length > 0) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + mapRepositoryToProject(repository: T): Project { + const config = this.parseConfig(repository.configYml, repository.configYaml) + let imageURL: string | undefined + if (config && config.image) { + imageURL = getBlobURL( + repository.owner, + repository.name, + config.image, + this.urlBuilders.getImageRef(repository) + ) + } + + const versions = this.sortVersions( + this.addRemoteVersions( + this.getVersions(repository), + config?.remoteVersions || [] + ), + repository.defaultBranchRef.name + ) + .filter(version => version.specifications.length > 0) + .map(version => this.setDefaultSpecification(version, config?.defaultSpecificationName)) + + const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") + return { + id: `${repository.owner}-${defaultName}`, + owner: repository.owner, + name: defaultName, + displayName: config?.name || defaultName, + versions, + imageURL: imageURL, + ownerUrl: this.urlBuilders.getOwnerUrl(repository.owner), + url: this.urlBuilders.getProjectUrl(repository) + } + } + + private parseConfig(configYml: ConfigYml, configYaml: ConfigYml): IProjectConfig | null { + const yml = configYml || configYaml + if (!yml || !yml.text || yml.text.length == 0) { + return null + } + const parser = new ProjectConfigParser() + return parser.parse(yml.text) + } + + private getVersions(repository: T): Version[] { + const branchVersions = repository.branches.map(branch => { + const isDefaultRef = branch.name === repository.defaultBranchRef.name + return this.mapVersionFromRef(repository, branch, isDefaultRef) + }) + const tagVersions = repository.tags.map(tag => { + return this.mapVersionFromRef(repository, tag, false) + }) + return branchVersions.concat(tagVersions) + } + + private mapVersionFromRef( + repository: T, + ref: RepositoryRef, + isDefaultRef: boolean + ): Version { + const specifications = ref.files + .filter(file => isOpenAPISpecification(file.name)) + .map(file => ({ + id: file.name, + name: file.name, + url: getBlobURL(repository.owner, repository.name, file.name, this.urlBuilders.getBlobRef(ref)), + editURL: this.urlBuilders.getSpecEditUrl(repository, ref, file.name), + diffURL: this.urlBuilders.getDiffUrl?.(repository, ref, file.name), + diffBaseBranch: ref.baseRef, + diffBaseOid: ref.baseRefOid, + diffPrUrl: this.urlBuilders.getPrUrl?.(repository, ref), + isDefault: false + })) + .sort((a, b) => a.name.localeCompare(b.name)) + + return { + id: ref.name, + name: ref.name, + specifications: specifications, + url: this.urlBuilders.getVersionUrl(repository, ref), + isDefault: isDefaultRef + } + } + + private sortVersions(versions: Version[], defaultBranchName: string): Version[] { + const candidateDefaultBranches = [ + defaultBranchName, "main", "master", "develop", "development", "trunk" + ] + // Reverse them so the top-priority branches end up at the top of the list. + .reverse() + const copiedVersions = [...versions].sort((a, b) => { + return a.name.localeCompare(b.name) + }) + // Move the top-priority branches to the top of the list. + for (const candidateDefaultBranch of candidateDefaultBranches) { + const defaultBranchIndex = copiedVersions.findIndex(version => { + return version.name === candidateDefaultBranch + }) + if (defaultBranchIndex !== -1) { + const branchVersion = copiedVersions[defaultBranchIndex] + copiedVersions.splice(defaultBranchIndex, 1) + copiedVersions.splice(0, 0, branchVersion) + } + } + return copiedVersions + } + + private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version { + return { + ...version, + specifications: version.specifications.map(spec => ({ + ...spec, + isDefault: spec.name === defaultSpecificationName + })) + } + } + + private addRemoteVersions( + existingVersions: Version[], + remoteVersions: ProjectConfigRemoteVersion[] + ): Version[] { + const versions = [...existingVersions] + const versionIds = versions.map(e => e.id) + for (const remoteVersion of remoteVersions) { + const baseVersionId = makeURLSafeID( + (remoteVersion.id || remoteVersion.name).toLowerCase() + ) + // If the version ID exists then we suffix it with a number to ensure unique versions. + // E.g. if "foo" already exists, we make it "foo1". + const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length + const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") + const specifications = remoteVersion.specifications.map(e => { + const remoteConfig: RemoteConfig = { + url: e.url, + auth: this.tryDecryptAuth(e) + } + + const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig) + + return { + id: makeURLSafeID((e.id || e.name).toLowerCase()), + name: e.name, + url: `/api/remotes/${encodedRemoteConfig}`, + isDefault: false + } + }) + versions.push({ + id: versionId, + name: remoteVersion.name, + specifications, + isDefault: false + }) + versionIds.push(baseVersionId) + } + return versions + } + + private tryDecryptAuth( + projectConfigRemoteSpec: ProjectConfigRemoteSpecification + ): { type: string, username: string, password: string } | undefined { + if (!projectConfigRemoteSpec.auth) { + return undefined + } + + try { + return { + type: projectConfigRemoteSpec.auth.type, + username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername), + password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword) + } + } catch (error) { + console.info(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.name} (${projectConfigRemoteSpec.url}). Perhaps a different public key was used?:`, error) + return undefined + } + } +} + +function isOpenAPISpecification(filename: string): boolean { + return !filename.startsWith(".") && ( + filename.endsWith(".yml") || filename.endsWith(".yaml") + ) +} + +function makeURLSafeID(str: string): string { + return str + .replace(/ /g, "-") + .replace(/[^A-Za-z0-9-]/g, "") +} + +export function getBlobURL(owner: string, repository: string, path: string, ref: string): string { + return `/api/blob/${owner}/${repository}/${path}?ref=${ref}` +} From 12c60fdb2a9e57d78ae69d88329f82966d57352a Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Mon, 8 Dec 2025 10:31:12 +0100 Subject: [PATCH 2/8] Add ProjectMapper tests --- __test__/projects/ProjectMapper.test.ts | 487 ++++++++++++++++++++++++ __test__/projects/testUtils.ts | 25 ++ 2 files changed, 512 insertions(+) create mode 100644 __test__/projects/ProjectMapper.test.ts create mode 100644 __test__/projects/testUtils.ts diff --git a/__test__/projects/ProjectMapper.test.ts b/__test__/projects/ProjectMapper.test.ts new file mode 100644 index 00000000..07860930 --- /dev/null +++ b/__test__/projects/ProjectMapper.test.ts @@ -0,0 +1,487 @@ +import ProjectMapper, { type URLBuilders, type RepositoryWithRefs, type RepositoryRef } from "@/features/projects/domain/ProjectMapper" +import { noopEncryptionService, base64RemoteConfigEncoder } from "./testUtils" + +// Simple URL builders for testing - uses predictable patterns +const testURLBuilders: URLBuilders = { + getImageRef(repository: RepositoryWithRefs): string { + return repository.defaultBranchRef.id! + }, + getBlobRef(ref: RepositoryRef): string { + return ref.id! + }, + getOwnerUrl(owner: string): string { + return `https://example.com/${owner}` + }, + getProjectUrl(repository: RepositoryWithRefs): string { + return `https://example.com/${repository.owner}/${repository.name}` + }, + getVersionUrl(repository: RepositoryWithRefs, ref: RepositoryRef): string { + return `https://example.com/${repository.owner}/${repository.name}/tree/${ref.name}` + }, + getSpecEditUrl(repository: RepositoryWithRefs, ref: RepositoryRef, fileName: string): string { + return `https://example.com/${repository.owner}/${repository.name}/edit/${ref.name}/${fileName}` + } +} + +function createMapper(repositoryNameSuffix = "-openapi") { + return new ProjectMapper({ + repositoryNameSuffix, + urlBuilders: testURLBuilders, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) +} + +function createRepository(overrides: Partial = {}): RepositoryWithRefs { + return { + owner: "acme", + name: "foo-openapi", + defaultBranchRef: { id: "12345678", name: "main" }, + branches: [{ + id: "12345678", + name: "main", + files: [{ name: "openapi.yml" }] + }], + tags: [], + ...overrides + } +} + +test("It removes suffix from project name", () => { + const mapper = createMapper("-openapi") + const project = mapper.mapRepositoryToProject(createRepository()) + expect(project.id).toEqual("acme-foo") + expect(project.name).toEqual("foo") + expect(project.displayName).toEqual("foo") +}) + +test("It maps branches and tags to versions", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "12345678", + name: "main", + files: [{ name: "openapi.yml" }] + }], + tags: [{ + id: "87654321", + name: "1.0", + files: [{ name: "openapi.yml" }] + }] + })) + expect(project.versions.length).toEqual(2) + expect(project.versions.map(v => v.name)).toContain("main") + expect(project.versions.map(v => v.name)).toContain("1.0") +}) + +test("It supports multiple OpenAPI specifications on a branch", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }] + })) + expect(project.versions[0].specifications.length).toEqual(3) +}) + +test("It filters away branches with no specifications", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [ + { id: "1", name: "main", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "bugfix", files: [{ name: "README.md" }] } + ] + })) + expect(project.versions.length).toEqual(1) + expect(project.versions[0].name).toEqual("main") +}) + +test("It filters away tags with no specifications", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ id: "1", name: "main", files: [{ name: "openapi.yml" }] }], + tags: [ + { id: "2", name: "1.0", files: [{ name: "openapi.yml" }] }, + { id: "3", name: "0.1", files: [{ name: "README.md" }] } + ] + })) + expect(project.versions.length).toEqual(2) +}) + +test("It reads image from configuration file with .yml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "image: icon.png" } + })) + expect(project.imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") +}) + +test("It reads display name from configuration file with .yml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "name: Hello World" } + })) + expect(project.displayName).toEqual("Hello World") +}) + +test("It reads image from configuration file with .yaml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYaml: { text: "image: icon.png" } + })) + expect(project.imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") +}) + +test("It reads display name from configuration file with .yaml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYaml: { text: "name: Hello World" } + })) + expect(project.displayName).toEqual("Hello World") +}) + +test("It sorts versions alphabetically", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [ + { id: "1", name: "anne", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "bobby", files: [{ name: "openapi.yml" }] } + ], + tags: [ + { id: "3", name: "cathrine", files: [{ name: "openapi.yml" }] }, + { id: "4", name: "1.0", files: [{ name: "openapi.yml" }] } + ] + })) + expect(project.versions[0].name).toEqual("1.0") + expect(project.versions[1].name).toEqual("anne") + expect(project.versions[2].name).toEqual("bobby") + expect(project.versions[3].name).toEqual("cathrine") +}) + +test("It prioritizes main, master, develop, and development branch names when sorting versions", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [ + { id: "1", name: "anne", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "develop", files: [{ name: "openapi.yml" }] }, + { id: "3", name: "main", files: [{ name: "openapi.yml" }] }, + { id: "4", name: "development", files: [{ name: "openapi.yml" }] }, + { id: "5", name: "master", files: [{ name: "openapi.yml" }] } + ], + tags: [{ id: "6", name: "1.0", files: [{ name: "openapi.yml" }] }] + })) + expect(project.versions[0].name).toEqual("main") + expect(project.versions[1].name).toEqual("master") + expect(project.versions[2].name).toEqual("develop") + expect(project.versions[3].name).toEqual("development") + expect(project.versions[4].name).toEqual("1.0") + expect(project.versions[5].name).toEqual("anne") +}) + +test("It sorts file specifications alphabetically", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "1", + name: "main", + files: [ + { name: "z-openapi.yml" }, + { name: "a-openapi.yml" }, + { name: "1-openapi.yml" } + ] + }] + })) + expect(project.versions[0].specifications[0].name).toEqual("1-openapi.yml") + expect(project.versions[0].specifications[1].name).toEqual("a-openapi.yml") + expect(project.versions[0].specifications[2].name).toEqual("z-openapi.yml") +}) + +test("It maintains remote version specification ordering from config", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + name: Hello World + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Zac + url: https://example.com/zac.yml + - id: another-spec + name: Bob + url: https://example.com/bob.yml + ` + } + })) + expect(project.versions[0].specifications[0].name).toEqual("Zac") + expect(project.versions[0].specifications[1].name).toEqual("Bob") +}) + +test("It identifies the default branch in returned versions", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + defaultBranchRef: { id: "1", name: "development" }, + branches: [ + { id: "1", name: "anne", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "main", files: [{ name: "openapi.yml" }] }, + { id: "3", name: "development", files: [{ name: "openapi.yml" }] } + ] + })) + const defaultVersionNames = project.versions.filter(v => v.isDefault).map(v => v.name) + expect(defaultVersionNames).toEqual(["development"]) +}) + +test("It adds remote versions from the project configuration", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + remoteVersions: + - name: Anne + specifications: + - name: Huey + url: https://example.com/huey.yml + - name: Dewey + url: https://example.com/dewey.yml + - name: Bobby + specifications: + - name: Louie + url: https://example.com/louie.yml + ` + } + })) + expect(project.versions).toEqual([{ + id: "anne", + name: "Anne", + isDefault: false, + specifications: [{ + id: "huey", + name: "Huey", + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}`, + isDefault: false + }, { + id: "dewey", + name: "Dewey", + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}`, + isDefault: false + }] + }, { + id: "bobby", + name: "Bobby", + isDefault: false, + specifications: [{ + id: "louie", + name: "Louie", + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}`, + isDefault: false + }] + }]) +}) + +test("It modifies ID of remote version if the ID already exists", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + defaultBranchRef: { id: "12345678", name: "bar" }, + branches: [{ + id: "12345678", + name: "bar", + files: [{ name: "openapi.yml" }] + }], + tags: [], + configYaml: { + text: ` + remoteVersions: + - name: Bar + specifications: + - name: Baz + url: https://example.com/baz.yml + - name: Bar + specifications: + - name: Hello + url: https://example.com/hello.yml + ` + } + })) + expect(project.versions[0].id).toEqual("bar") + expect(project.versions[1].id).toEqual("bar1") + expect(project.versions[2].id).toEqual("bar2") +}) + +test("It lets users specify the ID of a remote version", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + remoteVersions: + - id: some-version + name: Bar + specifications: + - name: Baz + url: https://example.com/baz.yml + ` + } + })) + expect(project.versions[0].id).toEqual("some-version") +}) + +test("It lets users specify the ID of a remote specification", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Baz + url: https://example.com/baz.yml + ` + } + })) + expect(project.versions[0].specifications[0].id).toEqual("some-spec") +}) + +test("It sets isDefault on the correct specification based on defaultSpecificationName in config", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "defaultSpecificationName: bar-service.yml" }, + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }] + })) + const specs = project.versions[0].specifications + expect(specs.find(s => s.name === "bar-service.yml")!.isDefault).toBe(true) + expect(specs.find(s => s.name === "foo-service.yml")!.isDefault).toBe(false) + expect(specs.find(s => s.name === "baz-service.yml")!.isDefault).toBe(false) +}) + +test("It sets a remote specification as the default if specified", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + defaultSpecificationName: Baz + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Baz + url: https://example.com/baz.yml + - id: another-spec + name: Qux + url: https://example.com/qux.yml + ` + } + })) + const remoteSpecs = project.versions[0].specifications + expect(remoteSpecs.find(s => s.id === "some-spec")!.isDefault).toBe(true) + expect(remoteSpecs.find(s => s.id === "another-spec")!.isDefault).toBe(false) +}) + +test("It sets isDefault to false for all specifications if defaultSpecificationName is not set", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }] + })) + const specs = project.versions[0].specifications + expect(specs.every(s => s.isDefault === false)).toBe(true) +}) + +test("It silently ignores defaultSpecificationName if no matching spec is found", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "defaultSpecificationName: non-existent.yml" }, + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" } + ] + }] + })) + const specs = project.versions[0].specifications + expect(specs.every(s => s.isDefault === false)).toBe(true) +}) + +test("It generates URLs using the provided URL builders", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "branch-id-123", + name: "main", + files: [{ name: "openapi.yml" }] + }] + })) + expect(project.url).toEqual("https://example.com/acme/foo-openapi") + expect(project.ownerUrl).toEqual("https://example.com/acme") + expect(project.versions[0].url).toEqual("https://example.com/acme/foo-openapi/tree/main") + expect(project.versions[0].specifications[0].editURL).toEqual("https://example.com/acme/foo-openapi/edit/main/openapi.yml") + expect(project.versions[0].specifications[0].url).toEqual("/api/blob/acme/foo-openapi/openapi.yml?ref=branch-id-123") +}) + +test("mapRepositories filters out projects with no versions", () => { + const mapper = createMapper() + const projects = mapper.mapRepositories([ + createRepository({ + name: "with-specs-openapi", + branches: [{ id: "1", name: "main", files: [{ name: "openapi.yml" }] }] + }), + createRepository({ + name: "without-specs-openapi", + branches: [{ id: "2", name: "main", files: [{ name: "README.md" }] }] + }) + ]) + expect(projects.length).toEqual(1) + expect(projects[0].name).toEqual("with-specs") +}) + +test("mapRepositories sorts projects alphabetically by name", () => { + const mapper = createMapper() + const projects = mapper.mapRepositories([ + createRepository({ + name: "zebra-openapi", + branches: [{ id: "1", name: "main", files: [{ name: "openapi.yml" }] }] + }), + createRepository({ + name: "alpha-openapi", + branches: [{ id: "2", name: "main", files: [{ name: "openapi.yml" }] }] + }), + createRepository({ + name: "middle-openapi", + branches: [{ id: "3", name: "main", files: [{ name: "openapi.yml" }] }] + }) + ]) + expect(projects.map(p => p.name)).toEqual(["alpha", "middle", "zebra"]) +}) diff --git a/__test__/projects/testUtils.ts b/__test__/projects/testUtils.ts new file mode 100644 index 00000000..014e2640 --- /dev/null +++ b/__test__/projects/testUtils.ts @@ -0,0 +1,25 @@ +import RemoteConfig from "@/features/projects/domain/RemoteConfig" + +/** + * Simple encryption service for testing. Does nothing. + */ +export const noopEncryptionService = { + encrypt: function (data: string): string { + return data + }, + decrypt: function (encryptedDataBase64: string): string { + return encryptedDataBase64 + } +} + +/** + * Simple encoder for testing + */ +export const base64RemoteConfigEncoder = { + encode: function (remoteConfig: RemoteConfig): string { + return Buffer.from(JSON.stringify(remoteConfig)).toString("base64") + }, + decode: function (encodedString: string): RemoteConfig { + return JSON.parse(Buffer.from(encodedString, "base64").toString()) + } +} From 13df2aaaf2706d5a4420f733af4ac9b05165e4f6 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Sat, 6 Dec 2025 19:06:08 +0100 Subject: [PATCH 3/8] Add Azure DevOps support as alternative project source provider - Add Microsoft Entra ID authentication via next-auth - Implement AzureDevOpsClient for REST API interactions - Add AzureDevOpsProjectDataSource and AzureDevOpsRepositoryDataSource - Create unified IBlobProvider interface for file content fetching - Support binary image files in blob API - Configure via PROJECT_SOURCE_PROVIDER env var (github or azure-devops) --- .env.example | 13 + .../[owner]/[repository]/[...path]/route.ts | 32 ++- src/app/api/hooks/github/route.ts | 6 + src/app/auth/signin/page.tsx | 22 +- src/common/azure-devops/AzureDevOpsClient.ts | 135 +++++++++ src/common/azure-devops/AzureDevOpsError.ts | 16 ++ src/common/azure-devops/IAzureDevOpsClient.ts | 27 ++ .../OAuthTokenRefreshingAzureDevOpsClient.ts | 76 +++++ src/common/azure-devops/index.ts | 5 + src/common/blob/AzureDevOpsBlobProvider.ts | 28 ++ src/common/blob/GitHubBlobProvider.ts | 24 ++ src/common/blob/IBlobProvider.ts | 3 + src/common/blob/index.ts | 3 + src/composition.ts | 202 ++++++++++--- .../data/AzureDevOpsOAuthTokenRefresher.ts | 78 +++++ src/features/auth/data/index.ts | 1 + .../auth/domain/log-in/LogInHandler.ts | 6 +- .../data/AzureDevOpsProjectDataSource.ts | 270 ++++++++++++++++++ .../data/AzureDevOpsRepositoryDataSource.ts | 124 ++++++++ src/features/projects/data/index.ts | 2 + .../IAzureDevOpsRepositoryDataSource.ts | 29 ++ 21 files changed, 1032 insertions(+), 70 deletions(-) create mode 100644 src/common/azure-devops/AzureDevOpsClient.ts create mode 100644 src/common/azure-devops/AzureDevOpsError.ts create mode 100644 src/common/azure-devops/IAzureDevOpsClient.ts create mode 100644 src/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.ts create mode 100644 src/common/azure-devops/index.ts create mode 100644 src/common/blob/AzureDevOpsBlobProvider.ts create mode 100644 src/common/blob/GitHubBlobProvider.ts create mode 100644 src/common/blob/IBlobProvider.ts create mode 100644 src/common/blob/index.ts create mode 100644 src/features/auth/data/AzureDevOpsOAuthTokenRefresher.ts create mode 100644 src/features/projects/data/AzureDevOpsProjectDataSource.ts create mode 100644 src/features/projects/data/AzureDevOpsRepositoryDataSource.ts create mode 100644 src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts diff --git a/.env.example b/.env.example index 3e2770b5..18f652f0 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ HIDDEN_REPOSITORIES= NEW_PROJECT_TEMPLATE_REPOSITORY=shapehq/starter-openapi PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES = 10 PROXY_API_TIMEOUT_IN_SECONDS = 30 + +# Project Source Provider: "github" or "azure-devops" (default: github) +PROJECT_SOURCE_PROVIDER=github + +# GitHub Configuration (required if PROJECT_SOURCE_PROVIDER=github) GITHUB_WEBHOOK_SECRET=preshared secret also put in app configuration in GitHub GITHUB_WEBHOK_REPOSITORY_ALLOWLIST= GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST= @@ -22,6 +27,14 @@ GITHUB_CLIENT_ID=GitHub App client ID GITHUB_CLIENT_SECRET=GitHub App client secret GITHUB_APP_ID=123456 GITHUB_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key - see README.md for more info + +# Azure DevOps Configuration (required if PROJECT_SOURCE_PROVIDER=azure-devops) +# Uses Microsoft Entra ID (Azure AD) for authentication +AZURE_ENTRA_ID_CLIENT_ID=Microsoft Entra ID App Registration client ID +AZURE_ENTRA_ID_CLIENT_SECRET=Microsoft Entra ID App Registration client secret +AZURE_ENTRA_ID_TENANT_ID=Microsoft Entra ID tenant/directory ID +AZURE_DEVOPS_ORGANIZATION=your-azure-devops-organization-name + ENCRYPTION_PUBLIC_KEY_BASE_64=base 64 encoded version of the public key ENCRYPTION_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key NEXT_PUBLIC_ENABLE_DIFF_SIDEBAR=true diff --git a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts index 1dc58c90..6b7c9ada 100644 --- a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts +++ b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts @@ -1,30 +1,32 @@ import { NextRequest, NextResponse } from "next/server" -import { session, userGitHubClient } from "@/composition" +import { session, blobProvider } from "@/composition" import { makeUnauthenticatedAPIErrorResponse } from "@/common" -export async function GET(req: NextRequest, { params }: { params: Promise<{ owner: string; repository: string; path: string[] }> }) { +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ owner: string; repository: string; path: string[] }> } +) { const isAuthenticated = await session.getIsAuthenticated() if (!isAuthenticated) { return makeUnauthenticatedAPIErrorResponse() } const { path: paramsPath, owner, repository } = await params const path = paramsPath.join("/") - const item = await userGitHubClient.getRepositoryContent({ - repositoryOwner: owner, - repositoryName: repository, - path: path, - ref: req.nextUrl.searchParams.get("ref") ?? undefined - }) - const url = new URL(item.downloadURL) - const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/; - const file = await fetch(url).then(r => r.blob()) + const ref = req.nextUrl.searchParams.get("ref") ?? "main" + + const content = await blobProvider.getFileContent(owner, repository, path, ref) + if (content === null) { + return NextResponse.json({ error: `File not found: ${path}` }, { status: 404 }) + } + const headers = new Headers() - if (new RegExp(imageRegex).exec(path)) { + const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/ + if (imageRegex.test(path)) { const cacheExpirationInSeconds = 60 * 60 * 24 * 30 // 30 days - headers.set("Content-Type", "image/*"); + headers.set("Content-Type", "image/*") headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`) } else { - headers.set("Content-Type", "text/plain"); + headers.set("Content-Type", "text/plain") } - return new NextResponse(file, { status: 200, headers }) + return new NextResponse(content, { status: 200, headers }) } diff --git a/src/app/api/hooks/github/route.ts b/src/app/api/hooks/github/route.ts index b1b42444..0724c892 100644 --- a/src/app/api/hooks/github/route.ts +++ b/src/app/api/hooks/github/route.ts @@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from "next/server" import { gitHubHookHandler } from "@/composition" export const POST = async (req: NextRequest): Promise => { + if (!gitHubHookHandler) { + return NextResponse.json( + { error: "GitHub webhooks not available" }, + { status: 404 } + ) + } await gitHubHookHandler.handle(req) return NextResponse.json({ status: "OK" }) } \ No newline at end of file diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 43a9281c..8d7a8667 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -3,12 +3,13 @@ import { Box, Button, Stack, Typography } from "@mui/material" import { signIn } from "@/composition" import { env } from "@/common" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faGithub } from "@fortawesome/free-brands-svg-icons" +import { faGithub, faMicrosoft } from "@fortawesome/free-brands-svg-icons" import SignInTexts from "@/features/auth/view/SignInTexts" import MessageLinkFooter from "@/common/ui/MessageLinkFooter" const SITE_NAME = env.getOrThrow("FRAMNA_DOCS_TITLE") const HELP_URL = env.get("FRAMNA_DOCS_HELP_URL") +const PROJECT_SOURCE_PROVIDER = env.get("PROJECT_SOURCE_PROVIDER") || "github" // Force page to be rendered dynamically to ensure we read the correct values for the environment variables. export const dynamic = "force-dynamic" @@ -74,7 +75,7 @@ const SignInColumn = () => { }}> {title} - + {HELP_URL && ( @@ -89,20 +90,25 @@ const SignInColumn = () => { ) } -const SignInWithGitHub = () => { +const SignInButton = () => { + const isAzureDevOps = PROJECT_SOURCE_PROVIDER === "azure-devops" + const providerId = isAzureDevOps ? "microsoft-entra-id" : "github" + const providerName = isAzureDevOps ? "Microsoft" : "GitHub" + const providerIcon = isAzureDevOps ? faMicrosoft : faGithub + return (
{ "use server" - await signIn("github", { redirectTo: "/" }) + await signIn(providerId, { redirectTo: "/" }) }} >
diff --git a/src/common/azure-devops/AzureDevOpsClient.ts b/src/common/azure-devops/AzureDevOpsClient.ts new file mode 100644 index 00000000..af49ab9b --- /dev/null +++ b/src/common/azure-devops/AzureDevOpsClient.ts @@ -0,0 +1,135 @@ +import IAzureDevOpsClient, { + AzureDevOpsRepository, + AzureDevOpsRef, + AzureDevOpsItem +} from "./IAzureDevOpsClient" +import { AzureDevOpsError } from "./AzureDevOpsError" + +interface IOAuthTokenDataSource { + getOAuthToken(): Promise<{ accessToken: string }> +} + +type AzureDevOpsApiResponse = { + value: T[] + count: number +} + +export default class AzureDevOpsClient implements IAzureDevOpsClient { + private readonly organization: string + private readonly oauthTokenDataSource: IOAuthTokenDataSource + private readonly apiVersion = "7.1" + + constructor(config: { + organization: string + oauthTokenDataSource: IOAuthTokenDataSource + }) { + this.organization = config.organization + this.oauthTokenDataSource = config.oauthTokenDataSource + } + + private async fetch(endpoint: string): Promise { + const oauthToken = await this.oauthTokenDataSource.getOAuthToken() + const url = `https://dev.azure.com/${this.organization}${endpoint}` + const separator = endpoint.includes("?") ? "&" : "?" + const fullUrl = `${url}${separator}api-version=${this.apiVersion}` + + const response = await fetch(fullUrl, { + headers: { + Authorization: `Bearer ${oauthToken.accessToken}`, + Accept: "application/json" + }, + // Don't follow redirects - Azure DevOps returns 302 for auth failures + redirect: "manual" + }) + + // Check for redirect (302) - Azure DevOps redirects to login on auth failure + if (response.status === 302) { + const location = response.headers.get("location") || "" + // Check if redirecting to a sign-in page (auth error) + const isAuthRedirect = location.includes("/_signin") || location.includes("/login") + throw new AzureDevOpsError( + `Azure DevOps API redirect (302) to: ${location}`, + 302, + isAuthRedirect // only trigger token refresh for auth redirects + ) + } + + // Check for authentication errors (401/403) + if (response.status === 401 || response.status === 403) { + const text = await response.text() + throw new AzureDevOpsError( + `Azure DevOps API authentication error: ${response.status} ${response.statusText} - ${text.substring(0, 200)}`, + response.status, + true // isAuthError - trigger token refresh + ) + } + + if (!response.ok) { + const text = await response.text() + throw new AzureDevOpsError( + `Azure DevOps API error: ${response.status} ${response.statusText} - ${text.substring(0, 200)}`, + response.status, + false + ) + } + + return await response.json() as T + } + + async getRepositories(): Promise { + const response = await this.fetch>( + "/_apis/git/repositories" + ) + return response.value + } + + async getRefs(repositoryId: string): Promise { + const response = await this.fetch>( + `/_apis/git/repositories/${repositoryId}/refs` + ) + return response.value + } + + async getItems(repositoryId: string, scopePath: string, version: string): Promise { + try { + const response = await this.fetch>( + `/_apis/git/repositories/${repositoryId}/items?scopePath=${encodeURIComponent(scopePath)}&recursionLevel=OneLevel&versionDescriptor.version=${encodeURIComponent(version)}` + ) + return response.value + } catch { + return [] + } + } + + private isImageFile(path: string): boolean { + const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg", ".ico"] + const lowerPath = path.toLowerCase() + return imageExtensions.some(ext => lowerPath.endsWith(ext)) + } + + async getFileContent(repositoryId: string, path: string, version: string): Promise { + try { + const oauthToken = await this.oauthTokenDataSource.getOAuthToken() + const url = `https://dev.azure.com/${this.organization}/_apis/git/repositories/${repositoryId}/items?path=${encodeURIComponent(path)}&versionDescriptor.version=${encodeURIComponent(version)}&api-version=${this.apiVersion}` + + const isImage = this.isImageFile(path) + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${oauthToken.accessToken}`, + Accept: isImage ? "application/octet-stream" : "text/plain" + } + }) + + if (!response.ok) { + return null + } + + if (isImage) { + return await response.arrayBuffer() + } + return await response.text() + } catch { + return null + } + } +} diff --git a/src/common/azure-devops/AzureDevOpsError.ts b/src/common/azure-devops/AzureDevOpsError.ts new file mode 100644 index 00000000..ab4d8d52 --- /dev/null +++ b/src/common/azure-devops/AzureDevOpsError.ts @@ -0,0 +1,16 @@ +/** + * Error thrown by Azure DevOps API client when requests fail. + * Includes HTTP status code for proper error handling. + */ +export class AzureDevOpsError extends Error { + readonly status: number + readonly isAuthError: boolean + + constructor(message: string, status: number, isAuthError: boolean) { + super(message) + this.name = "AzureDevOpsError" + this.status = status + this.isAuthError = isAuthError + } +} + diff --git a/src/common/azure-devops/IAzureDevOpsClient.ts b/src/common/azure-devops/IAzureDevOpsClient.ts new file mode 100644 index 00000000..9cb7137d --- /dev/null +++ b/src/common/azure-devops/IAzureDevOpsClient.ts @@ -0,0 +1,27 @@ +export type AzureDevOpsRepository = { + readonly id: string + readonly name: string + readonly defaultBranch?: string + readonly webUrl: string + readonly project: { + readonly id: string + readonly name: string + } +} + +export type AzureDevOpsRef = { + readonly name: string // e.g., "refs/heads/main" + readonly objectId: string +} + +export type AzureDevOpsItem = { + readonly path: string + readonly gitObjectType: "blob" | "tree" +} + +export default interface IAzureDevOpsClient { + getRepositories(): Promise + getRefs(repositoryId: string): Promise + getItems(repositoryId: string, scopePath: string, version: string): Promise + getFileContent(repositoryId: string, path: string, version: string): Promise +} diff --git a/src/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.ts b/src/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.ts new file mode 100644 index 00000000..79f4bec0 --- /dev/null +++ b/src/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.ts @@ -0,0 +1,76 @@ +import IAzureDevOpsClient, { + AzureDevOpsRepository, + AzureDevOpsRef, + AzureDevOpsItem +} from "./IAzureDevOpsClient" +import { AzureDevOpsError } from "./AzureDevOpsError" + +type OAuthToken = { accessToken: string, refreshToken?: string } + +interface IOAuthTokenDataSource { + getOAuthToken(): Promise +} + +interface IOAuthTokenRefresher { + refreshOAuthToken(oauthToken: OAuthToken): Promise +} + +/** + * Wraps an Azure DevOps client and automatically refreshes OAuth tokens + * when authentication errors occur. + */ +export default class OAuthTokenRefreshingAzureDevOpsClient implements IAzureDevOpsClient { + private readonly oauthTokenDataSource: IOAuthTokenDataSource + private readonly oauthTokenRefresher: IOAuthTokenRefresher + private readonly client: IAzureDevOpsClient + + constructor(config: { + oauthTokenDataSource: IOAuthTokenDataSource + oauthTokenRefresher: IOAuthTokenRefresher + client: IAzureDevOpsClient + }) { + this.oauthTokenDataSource = config.oauthTokenDataSource + this.oauthTokenRefresher = config.oauthTokenRefresher + this.client = config.client + } + + async getRepositories(): Promise { + return await this.send(async () => { + return await this.client.getRepositories() + }) + } + + async getRefs(repositoryId: string): Promise { + return await this.send(async () => { + return await this.client.getRefs(repositoryId) + }) + } + + async getItems(repositoryId: string, scopePath: string, version: string): Promise { + return await this.send(async () => { + return await this.client.getItems(repositoryId, scopePath, version) + }) + } + + async getFileContent(repositoryId: string, path: string, version: string): Promise { + return await this.send(async () => { + return await this.client.getFileContent(repositoryId, path, version) + }) + } + + private async send(fn: () => Promise): Promise { + const oauthToken = await this.oauthTokenDataSource.getOAuthToken() + try { + return await fn() + } catch (e) { + // Check if this is an authentication error that we can recover from + if (e instanceof AzureDevOpsError && e.isAuthError) { + // Refresh access token and try the request one last time + await this.oauthTokenRefresher.refreshOAuthToken(oauthToken) + return await fn() + } + // Not an error we can handle so forward it + throw e + } + } +} diff --git a/src/common/azure-devops/index.ts b/src/common/azure-devops/index.ts new file mode 100644 index 00000000..4e7bffd3 --- /dev/null +++ b/src/common/azure-devops/index.ts @@ -0,0 +1,5 @@ +export { default as AzureDevOpsClient } from "./AzureDevOpsClient" +export { default as OAuthTokenRefreshingAzureDevOpsClient } from "./OAuthTokenRefreshingAzureDevOpsClient" +export { AzureDevOpsError } from "./AzureDevOpsError" +export type { default as IAzureDevOpsClient } from "./IAzureDevOpsClient" +export * from "./IAzureDevOpsClient" diff --git a/src/common/blob/AzureDevOpsBlobProvider.ts b/src/common/blob/AzureDevOpsBlobProvider.ts new file mode 100644 index 00000000..c72336f7 --- /dev/null +++ b/src/common/blob/AzureDevOpsBlobProvider.ts @@ -0,0 +1,28 @@ +import { IAzureDevOpsClient } from "@/common/azure-devops" +import IBlobProvider from "./IBlobProvider" + +export default class AzureDevOpsBlobProvider implements IBlobProvider { + private readonly client: IAzureDevOpsClient + + constructor(params: { client: IAzureDevOpsClient }) { + this.client = params.client + } + + // owner is ignored - Azure DevOps organization is configured globally + async getFileContent(_owner: string, repository: string, path: string, ref: string): Promise { + const repositories = await this.client.getRepositories() + const repo = repositories.find(r => r.name === repository) + if (!repo) { + return null + } + const content = await this.client.getFileContent(repo.id, path, ref) + if (content === null) { + return null + } + // Convert ArrayBuffer to Blob for binary content + if (content instanceof ArrayBuffer) { + return new Blob([content]) + } + return content + } +} diff --git a/src/common/blob/GitHubBlobProvider.ts b/src/common/blob/GitHubBlobProvider.ts new file mode 100644 index 00000000..e00e9eb6 --- /dev/null +++ b/src/common/blob/GitHubBlobProvider.ts @@ -0,0 +1,24 @@ +import { IGitHubClient } from "@/common/github" +import IBlobProvider from "./IBlobProvider" + +export default class GitHubBlobProvider implements IBlobProvider { + private readonly gitHubClient: IGitHubClient + + constructor(params: { gitHubClient: IGitHubClient }) { + this.gitHubClient = params.gitHubClient + } + + async getFileContent(owner: string, repository: string, path: string, ref: string): Promise { + try { + const item = await this.gitHubClient.getRepositoryContent({ + repositoryOwner: owner, + repositoryName: repository, + path, + ref + }) + return await fetch(new URL(item.downloadURL)).then(r => r.blob()) + } catch { + return null + } + } +} diff --git a/src/common/blob/IBlobProvider.ts b/src/common/blob/IBlobProvider.ts new file mode 100644 index 00000000..2aeb35d0 --- /dev/null +++ b/src/common/blob/IBlobProvider.ts @@ -0,0 +1,3 @@ +export default interface IBlobProvider { + getFileContent(owner: string, repository: string, path: string, ref: string): Promise +} diff --git a/src/common/blob/index.ts b/src/common/blob/index.ts new file mode 100644 index 00000000..b3805189 --- /dev/null +++ b/src/common/blob/index.ts @@ -0,0 +1,3 @@ +export { default as GitHubBlobProvider } from "./GitHubBlobProvider" +export { default as AzureDevOpsBlobProvider } from "./AzureDevOpsBlobProvider" +export type { default as IBlobProvider } from "./IBlobProvider" diff --git a/src/composition.ts b/src/composition.ts index 8187b966..b173c3a1 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -1,6 +1,8 @@ import { Pool } from "pg" import NextAuth from "next-auth" +import type { Provider } from "next-auth/providers" import GithubProvider from "next-auth/providers/github" +import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id" import PostgresAdapter from "@auth/pg-adapter" import RedisKeyedMutexFactory from "@/common/mutex/RedisKeyedMutexFactory" import RedisKeyValueStore from "@/common/key-value-store/RedisKeyValueStore" @@ -15,18 +17,24 @@ import { SessionMutexFactory, listFromCommaSeparatedString } from "@/common" +import { AzureDevOpsClient, OAuthTokenRefreshingAzureDevOpsClient } from "@/common/azure-devops" +import { GitHubBlobProvider, AzureDevOpsBlobProvider, IBlobProvider } from "@/common/blob" import { GitHubLoginDataSource, GitHubProjectDataSource, - GitHubRepositoryDataSource + GitHubRepositoryDataSource, + AzureDevOpsRepositoryDataSource, + AzureDevOpsProjectDataSource } from "@/features/projects/data" import { CachingProjectDataSource, FilteringGitHubRepositoryDataSource, - ProjectRepository + ProjectRepository, + IProjectDataSource } from "@/features/projects/domain" import { - GitHubOAuthTokenRefresher + GitHubOAuthTokenRefresher, + AzureDevOpsOAuthTokenRefresher } from "@/features/auth/data" import { AuthjsAccountsOAuthTokenRepository, @@ -39,7 +47,8 @@ import { OAuthTokenRepository, OAuthTokenSessionValidator, PersistingOAuthTokenRefresher, - UserDataCleanUpLogOutHandler + UserDataCleanUpLogOutHandler, + IOAuthTokenRefresher } from "@/features/auth/domain" import { GitHubHookHandler @@ -56,14 +65,31 @@ import RemoteConfigEncoder from "./features/projects/domain/RemoteConfigEncoder" import { OasDiffCalculator } from "./features/diff/data/OasDiffCalculator" import { IOasDiffCalculator } from "./features/diff/data/IOasDiffCalculator" -const gitHubAppCredentials = { +// Provider configuration +const projectSourceProvider = env.get("PROJECT_SOURCE_PROVIDER") || "github" +const isGitHubProvider = projectSourceProvider === "github" +const isAzureDevOpsProvider = projectSourceProvider === "azure-devops" + +// Microsoft's registered Application ID for Azure DevOps +const AZURE_DEVOPS_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798" + +// GitHub credentials (only loaded if using GitHub provider) +const gitHubAppCredentials = isGitHubProvider ? { appId: env.getOrThrow("GITHUB_APP_ID"), clientId: env.getOrThrow("GITHUB_CLIENT_ID"), clientSecret: env.getOrThrow("GITHUB_CLIENT_SECRET"), privateKey: Buffer .from(env.getOrThrow("GITHUB_PRIVATE_KEY_BASE_64"), "base64") .toString("utf-8") -} +} : null + +// Azure DevOps credentials (only loaded if using Azure DevOps provider) +const azureDevOpsCredentials = isAzureDevOpsProvider ? { + clientId: env.getOrThrow("AZURE_ENTRA_ID_CLIENT_ID"), + clientSecret: env.getOrThrow("AZURE_ENTRA_ID_CLIENT_SECRET"), + tenantId: env.getOrThrow("AZURE_ENTRA_ID_TENANT_ID"), + organization: env.getOrThrow("AZURE_DEVOPS_ORGANIZATION") +} : null const pool = new Pool({ host: env.getOrThrow("POSTGRESQL_HOST"), @@ -77,13 +103,48 @@ const pool = new Pool({ const db = new PostgreSQLDB({ pool }) +// The NextAuth provider ID differs from our config value +const authProviderName = isAzureDevOpsProvider ? "microsoft-entra-id" : "github" + const oauthTokenRepository = new FallbackOAuthTokenRepository({ - primaryRepository: new OAuthTokenRepository({ db, provider: "github" }), - secondaryRepository: new AuthjsAccountsOAuthTokenRepository({ db, provider: "github" }) + primaryRepository: new OAuthTokenRepository({ db, provider: authProviderName }), + secondaryRepository: new AuthjsAccountsOAuthTokenRepository({ db, provider: authProviderName }) }) const logInHandler = new LogInHandler({ oauthTokenRepository }) +// Build auth providers based on configuration +function getAuthProviders(): Provider[] { + if (isGitHubProvider && gitHubAppCredentials) { + return [ + GithubProvider({ + clientId: gitHubAppCredentials.clientId, + clientSecret: gitHubAppCredentials.clientSecret, + authorization: { + params: { + scope: "repo" + } + } + }) + ] + } else if (isAzureDevOpsProvider && azureDevOpsCredentials) { + return [ + MicrosoftEntraID({ + clientId: azureDevOpsCredentials.clientId, + clientSecret: azureDevOpsCredentials.clientSecret, + issuer: `https://login.microsoftonline.com/${azureDevOpsCredentials.tenantId}/v2.0`, + authorization: { + params: { + // Request Azure DevOps API access + offline_access for refresh tokens + scope: `openid profile email offline_access ${AZURE_DEVOPS_RESOURCE_ID}/.default` + } + } + }) + ] + } + throw new Error(`Unsupported PROJECT_SOURCE_PROVIDER: ${projectSourceProvider}`) +} + export const { signIn, auth, handlers: authHandlers } = NextAuth({ adapter: PostgresAdapter(pool), secret: env.getOrThrow("NEXTAUTH_SECRET"), @@ -95,17 +156,7 @@ export const { signIn, auth, handlers: authHandlers } = NextAuth({ pages: { signIn: "/auth/signin" }, - providers: [ - GithubProvider({ - clientId: env.getOrThrow("GITHUB_CLIENT_ID"), - clientSecret: env.getOrThrow("GITHUB_CLIENT_SECRET"), - authorization: { - params: { - scope: "repo" - } - } - }) - ], + providers: getAuthProviders(), session: { strategy: "database" }, @@ -137,6 +188,20 @@ const oauthTokenDataSource = new OAuthTokenDataSource({ repository: oauthTokenRepository }) +// Build OAuth token refresher based on provider +function getOAuthTokenRefresher(): IOAuthTokenRefresher { + if (isGitHubProvider && gitHubAppCredentials) { + return new GitHubOAuthTokenRefresher(gitHubAppCredentials) + } else if (isAzureDevOpsProvider && azureDevOpsCredentials) { + return new AzureDevOpsOAuthTokenRefresher({ + clientId: azureDevOpsCredentials.clientId, + clientSecret: azureDevOpsCredentials.clientSecret, + tenantId: azureDevOpsCredentials.tenantId + }) + } + throw new Error(`Unsupported PROJECT_SOURCE_PROVIDER: ${projectSourceProvider}`) +} + const oauthTokenRefresher = new LockingOAuthTokenRefresher({ mutexFactory: new SessionMutexFactory({ baseKey: "mutexLockingOAuthTokenRefresher", @@ -146,25 +211,50 @@ const oauthTokenRefresher = new LockingOAuthTokenRefresher({ oauthTokenRefresher: new PersistingOAuthTokenRefresher({ userIdReader: session, oauthTokenRepository, - oauthTokenRefresher: new GitHubOAuthTokenRefresher(gitHubAppCredentials) + oauthTokenRefresher: getOAuthTokenRefresher() }) }) -const gitHubClient = new GitHubClient({ +// GitHub-specific clients (only used for GitHub provider) +const gitHubClient = isGitHubProvider && gitHubAppCredentials ? new GitHubClient({ ...gitHubAppCredentials, oauthTokenDataSource -}) +}) : null -const repoRestrictedGitHubClient = new RepoRestrictedGitHubClient({ +const repoRestrictedGitHubClient = gitHubClient ? new RepoRestrictedGitHubClient({ repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), gitHubClient -}) +}) : null -export const userGitHubClient = new OAuthTokenRefreshingGitHubClient({ +export const userGitHubClient = repoRestrictedGitHubClient ? new OAuthTokenRefreshingGitHubClient({ gitHubClient: repoRestrictedGitHubClient, oauthTokenDataSource, oauthTokenRefresher -}) +}) : null + +// Azure DevOps client (only used for Azure DevOps provider) +const baseAzureDevOpsClient = isAzureDevOpsProvider && azureDevOpsCredentials ? new AzureDevOpsClient({ + organization: azureDevOpsCredentials.organization, + oauthTokenDataSource +}) : null + +export const azureDevOpsClient = baseAzureDevOpsClient ? new OAuthTokenRefreshingAzureDevOpsClient({ + client: baseAzureDevOpsClient, + oauthTokenDataSource, + oauthTokenRefresher +}) : null + +// Blob provider for fetching file content +function getBlobProvider(): IBlobProvider { + if (userGitHubClient) { + return new GitHubBlobProvider({ gitHubClient: userGitHubClient }) + } else if (azureDevOpsClient) { + return new AzureDevOpsBlobProvider({ client: azureDevOpsClient }) + } + throw new Error(`No blob provider available for PROJECT_SOURCE_PROVIDER: ${projectSourceProvider}`) +} + +export const blobProvider = getBlobProvider() export const blockingSessionValidator = new OAuthTokenSessionValidator({ oauthTokenDataSource @@ -187,23 +277,43 @@ 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, +// Build project data source based on provider +function getProjectDataSource(): IProjectDataSource { + if (isGitHubProvider && userGitHubClient) { + return 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 + }) + } else if (isAzureDevOpsProvider && azureDevOpsClient && azureDevOpsCredentials) { + return new AzureDevOpsProjectDataSource({ + repositoryDataSource: new AzureDevOpsRepositoryDataSource({ + client: azureDevOpsClient, + organization: azureDevOpsCredentials.organization, repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME") - }) - }), - repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), - encryptionService: encryptionService, - remoteConfigEncoder: remoteConfigEncoder - }), + }), + repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), + encryptionService: encryptionService, + remoteConfigEncoder: remoteConfigEncoder + }) + } + throw new Error(`Unsupported PROJECT_SOURCE_PROVIDER: ${projectSourceProvider}`) +} + +export const projectDataSource = new CachingProjectDataSource({ + dataSource: getProjectDataSource(), repository: projectRepository }) @@ -213,7 +323,8 @@ export const logOutHandler = new ErrorIgnoringLogOutHandler( ]) ) -export const gitHubHookHandler = new GitHubHookHandler({ +// GitHub webhook handler (only available for GitHub provider) +export const gitHubHookHandler = isGitHubProvider && gitHubClient ? new GitHubHookHandler({ secret: env.getOrThrow("GITHUB_WEBHOOK_SECRET"), pullRequestEventHandler: new FilteringPullRequestEventHandler({ filter: new RepositoryNameEventFilter({ @@ -232,6 +343,9 @@ export const gitHubHookHandler = new GitHubHookHandler({ }) }) }) -}) +) : null -export const diffCalculator: IOasDiffCalculator = new OasDiffCalculator(gitHubClient) +// Diff calculator only available for GitHub provider (requires GitHub-specific APIs) +export const diffCalculator: IOasDiffCalculator | null = gitHubClient + ? new OasDiffCalculator(gitHubClient) + : null diff --git a/src/features/auth/data/AzureDevOpsOAuthTokenRefresher.ts b/src/features/auth/data/AzureDevOpsOAuthTokenRefresher.ts new file mode 100644 index 00000000..20d2e719 --- /dev/null +++ b/src/features/auth/data/AzureDevOpsOAuthTokenRefresher.ts @@ -0,0 +1,78 @@ +import { UnauthorizedError } from "@/common" +import { OAuthToken, IOAuthTokenRefresher } from "../domain" + +// Microsoft's registered Application ID for Azure DevOps +const AZURE_DEVOPS_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798" + +export interface AzureDevOpsOAuthTokenRefresherConfig { + readonly clientId: string + readonly clientSecret: string + readonly tenantId: string +} + +type TokenResponse = { + access_token?: string + refresh_token?: string + error?: string + error_description?: string +} + +/** + * Refreshes OAuth tokens using Microsoft Entra ID (Azure AD) token endpoint. + */ +export default class AzureDevOpsOAuthTokenRefresher implements IOAuthTokenRefresher { + private readonly config: AzureDevOpsOAuthTokenRefresherConfig + + constructor(config: AzureDevOpsOAuthTokenRefresherConfig) { + this.config = config + } + + async refreshOAuthToken(oldOAuthToken: OAuthToken): Promise { + if (!oldOAuthToken.refreshToken) { + throw new Error("Cannot refresh OAuth token as it has no refresh token") + } + + // Use Microsoft Entra ID token endpoint + const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token` + const body = new URLSearchParams({ + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + grant_type: "refresh_token", + refresh_token: oldOAuthToken.refreshToken, + // Request Azure DevOps API access + scope: `${AZURE_DEVOPS_RESOURCE_ID}/.default offline_access` + }) + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: body.toString() + }) + + if (response.status !== 200) { + throw new UnauthorizedError( + `Failed refreshing access token with HTTP status ${response.status}: ${response.statusText}` + ) + } + + const data = await response.json() as TokenResponse + + if (data.error) { + throw new UnauthorizedError(data.error_description || data.error) + } + + const accessToken = data.access_token + const refreshToken = data.refresh_token + + if (!accessToken || accessToken.length <= 0) { + throw new UnauthorizedError("Refreshing access token did not produce a valid access token") + } + + return { + accessToken, + refreshToken: refreshToken || oldOAuthToken.refreshToken + } + } +} diff --git a/src/features/auth/data/index.ts b/src/features/auth/data/index.ts index a1b0236b..fe22534f 100644 --- a/src/features/auth/data/index.ts +++ b/src/features/auth/data/index.ts @@ -1 +1,2 @@ export { default as GitHubOAuthTokenRefresher } from "./GitHubOAuthTokenRefresher" +export { default as AzureDevOpsOAuthTokenRefresher } from "./AzureDevOpsOAuthTokenRefresher" diff --git a/src/features/auth/domain/log-in/LogInHandler.ts b/src/features/auth/domain/log-in/LogInHandler.ts index c8865775..7b09e5ed 100644 --- a/src/features/auth/domain/log-in/LogInHandler.ts +++ b/src/features/auth/domain/log-in/LogInHandler.ts @@ -13,15 +13,15 @@ export default class LogInHandler implements ILogInHandler { if (!account) { return false } - if (account.provider === "github") { - return await this.handleLogInForGitHubUser({ user, account }) + if (account.provider === "github" || account.provider === "microsoft-entra-id") { + return await this.handleLogInForOAuthUser({ user, account }) } else { console.error("Unhandled account provider: " + account.provider) return false } } - private async handleLogInForGitHubUser({ user, account }: { user: IUser, account: IAccount }) { + private async handleLogInForOAuthUser({ user, account }: { user: IUser, account: IAccount }) { if (!user.id) { return false } diff --git a/src/features/projects/data/AzureDevOpsProjectDataSource.ts b/src/features/projects/data/AzureDevOpsProjectDataSource.ts new file mode 100644 index 00000000..aa858a02 --- /dev/null +++ b/src/features/projects/data/AzureDevOpsProjectDataSource.ts @@ -0,0 +1,270 @@ +import { IEncryptionService } from "@/features/encrypt/EncryptionService" +import { + Project, + Version, + IProjectConfig, + IProjectDataSource, + ProjectConfigParser, + ProjectConfigRemoteVersion, + ProjectConfigRemoteSpecification +} from "../domain" +import IAzureDevOpsRepositoryDataSource, { + AzureDevOpsRepositoryWithRefs, + AzureDevOpsRepositoryRef +} from "../domain/IAzureDevOpsRepositoryDataSource" +import RemoteConfig from "../domain/RemoteConfig" +import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder" + +export default class AzureDevOpsProjectDataSource implements IProjectDataSource { + private readonly repositoryDataSource: IAzureDevOpsRepositoryDataSource + private readonly repositoryNameSuffix: string + private readonly encryptionService: IEncryptionService + private readonly remoteConfigEncoder: IRemoteConfigEncoder + + constructor(config: { + repositoryDataSource: IAzureDevOpsRepositoryDataSource + 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: AzureDevOpsRepositoryWithRefs): Project { + const config = this.getConfig(repository) + let imageURL: string | undefined + if (config && config.image) { + imageURL = this.getAzureDevOpsBlobURL({ + organization: repository.owner, + repositoryName: repository.name, + path: config.image, + ref: repository.defaultBranchRef.name + }) + } + 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://dev.azure.com/${repository.owner}`, + url: repository.webUrl + } + } + + private getConfig(repository: AzureDevOpsRepositoryWithRefs): 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: AzureDevOpsRepositoryWithRefs): Version[] { + const branchVersions = repository.branches.map(branch => { + const isDefaultRef = branch.name == repository.defaultBranchRef.name + return this.mapVersionFromRef({ + organization: repository.owner, + repositoryName: repository.name, + webUrl: repository.webUrl, + ref: branch, + isDefaultRef + }) + }) + const tagVersions = repository.tags.map(tag => { + return this.mapVersionFromRef({ + organization: repository.owner, + repositoryName: repository.name, + webUrl: repository.webUrl, + ref: tag + }) + }) + return branchVersions.concat(tagVersions) + } + + private mapVersionFromRef({ + organization, + repositoryName, + webUrl, + ref, + isDefaultRef + }: { + organization: string + repositoryName: string + webUrl: string + ref: AzureDevOpsRepositoryRef + isDefaultRef?: boolean + }): Version { + const specifications = ref.files.filter(file => { + return this.isOpenAPISpecification(file.name) + }).map(file => { + return { + id: file.name, + name: file.name, + url: this.getAzureDevOpsBlobURL({ + organization, + repositoryName, + path: file.name, + ref: ref.name + }), + // Azure DevOps edit URL format + editURL: `${webUrl}?path=/${file.name}&version=GB${ref.name}&_a=contents`, + isDefault: false // initial value + } + }).sort((a, b) => a.name.localeCompare(b.name)) + + return { + id: ref.name, + name: ref.name, + specifications: specifications, + url: `${webUrl}?version=GB${ref.name}`, + isDefault: isDefaultRef || false + } + } + + private isOpenAPISpecification(filename: string) { + return !filename.startsWith(".") && ( + filename.endsWith(".yml") || filename.endsWith(".yaml") + ) + } + + private getAzureDevOpsBlobURL({ + organization, + repositoryName, + path, + ref + }: { + organization: string + repositoryName: string + path: string + ref: string + }): string { + // Use internal API route for fetching blob content + return `/api/blob/${organization}/${repositoryName}/${path}?ref=${ref}` + } + + 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. + const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length + const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") + const specifications = remoteVersion.specifications.map(e => { + const remoteConfig: RemoteConfig = { + url: e.url, + auth: this.tryDecryptAuth(e) + } + + const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig) + + return { + id: this.makeURLSafeID((e.id || e.name).toLowerCase()), + name: e.name, + url: `/api/remotes/${encodedRemoteConfig}`, + isDefault: false // initial value + } + }) + versions.push({ + id: versionId, + name: remoteVersion.name, + specifications, + isDefault: false + }) + versionIds.push(baseVersionId) + } + return versions + } + + private sortVersions(versions: Version[], defaultBranchName: string): Version[] { + const candidateDefaultBranches = [ + defaultBranchName, "main", "master", "develop", "development", "trunk" + ] + // Reverse them so the top-priority branches end up at the top of the list. + .reverse() + const copiedVersions = [...versions].sort((a, b) => { + return a.name.localeCompare(b.name) + }) + // Move the top-priority branches to the top of the list. + for (const candidateDefaultBranch of candidateDefaultBranches) { + const defaultBranchIndex = copiedVersions.findIndex(version => { + return version.name === candidateDefaultBranch + }) + if (defaultBranchIndex !== -1) { + const branchVersion = copiedVersions[defaultBranchIndex] + copiedVersions.splice(defaultBranchIndex, 1) + copiedVersions.splice(0, 0, branchVersion) + } + } + return copiedVersions + } + + private makeURLSafeID(str: string): string { + return str + .replace(/ /g, "-") + .replace(/[^A-Za-z0-9-]/g, "") + } + + private tryDecryptAuth(projectConfigRemoteSpec: ProjectConfigRemoteSpecification): { type: string, username: string, password: string } | undefined { + if (!projectConfigRemoteSpec.auth) { + return undefined + } + + try { + return { + type: projectConfigRemoteSpec.auth.type, + username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername), + password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword) + } + } catch (error) { + console.info(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.name} (${projectConfigRemoteSpec.url}). Perhaps a different public key was used?:`, error) + return undefined + } + } + + private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version { + return { + ...version, + specifications: version.specifications.map(spec => ({ + ...spec, + isDefault: spec.name === defaultSpecificationName + })) + } + } +} diff --git a/src/features/projects/data/AzureDevOpsRepositoryDataSource.ts b/src/features/projects/data/AzureDevOpsRepositoryDataSource.ts new file mode 100644 index 00000000..c28cf80e --- /dev/null +++ b/src/features/projects/data/AzureDevOpsRepositoryDataSource.ts @@ -0,0 +1,124 @@ +import IAzureDevOpsRepositoryDataSource, { + AzureDevOpsRepositoryWithRefs, + AzureDevOpsRepositoryRef +} from "../domain/IAzureDevOpsRepositoryDataSource" +import { IAzureDevOpsClient, AzureDevOpsRepository, AzureDevOpsRef } from "@/common/azure-devops" + +export default class AzureDevOpsRepositoryDataSource implements IAzureDevOpsRepositoryDataSource { + private readonly client: IAzureDevOpsClient + private readonly organization: string + private readonly repositoryNameSuffix: string + private readonly projectConfigurationFilename: string + + constructor(config: { + client: IAzureDevOpsClient + organization: string + repositoryNameSuffix: string + projectConfigurationFilename: string + }) { + this.client = config.client + this.organization = config.organization + this.repositoryNameSuffix = config.repositoryNameSuffix + this.projectConfigurationFilename = config.projectConfigurationFilename.replace(/\.ya?ml$/, "") + } + + async getRepositories(): Promise { + const allRepos = await this.client.getRepositories() + + // Filter repos by naming convention + const matchingRepos = allRepos.filter(repo => + repo.name.endsWith(this.repositoryNameSuffix) + ) + + // Fetch details for each matching repo + const results = await Promise.all( + matchingRepos.map(repo => this.enrichRepository(repo)) + ) + + return results.filter((repo): repo is AzureDevOpsRepositoryWithRefs => repo !== null) + } + + private async enrichRepository(repo: AzureDevOpsRepository): Promise { + try { + const refs = await this.client.getRefs(repo.id) + + // Separate branches and tags + const branchRefs = refs.filter(ref => ref.name.startsWith("refs/heads/")) + const tagRefs = refs.filter(ref => ref.name.startsWith("refs/tags/")) + + // Get default branch name + const defaultBranchName = repo.defaultBranch?.replace("refs/heads/", "") || "main" + const defaultBranchRef = branchRefs.find(ref => + ref.name === `refs/heads/${defaultBranchName}` + ) + + // Fetch files for each branch/tag + const branches = await Promise.all( + branchRefs.map(ref => this.enrichRef(repo.id, ref)) + ) + const tags = await Promise.all( + tagRefs.map(ref => this.enrichRef(repo.id, ref)) + ) + + // Fetch config files from default branch + const configYml = await this.fetchConfigFile(repo.id, defaultBranchName, ".yml") + const configYaml = await this.fetchConfigFile(repo.id, defaultBranchName, ".yaml") + + return { + name: repo.name, + owner: this.organization, + webUrl: repo.webUrl, + defaultBranchRef: { + id: defaultBranchRef?.objectId || "", + name: defaultBranchName + }, + configYml, + configYaml, + branches: branches.filter((b): b is AzureDevOpsRepositoryRef => b !== null), + tags: tags.filter((t): t is AzureDevOpsRepositoryRef => t !== null) + } + } catch { + return null + } + } + + private async enrichRef( + repositoryId: string, + ref: AzureDevOpsRef + ): Promise { + try { + // Extract branch/tag name from full ref path + const name = ref.name + .replace("refs/heads/", "") + .replace("refs/tags/", "") + + // Get root files for this ref + const items = await this.client.getItems(repositoryId, "/", name) + const files = items + .filter(item => item.gitObjectType === "blob") + .map(item => ({ name: item.path.replace("/", "") })) + + return { + id: ref.objectId, + name, + files + } + } catch { + return null + } + } + + private async fetchConfigFile( + repositoryId: string, + branchName: string, + extension: string + ): Promise<{ text: string } | undefined> { + const path = `${this.projectConfigurationFilename}${extension}` + const content = await this.client.getFileContent(repositoryId, path, branchName) + + if (content) { + return { text: content } + } + return undefined + } +} diff --git a/src/features/projects/data/index.ts b/src/features/projects/data/index.ts index 748a41b9..f548c169 100644 --- a/src/features/projects/data/index.ts +++ b/src/features/projects/data/index.ts @@ -3,3 +3,5 @@ export * from "./GitHubProjectDataSource" export { default as useProjectSelection } from "./useProjectSelection" export { default as GitHubLoginDataSource } from "./GitHubLoginDataSource" export { default as GitHubRepositoryDataSource } from "./GitHubRepositoryDataSource" +export { default as AzureDevOpsRepositoryDataSource } from "./AzureDevOpsRepositoryDataSource" +export { default as AzureDevOpsProjectDataSource } from "./AzureDevOpsProjectDataSource" diff --git a/src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts b/src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts new file mode 100644 index 00000000..122ff1cc --- /dev/null +++ b/src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts @@ -0,0 +1,29 @@ +export type AzureDevOpsRepositoryWithRefs = { + readonly name: string + readonly owner: string // organization + readonly defaultBranchRef: { + readonly id: string + readonly name: string + } + readonly webUrl: string + readonly configYml?: { + readonly text: string + } + readonly configYaml?: { + readonly text: string + } + readonly branches: AzureDevOpsRepositoryRef[] + readonly tags: AzureDevOpsRepositoryRef[] +} + +export type AzureDevOpsRepositoryRef = { + readonly id: string + readonly name: string + readonly files: { + readonly name: string + }[] +} + +export default interface IAzureDevOpsRepositoryDataSource { + getRepositories(): Promise +} From edc18ca4ac4824b252f5df8768460f0397b086b9 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Sat, 6 Dec 2025 21:16:27 +0100 Subject: [PATCH 4/8] Add tests for Azure DevOps components and ProjectMapper Add comprehensive tests for ProjectMapper, Azure DevOps client, blob providers, and repository data sources. Fix AzureDevOpsError prototype chain to ensure instanceof checks work correctly. --- .../azure-devops/AzureDevOpsClient.test.ts | 418 ++++++ .../azure-devops/AzureDevOpsError.test.ts | 72 + ...thTokenRefreshingAzureDevOpsClient.test.ts | 246 ++++ .../blob/AzureDevOpsBlobProvider.test.ts | 147 +++ .../common/blob/GitHubBlobProvider.test.ts | 118 ++ .../AzureDevOpsProjectDataSource.test.ts | 74 ++ .../AzureDevOpsRepositoryDataSource.test.ts | 424 ++++++ .../projects/GitHubProjectDataSource.test.ts | 1158 +---------------- src/common/azure-devops/AzureDevOpsError.ts | 2 + 9 files changed, 1511 insertions(+), 1148 deletions(-) create mode 100644 __test__/common/azure-devops/AzureDevOpsClient.test.ts create mode 100644 __test__/common/azure-devops/AzureDevOpsError.test.ts create mode 100644 __test__/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.test.ts create mode 100644 __test__/common/blob/AzureDevOpsBlobProvider.test.ts create mode 100644 __test__/common/blob/GitHubBlobProvider.test.ts create mode 100644 __test__/projects/AzureDevOpsProjectDataSource.test.ts create mode 100644 __test__/projects/AzureDevOpsRepositoryDataSource.test.ts diff --git a/__test__/common/azure-devops/AzureDevOpsClient.test.ts b/__test__/common/azure-devops/AzureDevOpsClient.test.ts new file mode 100644 index 00000000..bbf2cdc2 --- /dev/null +++ b/__test__/common/azure-devops/AzureDevOpsClient.test.ts @@ -0,0 +1,418 @@ +import { jest } from "@jest/globals" +import AzureDevOpsClient from "@/common/azure-devops/AzureDevOpsClient" +import { AzureDevOpsError } from "@/common/azure-devops/AzureDevOpsError" + +const originalFetch = global.fetch + +function createMockTokenDataSource(accessToken = "test-token") { + return { + async getOAuthToken() { + return { accessToken } + } + } +} + +function mockFetchResponse(data: unknown, status = 200, headers: Record = {}) { + return jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + headers: { + get: (name: string) => headers[name.toLowerCase()] || null + }, + json: () => Promise.resolve(data), + text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) + }) +} + +afterEach(() => { + global.fetch = originalFetch +}) + +describe("getRepositories", () => { + test("It calls the correct API endpoint", async () => { + let fetchedUrl: string | undefined + global.fetch = jest.fn().mockImplementation((url: string | URL | Request) => { + fetchedUrl = url.toString() + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ value: [], count: 0 }) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getRepositories() + + expect(fetchedUrl).toContain("https://dev.azure.com/my-org/_apis/git/repositories") + expect(fetchedUrl).toContain("api-version=7.1") + }) + + test("It includes the Bearer token in the Authorization header", async () => { + let capturedHeaders: HeadersInit | undefined + global.fetch = jest.fn().mockImplementation((_url: string, options?: RequestInit) => { + capturedHeaders = options?.headers + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ value: [], count: 0 }) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource("my-access-token") + }) + await sut.getRepositories() + + expect(capturedHeaders).toBeDefined() + expect((capturedHeaders as Record).Authorization).toEqual("Bearer my-access-token") + }) + + test("It returns the repositories from the response", async () => { + global.fetch = mockFetchResponse({ + value: [ + { id: "repo-1", name: "foo-openapi", webUrl: "https://test", project: { id: "p1", name: "proj" } } + ], + count: 1 + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const repos = await sut.getRepositories() + + expect(repos).toHaveLength(1) + expect(repos[0].name).toEqual("foo-openapi") + }) +}) + +describe("getRefs", () => { + test("It calls the correct API endpoint with repository ID", async () => { + let fetchedUrl: string | undefined + global.fetch = jest.fn().mockImplementation((url: string | URL | Request) => { + fetchedUrl = url.toString() + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ value: [], count: 0 }) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getRefs("repo-123") + + expect(fetchedUrl).toContain("/_apis/git/repositories/repo-123/refs") + }) + + test("It returns the refs from the response", async () => { + global.fetch = mockFetchResponse({ + value: [ + { name: "refs/heads/main", objectId: "abc123" }, + { name: "refs/tags/v1.0", objectId: "def456" } + ], + count: 2 + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const refs = await sut.getRefs("repo-123") + + expect(refs).toHaveLength(2) + expect(refs[0].name).toEqual("refs/heads/main") + }) +}) + +describe("getItems", () => { + test("It calls the correct API endpoint with scope path and version", async () => { + let fetchedUrl: string | undefined + global.fetch = jest.fn().mockImplementation((url: string | URL | Request) => { + fetchedUrl = url.toString() + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ value: [], count: 0 }) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getItems("repo-123", "/docs", "main") + + expect(fetchedUrl).toContain("/_apis/git/repositories/repo-123/items") + expect(fetchedUrl).toContain("scopePath=%2Fdocs") + expect(fetchedUrl).toContain("versionDescriptor.version=main") + expect(fetchedUrl).toContain("recursionLevel=OneLevel") + }) + + test("It returns empty array when request fails", async () => { + global.fetch = mockFetchResponse("Not found", 404) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const items = await sut.getItems("repo-123", "/", "main") + + expect(items).toEqual([]) + }) +}) + +describe("getFileContent", () => { + test("It returns text content for non-image files", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve("openapi: 3.0.0") + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const content = await sut.getFileContent("repo-123", "openapi.yml", "main") + + expect(content).toEqual("openapi: 3.0.0") + }) + + test("It returns ArrayBuffer for image files", async () => { + const mockArrayBuffer = new ArrayBuffer(16) + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(mockArrayBuffer) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const content = await sut.getFileContent("repo-123", "icon.png", "main") + + expect(content).toBe(mockArrayBuffer) + }) + + test("It uses octet-stream Accept header for image files", async () => { + let capturedHeaders: HeadersInit | undefined + global.fetch = jest.fn().mockImplementation((_url: string, options?: RequestInit) => { + capturedHeaders = options?.headers + return Promise.resolve({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getFileContent("repo-123", "logo.jpg", "main") + + expect((capturedHeaders as Record).Accept).toEqual("application/octet-stream") + }) + + test("It uses text/plain Accept header for non-image files", async () => { + let capturedHeaders: HeadersInit | undefined + global.fetch = jest.fn().mockImplementation((_url: string, options?: RequestInit) => { + capturedHeaders = options?.headers + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve("content") + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getFileContent("repo-123", "openapi.yml", "main") + + expect((capturedHeaders as Record).Accept).toEqual("text/plain") + }) + + test("It returns null when response is not ok", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404 + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const content = await sut.getFileContent("repo-123", "missing.yml", "main") + + expect(content).toBeNull() + }) + + test("It returns null when fetch throws an error", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network error")) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const content = await sut.getFileContent("repo-123", "openapi.yml", "main") + + expect(content).toBeNull() + }) +}) + +describe("Error handling", () => { + test("It throws AzureDevOpsError with isAuthError=true for 401 responses", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: () => Promise.resolve("Authentication failed") + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + + try { + await sut.getRepositories() + fail("Expected error to be thrown") + } catch (e) { + expect(e).toBeInstanceOf(AzureDevOpsError) + expect((e as AzureDevOpsError).isAuthError).toBe(true) + expect((e as AzureDevOpsError).status).toBe(401) + } + }) + + test("It throws AzureDevOpsError with isAuthError=true for 403 responses", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + text: () => Promise.resolve("Access denied") + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + + try { + await sut.getRepositories() + fail("Expected error to be thrown") + } catch (e) { + expect(e).toBeInstanceOf(AzureDevOpsError) + expect((e as AzureDevOpsError).isAuthError).toBe(true) + expect((e as AzureDevOpsError).status).toBe(403) + } + }) + + test("It throws AzureDevOpsError with isAuthError=true for 302 redirect to signin", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 302, + statusText: "Found", + headers: { + get: (name: string) => name.toLowerCase() === "location" ? "https://login.microsoftonline.com/_signin" : null + } + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + + try { + await sut.getRepositories() + fail("Expected error to be thrown") + } catch (e) { + expect(e).toBeInstanceOf(AzureDevOpsError) + expect((e as AzureDevOpsError).isAuthError).toBe(true) + expect((e as AzureDevOpsError).status).toBe(302) + } + }) + + test("It throws AzureDevOpsError with isAuthError=false for 302 redirect to non-auth URL", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 302, + statusText: "Found", + headers: { + get: (name: string) => name.toLowerCase() === "location" ? "https://dev.azure.com/other-page" : null + } + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + + try { + await sut.getRepositories() + fail("Expected error to be thrown") + } catch (e) { + expect(e).toBeInstanceOf(AzureDevOpsError) + expect((e as AzureDevOpsError).isAuthError).toBe(false) + expect((e as AzureDevOpsError).status).toBe(302) + } + }) + + test("It throws AzureDevOpsError with isAuthError=false for other error responses", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: () => Promise.resolve("Server error") + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + + try { + await sut.getRepositories() + fail("Expected error to be thrown") + } catch (e) { + expect(e).toBeInstanceOf(AzureDevOpsError) + expect((e as AzureDevOpsError).isAuthError).toBe(false) + expect((e as AzureDevOpsError).status).toBe(500) + } + }) +}) + +describe("Image file detection", () => { + const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg", ".ico"] + + test.each(imageExtensions)("It treats %s files as images", async (ext) => { + let capturedHeaders: HeadersInit | undefined + global.fetch = jest.fn().mockImplementation((_url: string, options?: RequestInit) => { + capturedHeaders = options?.headers + return Promise.resolve({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getFileContent("repo-123", `image${ext}`, "main") + + expect((capturedHeaders as Record).Accept).toEqual("application/octet-stream") + }) +}) diff --git a/__test__/common/azure-devops/AzureDevOpsError.test.ts b/__test__/common/azure-devops/AzureDevOpsError.test.ts new file mode 100644 index 00000000..ea58011e --- /dev/null +++ b/__test__/common/azure-devops/AzureDevOpsError.test.ts @@ -0,0 +1,72 @@ +import { AzureDevOpsError } from "@/common/azure-devops/AzureDevOpsError" + +test("It sets the error name to AzureDevOpsError", () => { + const error = new AzureDevOpsError("Test error", 400, false) + expect(error.name).toEqual("AzureDevOpsError") +}) + +test("It stores the error message", () => { + const error = new AzureDevOpsError("Something went wrong", 500, false) + expect(error.message).toEqual("Something went wrong") +}) + +test("It stores the HTTP status code", () => { + const error = new AzureDevOpsError("Unauthorized", 401, true) + expect(error.status).toEqual(401) +}) + +test("It stores the isAuthError flag when true", () => { + const error = new AzureDevOpsError("Auth failed", 401, true) + expect(error.isAuthError).toBe(true) +}) + +test("It stores the isAuthError flag when false", () => { + const error = new AzureDevOpsError("Not found", 404, false) + expect(error.isAuthError).toBe(false) +}) + +test("It is an instance of Error", () => { + const error = new AzureDevOpsError("Test error", 400, false) + expect(error).toBeInstanceOf(Error) +}) + +test("It is an instance of AzureDevOpsError", () => { + const error = new AzureDevOpsError("Test error", 400, false) + expect(error).toBeInstanceOf(AzureDevOpsError) +}) + +test("It can be caught as an Error", () => { + let caught: Error | undefined + try { + throw new AzureDevOpsError("Test", 500, false) + } catch (e) { + caught = e as Error + } + expect(caught).toBeDefined() + expect(caught?.message).toEqual("Test") +}) + +test("It works correctly with instanceof after being thrown", () => { + let isAzureDevOpsError = false + try { + throw new AzureDevOpsError("Test", 401, true) + } catch (e) { + isAzureDevOpsError = e instanceof AzureDevOpsError + } + expect(isAzureDevOpsError).toBe(true) +}) + +test("It preserves status and isAuthError after being thrown", () => { + let status: number | undefined + let isAuthError: boolean | undefined + try { + throw new AzureDevOpsError("Unauthorized", 401, true) + } catch (e) { + if (e instanceof AzureDevOpsError) { + status = e.status + isAuthError = e.isAuthError + } + } + expect(status).toEqual(401) + expect(isAuthError).toBe(true) +}) diff --git a/__test__/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.test.ts b/__test__/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.test.ts new file mode 100644 index 00000000..4cda361e --- /dev/null +++ b/__test__/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.test.ts @@ -0,0 +1,246 @@ +import OAuthTokenRefreshingAzureDevOpsClient from "@/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient" +import { AzureDevOpsError } from "@/common/azure-devops/AzureDevOpsError" +import IAzureDevOpsClient from "@/common/azure-devops/IAzureDevOpsClient" + +function createMockClient(overrides: Partial = {}): IAzureDevOpsClient { + return { + async getRepositories() { + return [] + }, + async getRefs() { + return [] + }, + async getItems() { + return [] + }, + async getFileContent() { + return null + }, + ...overrides + } +} + +test("It forwards a request to getRepositories", async () => { + let didForwardRequest = false + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + didForwardRequest = true + return [{ id: "1", name: "test", webUrl: "https://test", project: { id: "1", name: "proj" } }] + } + }) + }) + await sut.getRepositories() + expect(didForwardRequest).toBeTruthy() +}) + +test("It forwards a request to getRefs", async () => { + let forwardedRepoId: string | undefined + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRefs(repositoryId) { + forwardedRepoId = repositoryId + return [{ name: "refs/heads/main", objectId: "abc123" }] + } + }) + }) + await sut.getRefs("repo-123") + expect(forwardedRepoId).toEqual("repo-123") +}) + +test("It forwards a request to getItems", async () => { + let forwardedParams: { repoId?: string, path?: string, version?: string } = {} + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getItems(repositoryId, scopePath, version) { + forwardedParams = { repoId: repositoryId, path: scopePath, version } + return [] + } + }) + }) + await sut.getItems("repo-123", "/", "main") + expect(forwardedParams).toEqual({ repoId: "repo-123", path: "/", version: "main" }) +}) + +test("It forwards a request to getFileContent", async () => { + let forwardedParams: { repoId?: string, path?: string, version?: string } = {} + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getFileContent(repositoryId, path, version) { + forwardedParams = { repoId: repositoryId, path, version } + return "file content" + } + }) + }) + await sut.getFileContent("repo-123", "openapi.yml", "main") + expect(forwardedParams).toEqual({ repoId: "repo-123", path: "openapi.yml", version: "main" }) +}) + +test("It retries with a refreshed OAuth token when receiving an auth error", async () => { + let didRefreshOAuthToken = false + let didRespondWithAuthError = false + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + didRefreshOAuthToken = true + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + if (!didRespondWithAuthError) { + didRespondWithAuthError = true + throw new AzureDevOpsError("Unauthorized", 401, true) + } + return [] + } + }) + }) + await sut.getRepositories() + expect(didRefreshOAuthToken).toBeTruthy() +}) + +test("It only retries a request once when receiving auth errors", async () => { + let requestCount = 0 + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + requestCount += 1 + throw new AzureDevOpsError("Unauthorized", 401, true) + } + }) + }) + // When receiving the second auth error the call should fail. + await expect(sut.getRepositories()).rejects.toThrow("Unauthorized") + // We expect two requests: + // 1. The initial request that failed after which we refreshed the OAuth token. + // 2. The second request that failed after which we gave up. + expect(requestCount).toEqual(2) +}) + +test("It does not refresh an OAuth token when the initial request was successful", async () => { + let didRefreshOAuthToken = false + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + didRefreshOAuthToken = true + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + return [] + } + }) + }) + await sut.getRepositories() + expect(didRefreshOAuthToken).toBeFalsy() +}) + +test("It does not refresh OAuth token for non-auth errors", async () => { + let didRefreshOAuthToken = false + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + didRefreshOAuthToken = true + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + throw new AzureDevOpsError("Not Found", 404, false) + } + }) + }) + await expect(sut.getRepositories()).rejects.toThrow("Not Found") + expect(didRefreshOAuthToken).toBeFalsy() +}) + +test("It does not refresh OAuth token for non-AzureDevOpsError errors", async () => { + let didRefreshOAuthToken = false + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + didRefreshOAuthToken = true + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + throw new Error("Some random error") + } + }) + }) + await expect(sut.getRepositories()).rejects.toThrow("Some random error") + expect(didRefreshOAuthToken).toBeFalsy() +}) diff --git a/__test__/common/blob/AzureDevOpsBlobProvider.test.ts b/__test__/common/blob/AzureDevOpsBlobProvider.test.ts new file mode 100644 index 00000000..6de34118 --- /dev/null +++ b/__test__/common/blob/AzureDevOpsBlobProvider.test.ts @@ -0,0 +1,147 @@ +import AzureDevOpsBlobProvider from "@/common/blob/AzureDevOpsBlobProvider" +import IAzureDevOpsClient from "@/common/azure-devops/IAzureDevOpsClient" + +function createMockClient(overrides: Partial = {}): IAzureDevOpsClient { + return { + async getRepositories() { + return [] + }, + async getRefs() { + return [] + }, + async getItems() { + return [] + }, + async getFileContent() { + return null + }, + ...overrides + } +} + +test("It returns null when repository is not found", async () => { + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "other-repo", + webUrl: "https://dev.azure.com/org/proj/_git/other-repo", + project: { id: "proj-1", name: "proj" } + }] + } + }) + }) + const result = await sut.getFileContent("org", "foo-openapi", "openapi.yml", "main") + expect(result).toBeNull() +}) + +test("It returns null when file content is not found", async () => { + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + webUrl: "https://dev.azure.com/org/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getFileContent() { + return null + } + }) + }) + const result = await sut.getFileContent("org", "foo-openapi", "openapi.yml", "main") + expect(result).toBeNull() +}) + +test("It returns text content as string", async () => { + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + webUrl: "https://dev.azure.com/org/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getFileContent() { + return "openapi: 3.0.0\ninfo:\n title: Test API" + } + }) + }) + const result = await sut.getFileContent("org", "foo-openapi", "openapi.yml", "main") + expect(result).toEqual("openapi: 3.0.0\ninfo:\n title: Test API") +}) + +test("It converts ArrayBuffer content to Blob", async () => { + const testData = new TextEncoder().encode("binary content") + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + webUrl: "https://dev.azure.com/org/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getFileContent() { + return testData.buffer + } + }) + }) + const result = await sut.getFileContent("org", "foo-openapi", "icon.png", "main") + expect(result).toBeInstanceOf(Blob) +}) + +test("It passes correct parameters to getFileContent", async () => { + let passedParams: { repoId?: string, path?: string, ref?: string } = {} + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-123", + name: "foo-openapi", + webUrl: "https://dev.azure.com/org/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getFileContent(repositoryId, path, version) { + passedParams = { repoId: repositoryId, path, ref: version } + return "content" + } + }) + }) + await sut.getFileContent("org", "foo-openapi", "openapi.yml", "main") + expect(passedParams).toEqual({ + repoId: "repo-123", + path: "openapi.yml", + ref: "main" + }) +}) + +test("It ignores the owner parameter since organization is configured globally", async () => { + let didCallGetRepositories = false + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + didCallGetRepositories = true + return [{ + id: "repo-1", + name: "foo-openapi", + webUrl: "https://dev.azure.com/org/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getFileContent() { + return "content" + } + }) + }) + // Pass a different owner - it should still find the repo + await sut.getFileContent("different-org", "foo-openapi", "openapi.yml", "main") + expect(didCallGetRepositories).toBeTruthy() +}) diff --git a/__test__/common/blob/GitHubBlobProvider.test.ts b/__test__/common/blob/GitHubBlobProvider.test.ts new file mode 100644 index 00000000..fb364d93 --- /dev/null +++ b/__test__/common/blob/GitHubBlobProvider.test.ts @@ -0,0 +1,118 @@ +import { jest } from "@jest/globals" +import GitHubBlobProvider from "@/common/blob/GitHubBlobProvider" +import { IGitHubClient, GetRepositoryContentRequest } from "@/common/github" + +// Mock fetch globally for Blob conversion test +const originalFetch = global.fetch + +function createMockClient(overrides: Partial = {}): IGitHubClient { + return { + async graphql() { + return {} + }, + async getRepositoryContent() { + return { downloadURL: "https://example.com/file" } + }, + async getPullRequestFiles() { + return [] + }, + async getPullRequestComments() { + return [] + }, + async addCommentToPullRequest() {}, + async updatePullRequestComment() {}, + ...overrides + } +} + +beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + blob: () => Promise.resolve(new Blob(["test content"])) + }) +}) + +afterEach(() => { + global.fetch = originalFetch +}) + +test("It delegates to gitHubClient.getRepositoryContent", async () => { + let forwardedRequest: GetRepositoryContentRequest | undefined + const sut = new GitHubBlobProvider({ + gitHubClient: createMockClient({ + async getRepositoryContent(request) { + forwardedRequest = request + return { downloadURL: "https://example.com/file" } + } + }) + }) + await sut.getFileContent("owner", "repo", "path/to/file.yml", "abc123") + expect(forwardedRequest).toEqual({ + repositoryOwner: "owner", + repositoryName: "repo", + path: "path/to/file.yml", + ref: "abc123" + }) +}) + +test("It fetches blob from downloadURL", async () => { + let fetchedURL: string | undefined + global.fetch = jest.fn().mockImplementation((url: string | URL | Request) => { + fetchedURL = url.toString() + return Promise.resolve({ + blob: () => Promise.resolve(new Blob(["test content"])) + }) + }) + + const sut = new GitHubBlobProvider({ + gitHubClient: createMockClient({ + async getRepositoryContent() { + return { downloadURL: "https://raw.githubusercontent.com/owner/repo/file.yml" } + } + }) + }) + await sut.getFileContent("owner", "repo", "file.yml", "main") + expect(fetchedURL).toEqual("https://raw.githubusercontent.com/owner/repo/file.yml") +}) + +test("It returns Blob from the fetched content", async () => { + const testBlob = new Blob(["test content"], { type: "application/octet-stream" }) + global.fetch = jest.fn().mockResolvedValue({ + blob: () => Promise.resolve(testBlob) + }) + + const sut = new GitHubBlobProvider({ + gitHubClient: createMockClient({ + async getRepositoryContent() { + return { downloadURL: "https://example.com/file" } + } + }) + }) + const result = await sut.getFileContent("owner", "repo", "file.yml", "main") + expect(result).toBe(testBlob) +}) + +test("It returns null when getRepositoryContent throws an error", async () => { + const sut = new GitHubBlobProvider({ + gitHubClient: createMockClient({ + async getRepositoryContent() { + throw new Error("Not found") + } + }) + }) + const result = await sut.getFileContent("owner", "repo", "file.yml", "main") + expect(result).toBeNull() +}) + +test("It returns null when fetch throws an error", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network error")) + + const sut = new GitHubBlobProvider({ + gitHubClient: createMockClient({ + async getRepositoryContent() { + return { downloadURL: "https://example.com/file" } + } + }) + }) + const result = await sut.getFileContent("owner", "repo", "file.yml", "main") + expect(result).toBeNull() +}) diff --git a/__test__/projects/AzureDevOpsProjectDataSource.test.ts b/__test__/projects/AzureDevOpsProjectDataSource.test.ts new file mode 100644 index 00000000..bebaa02c --- /dev/null +++ b/__test__/projects/AzureDevOpsProjectDataSource.test.ts @@ -0,0 +1,74 @@ +import { AzureDevOpsProjectDataSource } from "@/features/projects/data" +import { noopEncryptionService, base64RemoteConfigEncoder } from "./testUtils" + +test("It loads repositories from data source", async () => { + let didLoadRepositories = false + const sut = new AzureDevOpsProjectDataSource({ + repositoryNameSuffix: "-openapi", + repositoryDataSource: { + async getRepositories() { + didLoadRepositories = true + return [] + } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) + await sut.getProjects() + expect(didLoadRepositories).toBeTruthy() +}) + +test("It generates correct Azure DevOps URLs", async () => { + const sut = new AzureDevOpsProjectDataSource({ + repositoryNameSuffix: "-openapi", + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "myorg", + name: "foo-openapi", + webUrl: "https://dev.azure.com/myorg/myproject/_git/foo-openapi", + defaultBranchRef: { name: "main" }, + branches: [{ + name: "main", + files: [{ name: "openapi.yml" }] + }], + tags: [] + }] + } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) + const projects = await sut.getProjects() + expect(projects[0].url).toEqual("https://dev.azure.com/myorg/myproject/_git/foo-openapi") + expect(projects[0].ownerUrl).toEqual("https://dev.azure.com/myorg") + expect(projects[0].versions[0].url).toEqual("https://dev.azure.com/myorg/myproject/_git/foo-openapi?version=GBmain") + expect(projects[0].versions[0].specifications[0].url).toEqual("/api/blob/myorg/foo-openapi/openapi.yml?ref=main") + expect(projects[0].versions[0].specifications[0].editURL).toEqual("https://dev.azure.com/myorg/myproject/_git/foo-openapi?path=/openapi.yml&version=GBmain&_a=contents") +}) + +test("It uses branch name as ref for Azure DevOps blob URLs", async () => { + const sut = new AzureDevOpsProjectDataSource({ + repositoryNameSuffix: "-openapi", + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "myorg", + name: "foo-openapi", + webUrl: "https://dev.azure.com/myorg/myproject/_git/foo-openapi", + defaultBranchRef: { name: "main" }, + branches: [{ + name: "feature/test", + files: [{ name: "openapi.yml" }] + }], + tags: [] + }] + } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) + const projects = await sut.getProjects() + // Azure DevOps uses branch name as ref, not commit SHA like GitHub + expect(projects[0].versions[0].specifications[0].url).toEqual("/api/blob/myorg/foo-openapi/openapi.yml?ref=feature/test") +}) diff --git a/__test__/projects/AzureDevOpsRepositoryDataSource.test.ts b/__test__/projects/AzureDevOpsRepositoryDataSource.test.ts new file mode 100644 index 00000000..0338d049 --- /dev/null +++ b/__test__/projects/AzureDevOpsRepositoryDataSource.test.ts @@ -0,0 +1,424 @@ +import { AzureDevOpsRepositoryDataSource } from "@/features/projects/data" +import IAzureDevOpsClient, { AzureDevOpsRepository, AzureDevOpsRef, AzureDevOpsItem } from "@/common/azure-devops/IAzureDevOpsClient" + +function createMockClient(overrides: Partial = {}): IAzureDevOpsClient { + return { + async getRepositories(): Promise { + return [] + }, + async getRefs(): Promise { + return [] + }, + async getItems(): Promise { + return [] + }, + async getFileContent(): Promise { + return null + }, + ...overrides + } +} + +test("It loads repositories from data source", async () => { + let didLoadRepositories = false + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + didLoadRepositories = true + return [] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + await sut.getRepositories() + expect(didLoadRepositories).toBeTruthy() +}) + +test("It filters repositories by suffix", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }, { + id: "repo-2", + name: "bar-service", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/bar-service", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs(repositoryId) { + if (repositoryId === "repo-1") { + return [{ + name: "refs/heads/main", + objectId: "abc123" + }] + } + return [] + }, + async getItems() { + return [{ + path: "/openapi.yml", + gitObjectType: "blob" + }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories.length).toEqual(1) + expect(repositories[0].name).toEqual("foo-openapi") +}) + +test("It maps repositories to the domain model", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ + name: "refs/heads/main", + objectId: "abc123" + }, { + name: "refs/tags/1.0", + objectId: "def456" + }] + }, + async getItems() { + return [{ + path: "/openapi.yml", + gitObjectType: "blob" + }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories).toEqual([{ + name: "foo-openapi", + owner: "myorg", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + defaultBranchRef: { + id: "abc123", + name: "main" + }, + configYml: undefined, + configYaml: undefined, + branches: [{ + id: "abc123", + name: "main", + files: [{ name: "openapi.yml" }] + }], + tags: [{ + id: "def456", + name: "1.0", + files: [{ name: "openapi.yml" }] + }] + }]) +}) + +test("It separates branches from tags by ref prefix", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [ + { name: "refs/heads/main", objectId: "abc123" }, + { name: "refs/heads/develop", objectId: "abc124" }, + { name: "refs/tags/v1.0", objectId: "def456" }, + { name: "refs/tags/v2.0", objectId: "def457" } + ] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].branches.length).toEqual(2) + expect(repositories[0].tags.length).toEqual(2) + expect(repositories[0].branches.map(b => b.name)).toEqual(["main", "develop"]) + expect(repositories[0].tags.map(t => t.name)).toEqual(["v1.0", "v2.0"]) +}) + +test("It strips refs/heads/ and refs/tags/ prefixes from ref names", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/feature/test", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [ + { name: "refs/heads/feature/test", objectId: "abc123" }, + { name: "refs/tags/release/v1.0", objectId: "def456" } + ] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].branches[0].name).toEqual("feature/test") + expect(repositories[0].tags[0].name).toEqual("release/v1.0") + expect(repositories[0].defaultBranchRef.name).toEqual("feature/test") +}) + +test("It fetches config file with .yml extension", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ name: "refs/heads/main", objectId: "abc123" }] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + }, + async getFileContent(_repoId, path) { + if (path === ".framna-docs.yml") { + return "name: Test Project" + } + return null + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].configYml).toEqual({ text: "name: Test Project" }) +}) + +test("It fetches config file with .yaml extension", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ name: "refs/heads/main", objectId: "abc123" }] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + }, + async getFileContent(_repoId, path) { + if (path === ".framna-docs.yaml") { + return "name: Test Project" + } + return null + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yaml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].configYaml).toEqual({ text: "name: Test Project" }) +}) + +test("It strips file extension from config filename before querying", async () => { + const queriedPaths: string[] = [] + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ name: "refs/heads/main", objectId: "abc123" }] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + }, + async getFileContent(_repoId, path) { + queriedPaths.push(path) + return null + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + await sut.getRepositories() + expect(queriedPaths).toContain(".framna-docs.yml") + expect(queriedPaths).toContain(".framna-docs.yaml") +}) + +test("It only includes blob items as files", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ name: "refs/heads/main", objectId: "abc123" }] + }, + async getItems() { + return [ + { path: "/openapi.yml", gitObjectType: "blob" }, + { path: "/docs", gitObjectType: "tree" }, + { path: "/schema.json", gitObjectType: "blob" } + ] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].branches[0].files.length).toEqual(2) + expect(repositories[0].branches[0].files.map(f => f.name)).toEqual(["openapi.yml", "schema.json"]) +}) + +test("It defaults to main branch when defaultBranch is not set", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ name: "refs/heads/main", objectId: "abc123" }] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].defaultBranchRef.name).toEqual("main") +}) + +test("It returns null for repositories that fail to enrich", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + throw new Error("API Error") + }, + async getItems() { + return [] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories.length).toEqual(0) +}) + +test("It returns null for refs that fail to enrich", async () => { + let callCount = 0 + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [ + { name: "refs/heads/main", objectId: "abc123" }, + { name: "refs/heads/broken", objectId: "broken123" } + ] + }, + async getItems(_repoId, _path, version) { + callCount++ + if (version === "broken") { + throw new Error("API Error") + } + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(callCount).toEqual(2) + expect(repositories[0].branches.length).toEqual(1) + expect(repositories[0].branches[0].name).toEqual("main") +}) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index 12438c76..266c4ef2 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -1,29 +1,5 @@ import { GitHubProjectDataSource } from "@/features/projects/data" -import RemoteConfig from "@/features/projects/domain/RemoteConfig" - -/** - * Simple encryption service for testing. Does nothing. - */ -const noopEncryptionService = { - encrypt: function (data: string): string { - return data - }, - decrypt: function (encryptedDataBase64: string): string { - return encryptedDataBase64 - } -} - -/** - * Simple encoder for testing - */ -const base64RemoteConfigEncoder = { - encode: function (remoteConfig: RemoteConfig): string { - return Buffer.from(JSON.stringify(remoteConfig)).toString("base64") - }, - decode: function (encodedString: string): RemoteConfig { - return JSON.parse(Buffer.from(encodedString, "base64").toString()) - } -} +import { noopEncryptionService, base64RemoteConfigEncoder } from "./testUtils" test("It loads repositories from data source", async () => { let didLoadRepositories = false @@ -42,401 +18,7 @@ test("It loads repositories from data source", async () => { 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 () => { +test("It generates correct GitHub URLs", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", repositoryDataSource: { @@ -445,736 +27,13 @@ test("It reads display name from configuration file with .yaml extension", async 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", + id: "abc123", 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" })}`, - isDefault: false - }, { - id: "dewey", - name: "Dewey", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}`, - isDefault: false - }] - }, { - id: "bobby", - name: "Bobby", - isDefault: false, - specifications: [{ - id: "louie", - name: "Louie", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}`, - isDefault: false - }] - }]) -}) - -test("It modifies ID of remote version if the ID already exists", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - name: Bar - specifications: - - name: Baz - url: https://example.com/baz.yml - - name: Bar - specifications: - - name: Hello - url: https://example.com/hello.yml - ` - }, - branches: [{ - id: "12345678", - name: "bar", - files: [{ - name: "openapi.yml" - }] - }], - 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" })}`, - isDefault: false - }] - }, { - id: "bar2", - name: "Bar", - isDefault: false, - specifications: [{ - id: "hello", - name: "Hello", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/hello.yml" })}`, - isDefault: false - }] - }]) -}) - -test("It lets users specify the ID of a remote version", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - id: some-version - name: Bar - specifications: - - name: Baz - url: https://example.com/baz.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "some-version", - name: "Bar", - isDefault: false, - specifications: [{ - id: "baz", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - isDefault: false - }] - }]) -}) - -test("It lets users specify the ID of a remote specification", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "bar", - name: "Bar", - isDefault: false, - specifications: [{ - id: "some-spec", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - isDefault: false - }] - }]) -}) - -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", + id: "abc123", name: "main", - files: [ - { name: "foo-service.yml" }, - { name: "bar-service.yml" }, - { name: "baz-service.yml" } - ] + files: [{ name: "openapi.yml" }] }], tags: [] }] @@ -1184,6 +43,9 @@ test("It silently ignores defaultSpecificationName if no matching spec is found" remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() - const specs = projects[0].versions[0].specifications - expect(specs.every(s => s.isDefault === false)).toBe(true) + expect(projects[0].url).toEqual("https://github.com/acme/foo-openapi") + expect(projects[0].ownerUrl).toEqual("https://github.com/acme") + expect(projects[0].versions[0].url).toEqual("https://github.com/acme/foo-openapi/tree/main") + expect(projects[0].versions[0].specifications[0].url).toEqual("/api/blob/acme/foo-openapi/openapi.yml?ref=abc123") + expect(projects[0].versions[0].specifications[0].editURL).toEqual("https://github.com/acme/foo-openapi/edit/main/openapi.yml") }) diff --git a/src/common/azure-devops/AzureDevOpsError.ts b/src/common/azure-devops/AzureDevOpsError.ts index ab4d8d52..ea64aca0 100644 --- a/src/common/azure-devops/AzureDevOpsError.ts +++ b/src/common/azure-devops/AzureDevOpsError.ts @@ -11,6 +11,8 @@ export class AzureDevOpsError extends Error { this.name = "AzureDevOpsError" this.status = status this.isAuthError = isAuthError + // Restore prototype chain - required for instanceof to work with Error subclasses in TypeScript + Object.setPrototypeOf(this, AzureDevOpsError.prototype) } } From cdef838819f3b26d6e2bcc1274aaf90033a36f7c Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Sat, 6 Dec 2025 21:19:51 +0100 Subject: [PATCH 5/8] Update README with Azure DevOps documentation Add provider configuration section explaining how to use either GitHub or Azure DevOps as the project source provider. --- README.md | 34 +++++++++++++++++-- .../[owner]/[repository]/[...path]/route.ts | 7 ++++ src/composition.ts | 2 +- .../data/AzureDevOpsRepositoryDataSource.ts | 2 +- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dda1ecea..fc1db0d6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

👋 Welcome to Framna Docs

-

Self-hosted web portal that centralizes OpenAPI documentation and facilitates spec-driven development, built with GitHub-based authorization.

+

Self-hosted web portal that centralizes OpenAPI documentation and facilitates spec-driven development, with support for GitHub and Azure DevOps.

@@ -54,16 +54,44 @@ brew install oasdiff ## 👨‍🔧 How does it work? -Framna Docs uses [OpenAPI specifications](https://swagger.io) from GitHub repositories. Users log in with their GitHub account to access documentation for projects they have access to. A repository only needs an OpenAPI spec to be recognized by Framna Docs, but customization is possible with a .framna-docs.yml file. Here's an example: +Framna Docs uses [OpenAPI specifications](https://swagger.io) from GitHub or Azure DevOps repositories. Users log in with their GitHub or Microsoft Entra ID account (depending on the configured provider) to access documentation for projects they have access to. A repository only needs an OpenAPI spec to be recognized by Framna Docs, but customization is possible with a .framna-docs.yml file. Here's an example: -Framna Docs supports spec-driven development by requiring OpenAPI specs in GitHub repos, ensuring version control and peer review. When a pull request is opened, Framna Docs comments with links to preview the documentation: +Framna Docs supports spec-driven development by requiring OpenAPI specs in version-controlled repositories, ensuring peer review. When using GitHub, Framna Docs comments on pull requests with links to preview the documentation: Learn more from the [Adding Documentation](https://github.com/shapehq/framna-docs/wiki/Adding-Documentation-to-Framna-Docs), [Browsing Documentation](https://github.com/shapehq/framna-docs/wiki/Browsing-Documentation), and [Updating Documentation](https://github.com/shapehq/framna-docs/wiki/Updating-Documentation) articles in the wiki. +### Provider Configuration + +Framna Docs supports two project source providers: **GitHub** (default) and **Azure DevOps**. Set the `PROJECT_SOURCE_PROVIDER` environment variable to choose your provider. + +#### GitHub (default) + +```bash +PROJECT_SOURCE_PROVIDER=github +GITHUB_CLIENT_ID=... +GITHUB_CLIENT_SECRET=... +GITHUB_APP_ID=... +GITHUB_PRIVATE_KEY_BASE_64=... +``` + +#### Azure DevOps + +Azure DevOps uses Microsoft Entra ID (formerly Azure AD) for authentication: + +```bash +PROJECT_SOURCE_PROVIDER=azure-devops +AZURE_ENTRA_ID_CLIENT_ID=... +AZURE_ENTRA_ID_CLIENT_SECRET=... +AZURE_ENTRA_ID_TENANT_ID=... +AZURE_DEVOPS_ORGANIZATION=your-organization +``` + +See `.env.example` for a full list of configuration options. + ## 👩‍💻 How can I contribute? Pull requests with bugfixes and new features are much appreciated. We are happy to review PRs and merge them once they are ready, as long as they contain changes that fit within the vision of Framna Docs. diff --git a/src/app/api/diff/[owner]/[repository]/[...path]/route.ts b/src/app/api/diff/[owner]/[repository]/[...path]/route.ts index 7486ce4d..26e932fa 100644 --- a/src/app/api/diff/[owner]/[repository]/[...path]/route.ts +++ b/src/app/api/diff/[owner]/[repository]/[...path]/route.ts @@ -14,6 +14,13 @@ export async function GET(req: NextRequest, { params }: { params: Promise Date: Mon, 8 Dec 2025 10:45:50 +0100 Subject: [PATCH 6/8] Refactor AzureDevOpsProjectDataSource to use ProjectMapper Remove duplicated mapping code by having AzureDevOpsProjectDataSource use the shared ProjectMapper class with Azure DevOps-specific URL builders. - Update AzureDevOpsRepositoryWithRefs to extend RepositoryWithRefs - Create azureDevOpsURLBuilders for Azure DevOps URL generation - Reduce AzureDevOpsProjectDataSource from 270 to 57 lines --- .../data/AzureDevOpsProjectDataSource.ts | 279 +++--------------- .../IAzureDevOpsRepositoryDataSource.ts | 32 +- 2 files changed, 47 insertions(+), 264 deletions(-) diff --git a/src/features/projects/data/AzureDevOpsProjectDataSource.ts b/src/features/projects/data/AzureDevOpsProjectDataSource.ts index aa858a02..6adeb086 100644 --- a/src/features/projects/data/AzureDevOpsProjectDataSource.ts +++ b/src/features/projects/data/AzureDevOpsProjectDataSource.ts @@ -1,25 +1,39 @@ import { IEncryptionService } from "@/features/encrypt/EncryptionService" import { Project, - Version, - IProjectConfig, - IProjectDataSource, - ProjectConfigParser, - ProjectConfigRemoteVersion, - ProjectConfigRemoteSpecification + IProjectDataSource } from "../domain" import IAzureDevOpsRepositoryDataSource, { - AzureDevOpsRepositoryWithRefs, - AzureDevOpsRepositoryRef + AzureDevOpsRepositoryWithRefs } from "../domain/IAzureDevOpsRepositoryDataSource" -import RemoteConfig from "../domain/RemoteConfig" +import ProjectMapper, { type URLBuilders, type RepositoryRef } from "../domain/ProjectMapper" import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder" +const azureDevOpsURLBuilders: URLBuilders = { + getImageRef(repository: AzureDevOpsRepositoryWithRefs): string { + return repository.defaultBranchRef.name + }, + getBlobRef(ref: RepositoryRef): string { + return ref.name + }, + getOwnerUrl(owner: string): string { + return `https://dev.azure.com/${owner}` + }, + getProjectUrl(repository: AzureDevOpsRepositoryWithRefs): string { + return repository.webUrl + }, + getVersionUrl(repository: AzureDevOpsRepositoryWithRefs, ref: RepositoryRef): string { + return `${repository.webUrl}?version=GB${ref.name}` + }, + getSpecEditUrl(repository: AzureDevOpsRepositoryWithRefs, ref: RepositoryRef, fileName: string): string { + return `${repository.webUrl}?path=/${fileName}&version=GB${ref.name}&_a=contents` + } + // No getDiffUrl or getPrUrl - diff calculation is not supported for Azure DevOps +} + export default class AzureDevOpsProjectDataSource implements IProjectDataSource { private readonly repositoryDataSource: IAzureDevOpsRepositoryDataSource - private readonly repositoryNameSuffix: string - private readonly encryptionService: IEncryptionService - private readonly remoteConfigEncoder: IRemoteConfigEncoder + private readonly projectMapper: ProjectMapper constructor(config: { repositoryDataSource: IAzureDevOpsRepositoryDataSource @@ -28,243 +42,16 @@ export default class AzureDevOpsProjectDataSource implements IProjectDataSource remoteConfigEncoder: IRemoteConfigEncoder }) { this.repositoryDataSource = config.repositoryDataSource - this.repositoryNameSuffix = config.repositoryNameSuffix - this.encryptionService = config.encryptionService - this.remoteConfigEncoder = config.remoteConfigEncoder + this.projectMapper = new ProjectMapper({ + repositoryNameSuffix: config.repositoryNameSuffix, + urlBuilders: azureDevOpsURLBuilders, + encryptionService: config.encryptionService, + 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: AzureDevOpsRepositoryWithRefs): Project { - const config = this.getConfig(repository) - let imageURL: string | undefined - if (config && config.image) { - imageURL = this.getAzureDevOpsBlobURL({ - organization: repository.owner, - repositoryName: repository.name, - path: config.image, - ref: repository.defaultBranchRef.name - }) - } - 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://dev.azure.com/${repository.owner}`, - url: repository.webUrl - } - } - - private getConfig(repository: AzureDevOpsRepositoryWithRefs): 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: AzureDevOpsRepositoryWithRefs): Version[] { - const branchVersions = repository.branches.map(branch => { - const isDefaultRef = branch.name == repository.defaultBranchRef.name - return this.mapVersionFromRef({ - organization: repository.owner, - repositoryName: repository.name, - webUrl: repository.webUrl, - ref: branch, - isDefaultRef - }) - }) - const tagVersions = repository.tags.map(tag => { - return this.mapVersionFromRef({ - organization: repository.owner, - repositoryName: repository.name, - webUrl: repository.webUrl, - ref: tag - }) - }) - return branchVersions.concat(tagVersions) - } - - private mapVersionFromRef({ - organization, - repositoryName, - webUrl, - ref, - isDefaultRef - }: { - organization: string - repositoryName: string - webUrl: string - ref: AzureDevOpsRepositoryRef - isDefaultRef?: boolean - }): Version { - const specifications = ref.files.filter(file => { - return this.isOpenAPISpecification(file.name) - }).map(file => { - return { - id: file.name, - name: file.name, - url: this.getAzureDevOpsBlobURL({ - organization, - repositoryName, - path: file.name, - ref: ref.name - }), - // Azure DevOps edit URL format - editURL: `${webUrl}?path=/${file.name}&version=GB${ref.name}&_a=contents`, - isDefault: false // initial value - } - }).sort((a, b) => a.name.localeCompare(b.name)) - - return { - id: ref.name, - name: ref.name, - specifications: specifications, - url: `${webUrl}?version=GB${ref.name}`, - isDefault: isDefaultRef || false - } - } - - private isOpenAPISpecification(filename: string) { - return !filename.startsWith(".") && ( - filename.endsWith(".yml") || filename.endsWith(".yaml") - ) - } - - private getAzureDevOpsBlobURL({ - organization, - repositoryName, - path, - ref - }: { - organization: string - repositoryName: string - path: string - ref: string - }): string { - // Use internal API route for fetching blob content - return `/api/blob/${organization}/${repositoryName}/${path}?ref=${ref}` - } - - 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. - const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length - const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") - const specifications = remoteVersion.specifications.map(e => { - const remoteConfig: RemoteConfig = { - url: e.url, - auth: this.tryDecryptAuth(e) - } - - const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig) - - return { - id: this.makeURLSafeID((e.id || e.name).toLowerCase()), - name: e.name, - url: `/api/remotes/${encodedRemoteConfig}`, - isDefault: false // initial value - } - }) - versions.push({ - id: versionId, - name: remoteVersion.name, - specifications, - isDefault: false - }) - versionIds.push(baseVersionId) - } - return versions - } - - private sortVersions(versions: Version[], defaultBranchName: string): Version[] { - const candidateDefaultBranches = [ - defaultBranchName, "main", "master", "develop", "development", "trunk" - ] - // Reverse them so the top-priority branches end up at the top of the list. - .reverse() - const copiedVersions = [...versions].sort((a, b) => { - return a.name.localeCompare(b.name) - }) - // Move the top-priority branches to the top of the list. - for (const candidateDefaultBranch of candidateDefaultBranches) { - const defaultBranchIndex = copiedVersions.findIndex(version => { - return version.name === candidateDefaultBranch - }) - if (defaultBranchIndex !== -1) { - const branchVersion = copiedVersions[defaultBranchIndex] - copiedVersions.splice(defaultBranchIndex, 1) - copiedVersions.splice(0, 0, branchVersion) - } - } - return copiedVersions - } - - private makeURLSafeID(str: string): string { - return str - .replace(/ /g, "-") - .replace(/[^A-Za-z0-9-]/g, "") - } - - private tryDecryptAuth(projectConfigRemoteSpec: ProjectConfigRemoteSpecification): { type: string, username: string, password: string } | undefined { - if (!projectConfigRemoteSpec.auth) { - return undefined - } - - try { - return { - type: projectConfigRemoteSpec.auth.type, - username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername), - password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword) - } - } catch (error) { - console.info(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.name} (${projectConfigRemoteSpec.url}). Perhaps a different public key was used?:`, error) - return undefined - } - } - - private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version { - return { - ...version, - specifications: version.specifications.map(spec => ({ - ...spec, - isDefault: spec.name === defaultSpecificationName - })) - } + return this.projectMapper.mapRepositories(repositories) } } diff --git a/src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts b/src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts index 122ff1cc..0026cf21 100644 --- a/src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts +++ b/src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts @@ -1,29 +1,25 @@ -export type AzureDevOpsRepositoryWithRefs = { - readonly name: string - readonly owner: string // organization +import { RepositoryWithRefs, RepositoryRef } from "./ProjectMapper" + +/** + * Azure DevOps repository ref type. + */ +export type AzureDevOpsRepositoryRef = RepositoryRef & { + readonly id: string // Required in Azure DevOps (optional in base type) +} + +/** + * Azure DevOps repository type with webUrl for URL building. + */ +export type AzureDevOpsRepositoryWithRefs = RepositoryWithRefs & { readonly defaultBranchRef: { - readonly id: string + readonly id: string // Required in Azure DevOps readonly name: string } readonly webUrl: string - readonly configYml?: { - readonly text: string - } - readonly configYaml?: { - readonly text: string - } readonly branches: AzureDevOpsRepositoryRef[] readonly tags: AzureDevOpsRepositoryRef[] } -export type AzureDevOpsRepositoryRef = { - readonly id: string - readonly name: string - readonly files: { - readonly name: string - }[] -} - export default interface IAzureDevOpsRepositoryDataSource { getRepositories(): Promise } From 5c359777fd02ecb504e13132019c510a83c3e99a Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Mon, 8 Dec 2025 13:30:50 +0100 Subject: [PATCH 7/8] Use TEXT for oauth_tokens columns to support JWT tokens Microsoft Entra ID access tokens are JWTs that can exceed 2000 characters, which doesn't fit in VARCHAR(255). --- create-tables.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/create-tables.sql b/create-tables.sql index 12dffb24..e8da9c0b 100644 --- a/create-tables.sql +++ b/create-tables.sql @@ -41,9 +41,9 @@ CREATE TABLE oauth_tokens ( user_id VARCHAR(255) NOT NULL, provider VARCHAR(255) NOT NULL, - access_token VARCHAR(255) NOT NULL, - refresh_token VARCHAR(255) NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, last_updated_at timestamptz NOT NULL DEFAULT now(), - + PRIMARY KEY (user_id, provider) ); From 3c063706f1ff64ffd06817b6bde133a7e17e4683 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Mon, 8 Dec 2025 14:20:58 +0100 Subject: [PATCH 8/8] Extract SignInButton --- src/app/auth/signin/SignInButton.tsx | 29 +++++++++++++++ src/app/auth/signin/page.tsx | 54 ++++++++++------------------ 2 files changed, 47 insertions(+), 36 deletions(-) create mode 100644 src/app/auth/signin/SignInButton.tsx diff --git a/src/app/auth/signin/SignInButton.tsx b/src/app/auth/signin/SignInButton.tsx new file mode 100644 index 00000000..1163063e --- /dev/null +++ b/src/app/auth/signin/SignInButton.tsx @@ -0,0 +1,29 @@ +"use client" + +import { signIn } from "next-auth/react" +import { Button, Stack, Typography } from "@mui/material" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faGithub, faMicrosoft } from "@fortawesome/free-brands-svg-icons" + +interface SignInButtonProps { + providerId: string +} + +export default function SignInButton({ providerId }: SignInButtonProps) { + const providerIcon = providerId === "microsoft-entra-id" ? faMicrosoft : faGithub + const providerName = providerId === "microsoft-entra-id" ? "Microsoft" : "GitHub" + + return ( + + ) +} diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 8d7a8667..2d47fc94 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -1,11 +1,9 @@ import Image from "next/image" -import { Box, Button, Stack, Typography } from "@mui/material" -import { signIn } from "@/composition" +import { Box, Stack, Typography } from "@mui/material" import { env } from "@/common" -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faGithub, faMicrosoft } from "@fortawesome/free-brands-svg-icons" import SignInTexts from "@/features/auth/view/SignInTexts" import MessageLinkFooter from "@/common/ui/MessageLinkFooter" +import SignInButton from "./SignInButton" const SITE_NAME = env.getOrThrow("FRAMNA_DOCS_TITLE") const HELP_URL = env.get("FRAMNA_DOCS_HELP_URL") @@ -13,12 +11,17 @@ const PROJECT_SOURCE_PROVIDER = env.get("PROJECT_SOURCE_PROVIDER") || "github" // Force page to be rendered dynamically to ensure we read the correct values for the environment variables. export const dynamic = "force-dynamic" - + export default async function Page() { + const isAzureDevOps = PROJECT_SOURCE_PROVIDER === "azure-devops" + const providerId = isAzureDevOps ? "microsoft-entra-id" : "github" + return ( - - + + ) } @@ -37,7 +40,11 @@ const InfoColumn = () => { ) } -const SignInColumn = () => { +interface SignInColumnProps { + providerId: string +} + +const SignInColumn = ({ providerId }: SignInColumnProps) => { const title = `Get started with ${SITE_NAME}` return ( { }}> {title} - + {HELP_URL && ( - - )} + )} ) } - -const SignInButton = () => { - const isAzureDevOps = PROJECT_SOURCE_PROVIDER === "azure-devops" - const providerId = isAzureDevOps ? "microsoft-entra-id" : "github" - const providerName = isAzureDevOps ? "Microsoft" : "GitHub" - const providerIcon = isAzureDevOps ? faMicrosoft : faGithub - - return ( -
{ - "use server" - await signIn(providerId, { redirectTo: "/" }) - }} - > - -
- ) -} \ No newline at end of file