|
1 | 1 | import { IEncryptionService } from "@/features/encrypt/EncryptionService" |
2 | 2 | import { |
3 | 3 | Project, |
4 | | - Version, |
5 | | - IProjectConfig, |
6 | 4 | IProjectDataSource, |
7 | | - ProjectConfigParser, |
8 | | - ProjectConfigRemoteVersion, |
9 | | - IGitHubRepositoryDataSource, |
10 | | - GitHubRepository, |
11 | | - GitHubRepositoryRef, |
12 | | - ProjectConfigRemoteSpecification |
| 5 | + IGitHubRepositoryDataSource |
13 | 6 | } from "../domain" |
14 | | -import RemoteConfig from "../domain/RemoteConfig" |
| 7 | +import ProjectMapper, { type URLBuilders, type RepositoryWithRefs, type RepositoryRef } from "../domain/ProjectMapper" |
15 | 8 | import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder" |
16 | 9 |
|
| 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 | + |
17 | 44 | export default class GitHubProjectDataSource implements IProjectDataSource { |
18 | 45 | 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 | + |
23 | 48 | constructor(config: { |
24 | 49 | repositoryDataSource: IGitHubRepositoryDataSource |
25 | 50 | repositoryNameSuffix: string |
26 | 51 | encryptionService: IEncryptionService |
27 | 52 | remoteConfigEncoder: IRemoteConfigEncoder |
28 | 53 | }) { |
29 | 54 | 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 |
45 | 60 | }) |
46 | 61 | } |
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 | | - } |
156 | 62 |
|
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) |
293 | 66 | } |
294 | 67 | } |
0 commit comments