Skip to content

Commit f0b576e

Browse files
committed
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.
1 parent 0d907a2 commit f0b576e

File tree

2 files changed

+334
-273
lines changed

2 files changed

+334
-273
lines changed
Lines changed: 46 additions & 273 deletions
Original file line numberDiff line numberDiff line change
@@ -1,294 +1,67 @@
11
import { IEncryptionService } from "@/features/encrypt/EncryptionService"
22
import {
33
Project,
4-
Version,
5-
IProjectConfig,
64
IProjectDataSource,
7-
ProjectConfigParser,
8-
ProjectConfigRemoteVersion,
9-
IGitHubRepositoryDataSource,
10-
GitHubRepository,
11-
GitHubRepositoryRef,
12-
ProjectConfigRemoteSpecification
5+
IGitHubRepositoryDataSource
136
} from "../domain"
14-
import RemoteConfig from "../domain/RemoteConfig"
7+
import ProjectMapper, { type URLBuilders, type RepositoryWithRefs, type RepositoryRef } from "../domain/ProjectMapper"
158
import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder"
169

10+
const gitHubURLBuilders: URLBuilders<RepositoryWithRefs> = {
11+
getImageRef(repository: RepositoryWithRefs): string {
12+
return repository.defaultBranchRef.id!
13+
},
14+
getBlobRef(ref: RepositoryRef): string {
15+
return ref.id!
16+
},
17+
getOwnerUrl(owner: string): string {
18+
return `https://github.com/${owner}`
19+
},
20+
getProjectUrl(repository: RepositoryWithRefs): string {
21+
return `https://github.com/${repository.owner}/${repository.name}`
22+
},
23+
getVersionUrl(repository: RepositoryWithRefs, ref: RepositoryRef): string {
24+
return `https://github.com/${repository.owner}/${repository.name}/tree/${ref.name}`
25+
},
26+
getSpecEditUrl(repository: RepositoryWithRefs, ref: RepositoryRef, fileName: string): string {
27+
return `https://github.com/${repository.owner}/${repository.name}/edit/${ref.name}/${encodeURIComponent(fileName)}`
28+
},
29+
getDiffUrl(repository: RepositoryWithRefs, ref: RepositoryRef, fileName: string): string | undefined {
30+
if (!ref.baseRefOid || !ref.id) {
31+
return undefined
32+
}
33+
const encodedPath = fileName.split('/').map(segment => encodeURIComponent(segment)).join('/')
34+
return `/api/diff/${repository.owner}/${repository.name}/${encodedPath}?baseRefOid=${ref.baseRefOid}&to=${ref.id}`
35+
},
36+
getPrUrl(repository: RepositoryWithRefs, ref: RepositoryRef): string | undefined {
37+
if (!ref.prNumber) {
38+
return undefined
39+
}
40+
return `https://github.com/${repository.owner}/${repository.name}/pull/${ref.prNumber}`
41+
}
42+
}
43+
1744
export default class GitHubProjectDataSource implements IProjectDataSource {
1845
private readonly repositoryDataSource: IGitHubRepositoryDataSource
19-
private readonly repositoryNameSuffix: string
20-
private readonly encryptionService: IEncryptionService
21-
private readonly remoteConfigEncoder: IRemoteConfigEncoder
22-
46+
private readonly projectMapper: ProjectMapper<RepositoryWithRefs>
47+
2348
constructor(config: {
2449
repositoryDataSource: IGitHubRepositoryDataSource
2550
repositoryNameSuffix: string
2651
encryptionService: IEncryptionService
2752
remoteConfigEncoder: IRemoteConfigEncoder
2853
}) {
2954
this.repositoryDataSource = config.repositoryDataSource
30-
this.repositoryNameSuffix = config.repositoryNameSuffix
31-
this.encryptionService = config.encryptionService
32-
this.remoteConfigEncoder = config.remoteConfigEncoder
33-
}
34-
35-
async getProjects(): Promise<Project[]> {
36-
const repositories = await this.repositoryDataSource.getRepositories()
37-
return repositories.map(repository => {
38-
return this.mapProject(repository)
39-
})
40-
.filter((project: Project) => {
41-
return project.versions.length > 0
42-
})
43-
.sort((a: Project, b: Project) => {
44-
return a.name.localeCompare(b.name)
55+
this.projectMapper = new ProjectMapper({
56+
repositoryNameSuffix: config.repositoryNameSuffix,
57+
urlBuilders: gitHubURLBuilders,
58+
encryptionService: config.encryptionService,
59+
remoteConfigEncoder: config.remoteConfigEncoder
4560
})
4661
}
47-
48-
private mapProject(repository: GitHubRepository): Project {
49-
const config = this.getConfig(repository)
50-
let imageURL: string | undefined
51-
if (config && config.image) {
52-
imageURL = this.getGitHubBlobURL({
53-
ownerName: repository.owner,
54-
repositoryName: repository.name,
55-
path: config.image,
56-
ref: repository.defaultBranchRef.id
57-
})
58-
}
59-
const versions = this.sortVersions(
60-
this.addRemoteVersions(
61-
this.getVersions(repository),
62-
config?.remoteVersions || []
63-
),
64-
repository.defaultBranchRef.name
65-
).filter(version => {
66-
return version.specifications.length > 0
67-
})
68-
.map(version => this.setDefaultSpecification(version, config?.defaultSpecificationName))
69-
const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "")
70-
return {
71-
id: `${repository.owner}-${defaultName}`,
72-
owner: repository.owner,
73-
name: defaultName,
74-
displayName: config?.name || defaultName,
75-
versions,
76-
imageURL: imageURL,
77-
ownerUrl: `https://github.com/${repository.owner}`,
78-
url: `https://github.com/${repository.owner}/${repository.name}`
79-
}
80-
}
81-
82-
private getConfig(repository: GitHubRepository): IProjectConfig | null {
83-
const yml = repository.configYml || repository.configYaml
84-
if (!yml || !yml.text || yml.text.length == 0) {
85-
return null
86-
}
87-
const parser = new ProjectConfigParser()
88-
return parser.parse(yml.text)
89-
}
90-
91-
private getVersions(repository: GitHubRepository): Version[] {
92-
const branchVersions = repository.branches.map(branch => {
93-
const isDefaultRef = branch.name == repository.defaultBranchRef.name
94-
return this.mapVersionFromRef({
95-
ownerName: repository.owner,
96-
repositoryName: repository.name,
97-
ref: branch,
98-
isDefaultRef
99-
})
100-
})
101-
const tagVersions = repository.tags.map(tag => {
102-
return this.mapVersionFromRef({
103-
ownerName: repository.owner,
104-
repositoryName: repository.name,
105-
ref: tag
106-
})
107-
})
108-
return branchVersions.concat(tagVersions)
109-
}
110-
111-
private mapVersionFromRef({
112-
ownerName,
113-
repositoryName,
114-
ref,
115-
isDefaultRef
116-
}: {
117-
ownerName: string
118-
repositoryName: string
119-
ref: GitHubRepositoryRef
120-
isDefaultRef?: boolean
121-
}): Version {
122-
const specifications = ref.files.filter(file => {
123-
return this.isOpenAPISpecification(file.name)
124-
}).map(file => {
125-
return {
126-
id: file.name,
127-
name: file.name,
128-
url: this.getGitHubBlobURL({
129-
ownerName,
130-
repositoryName,
131-
path: file.name,
132-
ref: ref.id
133-
}),
134-
editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${encodeURIComponent(file.name)}`,
135-
diffURL: this.getGitHubDiffURL({
136-
ownerName,
137-
repositoryName,
138-
path: file.name,
139-
baseRefOid: ref.baseRefOid,
140-
headRefOid: ref.id
141-
}),
142-
diffBaseBranch: ref.baseRef,
143-
diffBaseOid: ref.baseRefOid,
144-
diffPrUrl: ref.prNumber ? `https://github.com/${ownerName}/${repositoryName}/pull/${ref.prNumber}` : undefined,
145-
isDefault: false // initial value
146-
}
147-
}).sort((a, b) => a.name.localeCompare(b.name))
148-
return {
149-
id: ref.name,
150-
name: ref.name,
151-
specifications: specifications,
152-
url: `https://github.com/${ownerName}/${repositoryName}/tree/${ref.name}`,
153-
isDefault: isDefaultRef || false,
154-
}
155-
}
15662

157-
private isOpenAPISpecification(filename: string) {
158-
return !filename.startsWith(".") && (
159-
filename.endsWith(".yml") || filename.endsWith(".yaml")
160-
)
161-
}
162-
163-
private getGitHubBlobURL({
164-
ownerName,
165-
repositoryName,
166-
path,
167-
ref
168-
}: {
169-
ownerName: string
170-
repositoryName: string
171-
path: string
172-
ref: string
173-
}): string {
174-
const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/')
175-
return `/api/blob/${ownerName}/${repositoryName}/${encodedPath}?ref=${ref}`
176-
}
177-
178-
private getGitHubDiffURL({
179-
ownerName,
180-
repositoryName,
181-
path,
182-
baseRefOid,
183-
headRefOid
184-
}: {
185-
ownerName: string;
186-
repositoryName: string;
187-
path: string;
188-
baseRefOid: string | undefined;
189-
headRefOid: string }
190-
): string | undefined {
191-
if (!baseRefOid) {
192-
return undefined
193-
} else {
194-
const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/')
195-
return `/api/diff/${ownerName}/${repositoryName}/${encodedPath}?baseRefOid=${baseRefOid}&to=${headRefOid}`
196-
}
197-
}
198-
199-
private addRemoteVersions(
200-
existingVersions: Version[],
201-
remoteVersions: ProjectConfigRemoteVersion[]
202-
): Version[] {
203-
const versions = [...existingVersions]
204-
const versionIds = versions.map(e => e.id)
205-
for (const remoteVersion of remoteVersions) {
206-
const baseVersionId = this.makeURLSafeID(
207-
(remoteVersion.id || remoteVersion.name).toLowerCase()
208-
)
209-
// If the version ID exists then we suffix it with a number to ensure unique versions.
210-
// E.g. if "foo" already exists, we make it "foo1".
211-
const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length
212-
const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "")
213-
const specifications = remoteVersion.specifications.map(e => {
214-
const remoteConfig: RemoteConfig = {
215-
url: e.url,
216-
auth: this.tryDecryptAuth(e)
217-
};
218-
219-
const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig);
220-
221-
return {
222-
id: this.makeURLSafeID((e.id || e.name).toLowerCase()),
223-
name: e.name,
224-
url: `/api/remotes/${encodedRemoteConfig}`,
225-
isDefault: false // initial value
226-
};
227-
})
228-
versions.push({
229-
id: versionId,
230-
name: remoteVersion.name,
231-
specifications,
232-
isDefault: false
233-
})
234-
versionIds.push(baseVersionId)
235-
}
236-
return versions
237-
}
238-
239-
private sortVersions(versions: Version[], defaultBranchName: string): Version[] {
240-
const candidateDefaultBranches = [
241-
defaultBranchName, "main", "master", "develop", "development", "trunk"
242-
]
243-
// Reverse them so the top-priority branches end up at the top of the list.
244-
.reverse()
245-
const copiedVersions = [...versions].sort((a, b) => {
246-
return a.name.localeCompare(b.name)
247-
})
248-
// Move the top-priority branches to the top of the list.
249-
for (const candidateDefaultBranch of candidateDefaultBranches) {
250-
const defaultBranchIndex = copiedVersions.findIndex(version => {
251-
return version.name === candidateDefaultBranch
252-
})
253-
if (defaultBranchIndex !== -1) {
254-
const branchVersion = copiedVersions[defaultBranchIndex]
255-
copiedVersions.splice(defaultBranchIndex, 1)
256-
copiedVersions.splice(0, 0, branchVersion)
257-
}
258-
}
259-
return copiedVersions
260-
}
261-
262-
private makeURLSafeID(str: string): string {
263-
return str
264-
.replace(/ /g, "-")
265-
.replace(/[^A-Za-z0-9-]/g, "")
266-
}
267-
268-
private tryDecryptAuth(projectConfigRemoteSpec: ProjectConfigRemoteSpecification): { type: string, username: string, password: string } | undefined {
269-
if (!projectConfigRemoteSpec.auth) {
270-
return undefined
271-
}
272-
273-
try {
274-
return {
275-
type: projectConfigRemoteSpec.auth.type,
276-
username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername),
277-
password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword)
278-
}
279-
} catch (error) {
280-
console.info(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.name} (${projectConfigRemoteSpec.url}). Perhaps a different public key was used?:`, error);
281-
return undefined
282-
}
283-
}
284-
285-
private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version {
286-
return {
287-
...version,
288-
specifications: version.specifications.map(spec => ({
289-
...spec,
290-
isDefault: spec.name === defaultSpecificationName
291-
}))
292-
}
63+
async getProjects(): Promise<Project[]> {
64+
const repositories = await this.repositoryDataSource.getRepositories()
65+
return this.projectMapper.mapRepositories(repositories)
29366
}
29467
}

0 commit comments

Comments
 (0)