Skip to content

Commit b08083c

Browse files
Merge pull request #9 from url4irl/claude/issue-5-20250612_073614
feat: add GitLab, Forgejo, and Custom Git provider adapters
2 parents f2b9fdd + e40b40b commit b08083c

File tree

4 files changed

+1031
-0
lines changed

4 files changed

+1031
-0
lines changed

lib/providers/custom.ts

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
import axios, { AxiosInstance } from 'axios';
2+
import {
3+
BaseProviderAdapter,
4+
ProviderCapabilities,
5+
ProviderOperationResult,
6+
RepositoryInfo,
7+
BranchInfo,
8+
CommitInfo,
9+
WebhookConfig,
10+
ProviderAdapterFactory,
11+
} from './base';
12+
13+
export class CustomGitAdapter extends BaseProviderAdapter {
14+
private client: AxiosInstance;
15+
16+
constructor(name: string, endpoint: string, credentials: Record<string, any>) {
17+
super(name, endpoint, credentials);
18+
19+
this.client = axios.create({
20+
baseURL: this.endpoint,
21+
headers: this.getAuthHeaders(),
22+
timeout: 30000,
23+
});
24+
}
25+
26+
getCapabilities(): ProviderCapabilities {
27+
// Custom Git servers typically have limited capabilities
28+
// These can be overridden via configuration
29+
return {
30+
supportsWebhooks: false,
31+
supportsPullRequests: false,
32+
supportsIssues: false,
33+
supportsProjects: false,
34+
supportsWiki: false,
35+
supportsPackages: false,
36+
supportsActions: false,
37+
supportsProtectedBranches: false,
38+
maxFileSize: 100, // 100 MB default
39+
maxRepoSize: 10 * 1024, // 10 GB default
40+
};
41+
}
42+
43+
protected getAuthHeaders(): Record<string, string> {
44+
const headers: Record<string, string> = {
45+
'Content-Type': 'application/json',
46+
};
47+
48+
// Support various authentication methods
49+
if (this.credentials.token) {
50+
// Bearer token authentication
51+
headers['Authorization'] = `Bearer ${this.credentials.token}`;
52+
} else if (this.credentials.username && this.credentials.password) {
53+
// Basic authentication
54+
const auth = Buffer.from(`${this.credentials.username}:${this.credentials.password}`).toString('base64');
55+
headers['Authorization'] = `Basic ${auth}`;
56+
} else if (this.credentials.apiKey) {
57+
// API key authentication (custom header)
58+
headers['X-API-Key'] = this.credentials.apiKey;
59+
}
60+
61+
return headers;
62+
}
63+
64+
async validateCredentials(): Promise<ProviderOperationResult<boolean>> {
65+
try {
66+
// For custom Git servers, we'll try to access the info/refs endpoint
67+
// This is a standard Git HTTP endpoint
68+
const testRepo = this.credentials.testRepository || 'test.git';
69+
const response = await this.client.get(`/${testRepo}/info/refs`, {
70+
params: { service: 'git-upload-pack' },
71+
validateStatus: (status) => status < 500,
72+
});
73+
74+
if (response.status === 401 || response.status === 403) {
75+
return {
76+
success: false,
77+
error: 'Invalid credentials',
78+
};
79+
}
80+
81+
return {
82+
success: true,
83+
data: true,
84+
details: {
85+
authenticated: response.status !== 401,
86+
serverType: 'custom',
87+
},
88+
};
89+
} catch (error) {
90+
return this.handleError(error);
91+
}
92+
}
93+
94+
async getRepository(owner: string, repo: string): Promise<ProviderOperationResult<RepositoryInfo>> {
95+
try {
96+
// For custom Git servers, we have limited information
97+
// We'll construct what we can from the available data
98+
const repoPath = `${owner}/${repo}.git`;
99+
const fullUrl = `${this.endpoint}/${repoPath}`;
100+
101+
// Try to get refs to verify repository exists
102+
const response = await this.client.get(`/${repoPath}/info/refs`, {
103+
params: { service: 'git-upload-pack' },
104+
validateStatus: (status) => status < 500,
105+
});
106+
107+
if (response.status === 404) {
108+
return {
109+
success: false,
110+
error: 'Repository not found',
111+
};
112+
}
113+
114+
// Parse refs to find default branch
115+
const refs = this.parseRefs(response.data);
116+
const defaultBranch = this.findDefaultBranch(refs);
117+
118+
const repoInfo: RepositoryInfo = {
119+
name: repo,
120+
fullName: `${owner}/${repo}`,
121+
description: undefined, // Not available in basic Git
122+
private: true, // Assume private by default
123+
defaultBranch: defaultBranch || 'master',
124+
cloneUrl: fullUrl,
125+
sshUrl: fullUrl.replace(/^https?:\/\//, 'git@').replace(/\//, ':'),
126+
webUrl: fullUrl,
127+
size: undefined, // Not available
128+
createdAt: new Date(), // Not available, using current date
129+
updatedAt: new Date(), // Not available, using current date
130+
language: undefined, // Not available
131+
topics: [], // Not available
132+
};
133+
134+
return { success: true, data: repoInfo };
135+
} catch (error) {
136+
return this.handleError(error);
137+
}
138+
}
139+
140+
async listBranches(owner: string, repo: string): Promise<ProviderOperationResult<BranchInfo[]>> {
141+
try {
142+
const repoPath = `${owner}/${repo}.git`;
143+
const response = await this.client.get(`/${repoPath}/info/refs`, {
144+
params: { service: 'git-upload-pack' },
145+
});
146+
147+
const refs = this.parseRefs(response.data);
148+
const branches: BranchInfo[] = [];
149+
150+
for (const [ref, sha] of Object.entries(refs)) {
151+
if (ref.startsWith('refs/heads/')) {
152+
branches.push({
153+
name: ref.replace('refs/heads/', ''),
154+
commit: sha,
155+
protected: false, // Not available in basic Git
156+
});
157+
}
158+
}
159+
160+
return { success: true, data: branches };
161+
} catch (error) {
162+
return this.handleError(error);
163+
}
164+
}
165+
166+
async getCommit(owner: string, repo: string, sha: string): Promise<ProviderOperationResult<CommitInfo>> {
167+
try {
168+
// Custom Git servers don't typically expose commit details via HTTP
169+
// We'll return a minimal commit info
170+
const repoPath = `${owner}/${repo}.git`;
171+
172+
// We can only verify the commit exists by checking refs
173+
const commitInfo: CommitInfo = {
174+
sha: sha,
175+
message: 'Commit details not available via HTTP',
176+
author: {
177+
name: 'Unknown',
178+
email: 'unknown@example.com',
179+
date: new Date(),
180+
},
181+
committer: {
182+
name: 'Unknown',
183+
email: 'unknown@example.com',
184+
date: new Date(),
185+
},
186+
parents: [],
187+
url: `${this.endpoint}/${repoPath}/commit/${sha}`,
188+
};
189+
190+
return { success: true, data: commitInfo };
191+
} catch (error) {
192+
return this.handleError(error);
193+
}
194+
}
195+
196+
async push(
197+
owner: string,
198+
repo: string,
199+
branch: string,
200+
files: Array<{ path: string; content: string; encoding?: string }>,
201+
message: string,
202+
author?: { name: string; email: string }
203+
): Promise<ProviderOperationResult<CommitInfo>> {
204+
// Basic Git HTTP protocol doesn't support file-level operations
205+
// This would require implementing the Git pack protocol
206+
return {
207+
success: false,
208+
error: 'Push operations are not supported for custom Git servers via HTTP. Please use Git CLI or SSH.',
209+
};
210+
}
211+
212+
async pull(owner: string, repo: string, branch: string): Promise<ProviderOperationResult<any>> {
213+
try {
214+
const repoPath = `${owner}/${repo}.git`;
215+
216+
// Get refs
217+
const refsResponse = await this.client.get(`/${repoPath}/info/refs`, {
218+
params: { service: 'git-upload-pack' },
219+
});
220+
221+
const refs = this.parseRefs(refsResponse.data);
222+
const branchRef = `refs/heads/${branch}`;
223+
const commitSha = refs[branchRef];
224+
225+
if (!commitSha) {
226+
return {
227+
success: false,
228+
error: `Branch '${branch}' not found`,
229+
};
230+
}
231+
232+
return {
233+
success: true,
234+
data: {
235+
commit: commitSha,
236+
branch: {
237+
name: branch,
238+
commit: commitSha,
239+
},
240+
refs: refs,
241+
},
242+
};
243+
} catch (error) {
244+
return this.handleError(error);
245+
}
246+
}
247+
248+
async createWebhook(owner: string, repo: string, config: WebhookConfig): Promise<ProviderOperationResult<any>> {
249+
return {
250+
success: false,
251+
error: 'Webhooks are not supported for custom Git servers',
252+
};
253+
}
254+
255+
async deleteWebhook(owner: string, repo: string, webhookId: string): Promise<ProviderOperationResult<boolean>> {
256+
return {
257+
success: false,
258+
error: 'Webhooks are not supported for custom Git servers',
259+
};
260+
}
261+
262+
async getHealth(): Promise<ProviderOperationResult<{ healthy: boolean; latency: number; details?: any }>> {
263+
try {
264+
const start = Date.now();
265+
266+
// Try to access the base endpoint
267+
const response = await this.client.get('/', {
268+
validateStatus: (status) => status < 500,
269+
});
270+
271+
const latency = Date.now() - start;
272+
const healthy = response.status < 400;
273+
274+
return {
275+
success: true,
276+
data: {
277+
healthy,
278+
latency,
279+
details: {
280+
status: response.status,
281+
serverType: 'custom',
282+
},
283+
},
284+
};
285+
} catch (error) {
286+
return {
287+
success: false,
288+
data: {
289+
healthy: false,
290+
latency: -1,
291+
},
292+
error: this.handleError(error).error,
293+
};
294+
}
295+
}
296+
297+
// Helper methods specific to custom Git servers
298+
private parseRefs(data: string): Record<string, string> {
299+
const refs: Record<string, string> = {};
300+
const lines = data.split('\n');
301+
302+
for (const line of lines) {
303+
// Skip empty lines and service advertisements
304+
if (!line || line.startsWith('#')) continue;
305+
306+
// Parse ref format: "SHA ref-name"
307+
const match = line.match(/^([0-9a-f]{40})\s+(.+)$/);
308+
if (match) {
309+
refs[match[2]] = match[1];
310+
}
311+
}
312+
313+
return refs;
314+
}
315+
316+
private findDefaultBranch(refs: Record<string, string>): string | null {
317+
// Check for HEAD reference
318+
if (refs['HEAD']) {
319+
// HEAD might be a symbolic ref
320+
for (const [ref, sha] of Object.entries(refs)) {
321+
if (ref.startsWith('refs/heads/') && sha === refs['HEAD']) {
322+
return ref.replace('refs/heads/', '');
323+
}
324+
}
325+
}
326+
327+
// Fall back to common default branch names
328+
const commonDefaults = ['main', 'master', 'develop', 'trunk'];
329+
for (const branch of commonDefaults) {
330+
if (refs[`refs/heads/${branch}`]) {
331+
return branch;
332+
}
333+
}
334+
335+
// Return the first branch found
336+
for (const ref of Object.keys(refs)) {
337+
if (ref.startsWith('refs/heads/')) {
338+
return ref.replace('refs/heads/', '');
339+
}
340+
}
341+
342+
return null;
343+
}
344+
}
345+
346+
// Register the custom Git adapter
347+
ProviderAdapterFactory.register('custom', CustomGitAdapter);

0 commit comments

Comments
 (0)