Skip to content

Commit 06cfc9a

Browse files
andypolsAndrew Pols
authored andcommitted
feat: add scm-metadata service
1 parent 1cf06da commit 06cfc9a

10 files changed

Lines changed: 596 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import axios, { AxiosRequestConfig } from 'axios';
18+
import { GITHUB_API, GH_ACCEPT, USER_AGENT } from './githubAuth';
19+
import type { GitProvider, SCMRepositoryMetadata } from './GitProvider';
20+
21+
interface GitHubRepoResponse {
22+
description?: string;
23+
language?: string;
24+
license?: { spdx_id: string };
25+
html_url: string;
26+
owner?: { avatar_url: string; html_url: string };
27+
parent?: { full_name: string; html_url: string };
28+
}
29+
30+
function mapToScmMetadata(data: GitHubRepoResponse): SCMRepositoryMetadata {
31+
return {
32+
description: data.description,
33+
language: data.language,
34+
license: data.license?.spdx_id,
35+
htmlUrl: data.html_url,
36+
parentName: data.parent?.full_name,
37+
parentUrl: data.parent?.html_url,
38+
avatarUrl: data.owner?.avatar_url,
39+
profileUrl: data.owner?.html_url,
40+
};
41+
}
42+
43+
const publicGithubConfig: AxiosRequestConfig = {
44+
headers: {
45+
Accept: GH_ACCEPT,
46+
'User-Agent': USER_AGENT,
47+
},
48+
validateStatus: () => true,
49+
};
50+
51+
async function fetchRepo(project: string, name: string): Promise<GitHubRepoResponse | null> {
52+
const response = await axios.get<GitHubRepoResponse>(
53+
`${GITHUB_API}/repos/${encodeURIComponent(project)}/${encodeURIComponent(name)}`,
54+
publicGithubConfig,
55+
);
56+
if (response.status !== 200) {
57+
return null;
58+
}
59+
return response.data;
60+
}
61+
62+
export class GitHubProvider implements GitProvider {
63+
async getMetadata(project: string, name: string): Promise<SCMRepositoryMetadata | null> {
64+
try {
65+
const repo = await fetchRepo(project, name);
66+
return repo ? mapToScmMetadata(repo) : null;
67+
} catch {
68+
return null;
69+
}
70+
}
71+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import axios, { AxiosRequestConfig } from 'axios';
18+
import { USER_AGENT } from './githubAuth';
19+
import type { GitProvider, SCMRepositoryMetadata } from './GitProvider';
20+
21+
interface GitLabRepoResponse {
22+
description?: string;
23+
license?: { nickname: string };
24+
web_url: string;
25+
forked_from_project?: { full_name: string; web_url: string };
26+
avatar_url?: string;
27+
namespace?: { web_url: string };
28+
}
29+
30+
function gitlabConfig(): AxiosRequestConfig {
31+
return {
32+
headers: { 'User-Agent': USER_AGENT },
33+
validateStatus: () => true,
34+
};
35+
}
36+
37+
export class GitLabProvider implements GitProvider {
38+
constructor(private readonly hostname: string) {}
39+
40+
async getMetadata(project: string, name: string): Promise<SCMRepositoryMetadata | null> {
41+
const projectPath = encodeURIComponent(`${project}/${name}`);
42+
const base = `https://${this.hostname}`;
43+
const config = gitlabConfig();
44+
45+
try {
46+
const response = await axios.get<GitLabRepoResponse>(
47+
`${base}/api/v4/projects/${projectPath}`,
48+
config,
49+
);
50+
if (response.status !== 200) {
51+
return null;
52+
}
53+
54+
let primaryLanguage: string | undefined;
55+
try {
56+
const languagesResponse = await axios.get<Record<string, number>>(
57+
`${base}/api/v4/projects/${projectPath}/languages`,
58+
config,
59+
);
60+
if (languagesResponse.status === 200 && languagesResponse.data) {
61+
primaryLanguage = Object.keys(languagesResponse.data)[0];
62+
}
63+
} catch {
64+
/* optional enrichment */
65+
}
66+
67+
const data = response.data;
68+
return {
69+
description: data.description,
70+
language: primaryLanguage,
71+
license: data.license?.nickname,
72+
htmlUrl: data.web_url,
73+
parentName: data.forked_from_project?.full_name,
74+
parentUrl: data.forked_from_project?.web_url,
75+
avatarUrl: data.avatar_url,
76+
profileUrl: data.namespace?.web_url,
77+
};
78+
} catch {
79+
return null;
80+
}
81+
}
82+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export interface SCMRepositoryMetadata {
18+
description?: string;
19+
language?: string;
20+
license?: string;
21+
htmlUrl?: string;
22+
parentName?: string;
23+
parentUrl?: string;
24+
profileUrl?: string;
25+
avatarUrl?: string;
26+
}
27+
28+
export interface GitProvider {
29+
getMetadata(project: string, name: string): Promise<SCMRepositoryMetadata | null>;
30+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { GitProvider, SCMRepositoryMetadata } from './GitProvider';
18+
19+
export class UnsupportedProvider implements GitProvider {
20+
getMetadata(_project: string, _name: string): Promise<SCMRepositoryMetadata | null> {
21+
return Promise.resolve(null);
22+
}
23+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/** Public REST only — SCM metadata uses unauthenticated calls (rate-limited), cached in-process. */
18+
export const GITHUB_API = 'https://api.github.com';
19+
export const GH_ACCEPT = 'application/vnd.github+json';
20+
export const USER_AGENT = 'finos-git-proxy-scm-metadata';

src/service/gitProviders/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { GitHubProvider } from './GitHubProvider';
18+
import { GitLabProvider } from './GitLabProvider';
19+
import type { GitProvider } from './GitProvider';
20+
import { UnsupportedProvider } from './UnsupportedProvider';
21+
22+
export { GitProvider } from './GitProvider';
23+
export type { SCMRepositoryMetadata } from './GitProvider';
24+
25+
export function getGitProvider(remoteUrl: string): GitProvider {
26+
const hostname = new URL(remoteUrl).hostname.toLowerCase();
27+
if (hostname === 'github.com') return new GitHubProvider();
28+
if (hostname.includes('gitlab')) return new GitLabProvider(hostname);
29+
return new UnsupportedProvider();
30+
}

src/service/routes/repo.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { RepoQuery } from '../../db/types';
2323
import { isAdminUser } from './utils';
2424
import { Proxy } from '../../proxy';
2525
import { handleErrorAndLog } from '../../utils/errors';
26+
import { getCachedScmRepositoryMetadata } from '../scmMetadata';
2627

2728
function repo(proxy: Proxy) {
2829
const router = express.Router();
@@ -46,6 +47,17 @@ function repo(proxy: Proxy) {
4647
res.send(qd.map((d) => ({ ...d, proxyURL })));
4748
});
4849

50+
router.get('/:id/scm-metadata', async (req: Request<{ id: string }>, res: Response) => {
51+
const _id = req.params.id;
52+
const qd = await db.getRepoById(_id);
53+
if (!qd) {
54+
res.status(404).send({ message: 'Repository not found' });
55+
return;
56+
}
57+
const metadata = await getCachedScmRepositoryMetadata(qd.project, qd.name, qd.url);
58+
res.json(metadata);
59+
});
60+
4961
router.get('/:id', async (req: Request<{ id: string }>, res: Response) => {
5062
const proxyURL = getProxyURL(req);
5163
const _id = req.params.id;

0 commit comments

Comments
 (0)