Skip to content

Commit 8d4a3db

Browse files
authored
Support for github enterprise server(self hosted github) (#646)
* ui: add optional Custom Domain input in GH integration * feat(github): add support for custom GitHub domains in API service * feat(github): validate custom domain input with regex and provide user feedback * feat(github): update GithubApiService to handle optional domain input and set default API URL * feat(github): refactor GitHub domain handling to use centralized utility function * fix(github): update GITHUB_API_URL to use REST API endpoint and add error handling for custom domain retrieval * cleanup and test config * Error Handling
1 parent 055bb47 commit 8d4a3db

File tree

17 files changed

+375
-67
lines changed

17 files changed

+375
-67
lines changed

backend/analytics_server/mhq/exapi/github.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,18 @@ class GithubRateLimitExceeded(Exception):
2222

2323

2424
class GithubApiService:
25-
def __init__(self, access_token: str):
25+
def __init__(self, access_token: str, domain: Optional[str]):
2626
self._token = access_token
27-
self._g = Github(self._token, per_page=PAGE_SIZE)
28-
self.base_url = "https://api.github.com"
27+
self.base_url = self._get_api_url(domain)
28+
self._g = Github(self._token, base_url=self.base_url, per_page=PAGE_SIZE)
2929
self.headers = {"Authorization": f"Bearer {self._token}"}
3030

31+
def _get_api_url(self, domain: str) -> str:
32+
if not domain:
33+
return "https://api.github.com"
34+
else:
35+
return f"{domain}/api/v3"
36+
3137
@contextlib.contextmanager
3238
def temp_config(self, per_page: int = 30):
3339
self._g.per_page = per_page

backend/analytics_server/mhq/service/code/sync/etl_github_handler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import List, Dict, Optional, Tuple, Set
44

55
import pytz
6+
from mhq.utils.github import get_custom_github_domain
67
from github.PaginatedList import PaginatedList as GithubPaginatedList
78
from github.PullRequest import PullRequest as GithubPullRequest
89
from github.PullRequestReview import PullRequestReview as GithubPullRequestReview
@@ -382,7 +383,7 @@ def _get_access_token():
382383

383384
return GithubETLHandler(
384385
org_id,
385-
GithubApiService(_get_access_token()),
386+
GithubApiService(_get_access_token(), get_custom_github_domain(org_id)),
386387
CodeRepoService(),
387388
CodeETLAnalyticsService(),
388389
get_revert_prs_github_sync_handler(),

backend/analytics_server/mhq/service/external_integrations_service.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,29 @@ def __init__(
2626
self.custom_domain = custom_domain
2727

2828
def get_github_organizations(self):
29-
github_api_service = GithubApiService(self.access_token)
29+
github_api_service = GithubApiService(self.access_token, self.custom_domain)
3030
try:
3131
orgs: [GithubOrganization] = github_api_service.get_org_list()
3232
except GithubException as e:
3333
raise e
3434
return orgs
3535

3636
def get_github_org_repos(self, org_login: str, page_size: int, page: int):
37-
github_api_service = GithubApiService(self.access_token)
37+
github_api_service = GithubApiService(self.access_token, self.custom_domain)
3838
try:
3939
return github_api_service.get_repos_raw(org_login, page_size, page)
4040
except GithubException as e:
4141
raise e
4242

4343
def get_github_personal_repos(self, page_size: int, page: int):
44-
github_api_service = GithubApiService(self.access_token)
44+
github_api_service = GithubApiService(self.access_token, self.custom_domain)
4545
try:
4646
return github_api_service.get_user_repos_raw(page_size, page)
4747
except GithubException as e:
4848
raise e
4949

5050
def get_repo_workflows(self, gh_org_name: str, gh_org_repo_name: str):
51-
github_api_service = GithubApiService(self.access_token)
51+
github_api_service = GithubApiService(self.access_token, self.custom_domain)
5252
try:
5353
workflows = github_api_service.get_repo_workflows(
5454
gh_org_name, gh_org_repo_name

backend/analytics_server/mhq/service/workflows/sync/etl_github_actions_handler.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pytz
66

7+
from mhq.utils.github import get_custom_github_domain
78
from mhq.exapi.github import GithubApiService
89
from mhq.service.workflows.sync.etl_provider_handler import WorkflowProviderETLHandler
910
from mhq.store.models import UserIdentityProvider
@@ -181,5 +182,7 @@ def _get_access_token():
181182
return access_token
182183

183184
return GithubActionsETLHandler(
184-
org_id, GithubApiService(_get_access_token()), WorkflowRepoService()
185+
org_id,
186+
GithubApiService(_get_access_token(), get_custom_github_domain(org_id)),
187+
WorkflowRepoService(),
185188
)

backend/analytics_server/mhq/utils/github.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from queue import Queue
22
from threading import Thread
3+
from typing import Optional
34

45
from github import Organization
56

67
from mhq.utils.log import LOG
8+
from mhq.store.repos.core import CoreRepoService
9+
from mhq.store.models import UserIdentityProvider
710

811

912
def github_org_data_multi_thread_worker(orgs: [Organization]) -> dict:
@@ -48,3 +51,25 @@ def run(self):
4851
for worker in workers:
4952
r.update(worker.results)
5053
return r
54+
55+
56+
def get_custom_github_domain(org_id: str) -> Optional[str]:
57+
DEFAULT_DOMAIN = "https://api.github.com"
58+
core_repo_service = CoreRepoService()
59+
integrations = core_repo_service.get_org_integrations_for_names(
60+
org_id, [UserIdentityProvider.GITHUB.value]
61+
)
62+
63+
github_domain = (
64+
integrations[0].provider_meta.get("custom_domain")
65+
if integrations[0].provider_meta
66+
else None
67+
)
68+
69+
if not github_domain:
70+
LOG.warn(
71+
f"Custom domain not found for intergration for org {org_id} and provider {UserIdentityProvider.GITHUB.value}"
72+
)
73+
return DEFAULT_DOMAIN
74+
75+
return github_domain

backend/analytics_server/tests/exapi/__init__.py

Whitespace-only changes.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import unittest
2+
from unittest.mock import patch
3+
4+
from mhq.exapi.github import GithubApiService, PAGE_SIZE
5+
6+
7+
class DummyGithub:
8+
def __init__(self, token, base_url=None, per_page=None):
9+
self.token = token
10+
self.base_url = base_url
11+
self.per_page = per_page
12+
13+
14+
class TestGithubApiService(unittest.TestCase):
15+
16+
@patch("mhq.exapi.github.Github", new=DummyGithub)
17+
def test_default_domain_sets_standard_api_url(self):
18+
token = "deadpool"
19+
service = GithubApiService(access_token=token, domain=None)
20+
self.assertEqual(service.base_url, "https://api.github.com")
21+
self.assertIsInstance(service._g, DummyGithub)
22+
self.assertEqual(service._g.token, token)
23+
self.assertEqual(service._g.base_url, "https://api.github.com")
24+
self.assertEqual(service._g.per_page, PAGE_SIZE)
25+
26+
@patch("mhq.exapi.github.Github", new=DummyGithub)
27+
def test_empty_string_domain_uses_default_url(self):
28+
token = "deadpool"
29+
service = GithubApiService(access_token=token, domain="")
30+
self.assertEqual(service.base_url, "https://api.github.com")
31+
self.assertEqual(service._g.base_url, "https://api.github.com")
32+
33+
@patch("mhq.exapi.github.Github", new=DummyGithub)
34+
def test_custom_domain_appends_api_v3(self):
35+
token = "deadpool"
36+
custom_domain = "https://github.sujai.com"
37+
service = GithubApiService(access_token=token, domain=custom_domain)
38+
expected = f"{custom_domain}/api/v3"
39+
self.assertEqual(service.base_url, expected)
40+
self.assertEqual(service._g.base_url, expected)

web-server/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module.exports = {
22
preset: 'ts-jest/presets/js-with-babel', // Use the TypeScript preset with Babel
33
testEnvironment: 'jsdom', // Use jsdom as the test environment (for browser-like behavior)
4+
setupFiles: ['<rootDir>/jest.setup.js'],
45
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
56
testMatch: [
67
'**/__tests__/**/*.test.(ts|tsx|js|jsx)',

web-server/jest.setup.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const { TextEncoder, TextDecoder } = require('util');
2+
global.TextEncoder = TextEncoder;
3+
global.TextDecoder = TextDecoder;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
jest.mock('@/utils/db', () => ({
2+
db: jest.fn(),
3+
}));
4+
5+
import { db } from '@/utils/db';
6+
import * as githubUtils from '../utils';
7+
import { DEFAULT_GH_URL } from '@/constants/urls';
8+
9+
describe('GitHub URL utilities', () => {
10+
afterEach(() => {
11+
jest.resetAllMocks();
12+
});
13+
14+
describe('getGitHubCustomDomain', () => {
15+
it('returns custom_domain when present', async () => {
16+
const mockMeta = [{ custom_domain: 'custom.sujai.com' }];
17+
(db as jest.Mock).mockReturnValue({
18+
where: jest.fn().mockReturnThis(),
19+
then: jest.fn().mockResolvedValue(mockMeta)
20+
});
21+
22+
const domain = await githubUtils.getGitHubCustomDomain();
23+
expect(domain).toBe('custom.sujai.com');
24+
});
25+
26+
it('returns null when no provider_meta found', async () => {
27+
(db as jest.Mock).mockReturnValue({
28+
where: jest.fn().mockReturnThis(),
29+
then: jest.fn().mockResolvedValue([])
30+
});
31+
32+
const domain = await githubUtils.getGitHubCustomDomain();
33+
expect(domain).toBeNull();
34+
});
35+
36+
it('returns null on db error and logs error', async () => {
37+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
38+
(db as jest.Mock).mockImplementation(() => {
39+
throw new Error('DB failure');
40+
});
41+
42+
const domain = await githubUtils.getGitHubCustomDomain();
43+
expect(domain).toBeNull();
44+
expect(consoleSpy).toHaveBeenCalledWith(
45+
'Error occured while getting custom domain from database:',
46+
expect.any(Error)
47+
);
48+
});
49+
});
50+
51+
describe('getGitHubRestApiUrl', () => {
52+
it('uses default URL when no custom domain', async () => {
53+
jest.spyOn(githubUtils, 'getGitHubCustomDomain').mockResolvedValue(null);
54+
const url = await githubUtils.getGitHubRestApiUrl('path/to/repo');
55+
expect(url).toBe(`${DEFAULT_GH_URL}/path/to/repo`);
56+
});
57+
58+
it('uses custom domain when provided', async () => {
59+
jest.spyOn(githubUtils, 'getGitHubCustomDomain').mockResolvedValue('git.sujai.com');
60+
const url = await githubUtils.getGitHubRestApiUrl('repos/owner/repo');
61+
expect(url).toBe('https://git.sujai.com/api/v3/repos/owner/repo');
62+
});
63+
64+
it('normalizes multiple slashes in URL', async () => {
65+
jest.spyOn(githubUtils, 'getGitHubCustomDomain').mockResolvedValue('git.sujai.com/');
66+
const url = await githubUtils.getGitHubRestApiUrl('/repos//owner//repo');
67+
expect(url).toBe('https://git.sujai.com/api/v3/repos/owner/repo');
68+
});
69+
});
70+
71+
describe('getGitHubGraphQLUrl', () => {
72+
it('uses default GraphQL endpoint when no custom domain', async () => {
73+
jest.spyOn(githubUtils, 'getGitHubCustomDomain').mockResolvedValue(null);
74+
const url = await githubUtils.getGitHubGraphQLUrl();
75+
expect(url).toBe(`${DEFAULT_GH_URL}/graphql`);
76+
});
77+
78+
it('uses custom domain for GraphQL endpoint', async () => {
79+
jest.spyOn(githubUtils, 'getGitHubCustomDomain').mockResolvedValue('api.github.local');
80+
const url = await githubUtils.getGitHubGraphQLUrl();
81+
expect(url).toBe('https://api.github.local/api/graphql');
82+
});
83+
});
84+
});

web-server/pages/api/internal/[org_id]/utils.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import { Row } from '@/constants/db';
55
import { Integration } from '@/constants/integrations';
66
import { BaseRepo } from '@/types/resources';
77
import { db } from '@/utils/db';
8-
9-
const GITHUB_API_URL = 'https://api.github.com/graphql';
8+
import { DEFAULT_GH_URL } from '@/constants/urls';
109

1110
type GithubRepo = {
1211
name: string;
@@ -53,7 +52,7 @@ export const searchGithubRepos = async (
5352
};
5453

5554
const searchRepoWithURL = async (searchString: string) => {
56-
const apiUrl = `https://api.github.com/repos/${searchString}`;
55+
const apiUrl = await getGitHubRestApiUrl(`repos/${searchString}`);
5756
const response = await axios.get<GithubRepo>(apiUrl);
5857
const repo = response.data;
5958
return [
@@ -104,7 +103,9 @@ export const searchGithubReposWithNames = async (
104103

105104
const queryString = `${searchString} in:name fork:true`;
106105

107-
const response = await fetch(GITHUB_API_URL, {
106+
const githubApiUrl = await getGitHubGraphQLUrl();
107+
108+
const response = await fetch(githubApiUrl, {
108109
method: 'POST',
109110
headers: {
110111
'Content-Type': 'application/json',
@@ -304,3 +305,33 @@ const replaceURL = async (url: string): Promise<string> => {
304305

305306
return url;
306307
};
308+
309+
export const getGitHubCustomDomain = async (): Promise<string | null> => {
310+
try {
311+
const provider_meta = await db('Integration')
312+
.where('name', Integration.GITHUB)
313+
.then((r: Row<'Integration'>[]) => r.map((item) => item.provider_meta));
314+
315+
return head(provider_meta || [])?.custom_domain || null;
316+
} catch (error) {
317+
console.error('Error occured while getting custom domain from database:', error);
318+
return null;
319+
}
320+
};
321+
322+
const normalizeSlashes = (url: string) =>
323+
url.replace(/(?<!:)\/{2,}/g, '/');
324+
325+
export const getGitHubRestApiUrl = async (path: string) => {
326+
const customDomain = await getGitHubCustomDomain();
327+
const base = customDomain
328+
? `${customDomain}/api/v3`
329+
: DEFAULT_GH_URL;
330+
return normalizeSlashes(`${base}/${path}`);
331+
};
332+
333+
334+
export const getGitHubGraphQLUrl = async (): Promise<string> => {
335+
const customDomain = await getGitHubCustomDomain();
336+
return customDomain ? `${customDomain}/api/graphql` : `${DEFAULT_GH_URL}/graphql`;
337+
};

web-server/src/constants/urls.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const DEFAULT_GH_URL = 'https://api.github.com';

0 commit comments

Comments
 (0)