@shipfox/api-integration-gitea 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/api/client.d.ts +58 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +225 -0
- package/dist/api/client.js.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/core/connect.d.ts +18 -0
- package/dist/core/connect.d.ts.map +1 -0
- package/dist/core/connect.js +32 -0
- package/dist/core/connect.js.map +1 -0
- package/dist/core/connection-url.d.ts +2 -0
- package/dist/core/connection-url.d.ts.map +1 -0
- package/dist/core/connection-url.js +8 -0
- package/dist/core/connection-url.js.map +1 -0
- package/dist/core/errors.d.ts +10 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +17 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/source-control.d.ts +14 -0
- package/dist/core/source-control.d.ts.map +1 -0
- package/dist/core/source-control.js +177 -0
- package/dist/core/source-control.js.map +1 -0
- package/dist/core/webhook.d.ts +38 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +162 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/connections.d.ts +19 -0
- package/dist/db/connections.d.ts.map +1 -0
- package/dist/db/connections.js +42 -0
- package/dist/db/connections.js.map +1 -0
- package/dist/db/db.d.ts +191 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/connections.d.ts +97 -0
- package/dist/db/schema/connections.d.ts.map +1 -0
- package/dist/db/schema/connections.js +28 -0
- package/dist/db/schema/connections.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/integrations.d.ts +17 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +11 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/routes/connections.d.ts +13 -0
- package/dist/presentation/routes/connections.d.ts.map +1 -0
- package/dist/presentation/routes/connections.js +47 -0
- package/dist/presentation/routes/connections.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +36 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/webhooks.d.ts +11 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +109 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +10 -0
- package/drizzle/meta/0000_snapshot.json +96 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +66 -0
- package/src/api/client.test.ts +367 -0
- package/src/api/client.ts +364 -0
- package/src/config.ts +42 -0
- package/src/connection-external-url.test.ts +36 -0
- package/src/core/connect.test.ts +149 -0
- package/src/core/connect.ts +52 -0
- package/src/core/connection-url.ts +8 -0
- package/src/core/errors.ts +17 -0
- package/src/core/source-control-clone-url.test.ts +83 -0
- package/src/core/source-control.test.ts +349 -0
- package/src/core/source-control.ts +231 -0
- package/src/core/webhook.test.ts +396 -0
- package/src/core/webhook.ts +203 -0
- package/src/db/connections.test.ts +69 -0
- package/src/db/connections.ts +80 -0
- package/src/db/db.ts +18 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/connections.ts +32 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +83 -0
- package/src/presentation/dto/integrations.ts +14 -0
- package/src/presentation/routes/connections.test.ts +200 -0
- package/src/presentation/routes/connections.ts +63 -0
- package/src/presentation/routes/errors.ts +35 -0
- package/src/presentation/routes/webhooks.test.ts +188 -0
- package/src/presentation/routes/webhooks.ts +111 -0
- package/test/env.ts +11 -0
- package/test/factories/gitea-connection.ts +19 -0
- package/test/factories/index.ts +1 -0
- package/test/fixtures/gitea-webhook.ts +237 -0
- package/test/globalSetup.ts +14 -0
- package/test/index.ts +6 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {MAX_REPOSITORY_FILE_BYTES} from '@shipfox/api-integration-core-dto';
|
|
3
|
+
import {config} from '#config.js';
|
|
4
|
+
import {GiteaIntegrationProviderError} from '#core/errors.js';
|
|
5
|
+
|
|
6
|
+
const TRAILING_SLASHES_RE = /\/+$/;
|
|
7
|
+
const NEXT_PAGE_RE = /[?&]page=(\d+)/;
|
|
8
|
+
|
|
9
|
+
// Recursive tree listing is a single call; a tree larger than this is reported as
|
|
10
|
+
// truncated by Gitea and surfaced to the adapter as `too-many-files`.
|
|
11
|
+
const TREE_PAGE_SIZE = 1000;
|
|
12
|
+
|
|
13
|
+
export interface GiteaRepository {
|
|
14
|
+
ownerLogin: string;
|
|
15
|
+
name: string;
|
|
16
|
+
fullName: string;
|
|
17
|
+
defaultBranch: string;
|
|
18
|
+
private: boolean;
|
|
19
|
+
cloneUrl: string;
|
|
20
|
+
htmlUrl: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GiteaRepositoryPage {
|
|
24
|
+
repositories: GiteaRepository[];
|
|
25
|
+
nextCursor: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GiteaTreeBlob {
|
|
29
|
+
path: string;
|
|
30
|
+
size: number | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GiteaTree {
|
|
34
|
+
blobs: GiteaTreeBlob[];
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GiteaFileContent {
|
|
39
|
+
path: string;
|
|
40
|
+
content: string;
|
|
41
|
+
size: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GiteaApiClient {
|
|
45
|
+
listOrgRepositories(input: {
|
|
46
|
+
org: string;
|
|
47
|
+
limit: number;
|
|
48
|
+
cursor?: string | undefined;
|
|
49
|
+
}): Promise<GiteaRepositoryPage>;
|
|
50
|
+
getRepository(input: {owner: string; repo: string}): Promise<GiteaRepository>;
|
|
51
|
+
resolveRef(input: {owner: string; repo: string; ref: string}): Promise<string>;
|
|
52
|
+
listTree(input: {owner: string; repo: string; sha: string}): Promise<GiteaTree>;
|
|
53
|
+
fetchFileContent(input: {
|
|
54
|
+
owner: string;
|
|
55
|
+
repo: string;
|
|
56
|
+
path: string;
|
|
57
|
+
ref: string;
|
|
58
|
+
}): Promise<GiteaFileContent>;
|
|
59
|
+
organizationExists(input: {org: string}): Promise<boolean>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createGiteaApiClient(): GiteaApiClient {
|
|
63
|
+
return new HttpGiteaApiClient();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class HttpGiteaApiClient implements GiteaApiClient {
|
|
67
|
+
private cachedBaseApiUrl: string | undefined;
|
|
68
|
+
private cachedAuthHeader: string | undefined;
|
|
69
|
+
|
|
70
|
+
async listOrgRepositories(input: {
|
|
71
|
+
org: string;
|
|
72
|
+
limit: number;
|
|
73
|
+
cursor?: string | undefined;
|
|
74
|
+
}): Promise<GiteaRepositoryPage> {
|
|
75
|
+
const response = await this.request(`orgs/${encodeURIComponent(input.org)}/repos`, {
|
|
76
|
+
page: String(cursorToPage(input.cursor)),
|
|
77
|
+
limit: String(input.limit),
|
|
78
|
+
});
|
|
79
|
+
const data = await response.json();
|
|
80
|
+
if (!Array.isArray(data)) {
|
|
81
|
+
throw new GiteaIntegrationProviderError(
|
|
82
|
+
'malformed-provider-response',
|
|
83
|
+
'Gitea repository list response was not an array',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
repositories: data.map(toGiteaRepository),
|
|
89
|
+
nextCursor: nextCursorFromLink(response.headers.get('link')),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getRepository(input: {owner: string; repo: string}): Promise<GiteaRepository> {
|
|
94
|
+
const response = await this.request(
|
|
95
|
+
`repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}`,
|
|
96
|
+
);
|
|
97
|
+
return toGiteaRepository(await response.json());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async resolveRef(input: {owner: string; repo: string; ref: string}): Promise<string> {
|
|
101
|
+
const response = await this.request(
|
|
102
|
+
`repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/commits`,
|
|
103
|
+
{sha: input.ref, limit: '1', stat: 'false', verification: 'false', files: 'false'},
|
|
104
|
+
);
|
|
105
|
+
const data = await response.json();
|
|
106
|
+
const head = Array.isArray(data) ? data[0] : undefined;
|
|
107
|
+
if (!isRecord(head) || typeof head.sha !== 'string') {
|
|
108
|
+
throw new GiteaIntegrationProviderError(
|
|
109
|
+
'repository-not-found',
|
|
110
|
+
`Gitea ref ${input.ref} did not resolve to a commit`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return head.sha;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async listTree(input: {owner: string; repo: string; sha: string}): Promise<GiteaTree> {
|
|
117
|
+
const response = await this.request(
|
|
118
|
+
`repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/git/trees/${encodeURIComponent(input.sha)}`,
|
|
119
|
+
{recursive: 'true', per_page: String(TREE_PAGE_SIZE)},
|
|
120
|
+
);
|
|
121
|
+
const data = await response.json();
|
|
122
|
+
if (!isRecord(data)) {
|
|
123
|
+
throw new GiteaIntegrationProviderError(
|
|
124
|
+
'malformed-provider-response',
|
|
125
|
+
'Gitea tree response was not an object',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const entries = Array.isArray(data.tree) ? data.tree : [];
|
|
130
|
+
const blobs: GiteaTreeBlob[] = [];
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (isRecord(entry) && entry.type === 'blob' && typeof entry.path === 'string') {
|
|
133
|
+
blobs.push({path: entry.path, size: typeof entry.size === 'number' ? entry.size : null});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {blobs, truncated: data.truncated === true};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async fetchFileContent(input: {
|
|
141
|
+
owner: string;
|
|
142
|
+
repo: string;
|
|
143
|
+
path: string;
|
|
144
|
+
ref: string;
|
|
145
|
+
}): Promise<GiteaFileContent> {
|
|
146
|
+
const response = await this.request(
|
|
147
|
+
`repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/contents/${encodePath(input.path)}`,
|
|
148
|
+
{ref: input.ref},
|
|
149
|
+
{notFoundReason: 'file-not-found'},
|
|
150
|
+
);
|
|
151
|
+
const data = await response.json();
|
|
152
|
+
|
|
153
|
+
// Gitea returns an array for a directory path and an object for a single file;
|
|
154
|
+
// a non-file response means the requested path is not a readable file.
|
|
155
|
+
if (!isRecord(data) || Array.isArray(data) || data.type !== 'file') {
|
|
156
|
+
throw new GiteaIntegrationProviderError(
|
|
157
|
+
'file-not-found',
|
|
158
|
+
`Gitea path ${input.path} is not a file`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A well-formed Gitea file response always carries a numeric size; coercing a
|
|
163
|
+
// missing size to 0 would skip the limit guard below and decode unbounded content.
|
|
164
|
+
if (typeof data.size !== 'number') {
|
|
165
|
+
throw new GiteaIntegrationProviderError(
|
|
166
|
+
'malformed-provider-response',
|
|
167
|
+
'Gitea file response did not include a numeric size',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const size = data.size;
|
|
171
|
+
if (size > MAX_REPOSITORY_FILE_BYTES) {
|
|
172
|
+
throw new GiteaIntegrationProviderError(
|
|
173
|
+
'content-too-large',
|
|
174
|
+
'Gitea file content is larger than the supported limit',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (typeof data.content !== 'string' || data.encoding !== 'base64') {
|
|
178
|
+
throw new GiteaIntegrationProviderError(
|
|
179
|
+
'malformed-provider-response',
|
|
180
|
+
'Gitea file response did not include base64 content',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
path: typeof data.path === 'string' ? data.path : input.path,
|
|
186
|
+
size,
|
|
187
|
+
content: Buffer.from(data.content, 'base64').toString('utf8'),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async organizationExists(input: {org: string}): Promise<boolean> {
|
|
192
|
+
const response = await this.requestRaw(`orgs/${encodeURIComponent(input.org)}`);
|
|
193
|
+
if (response.ok) return true;
|
|
194
|
+
if (response.status === 404) return false;
|
|
195
|
+
throw giteaHttpError(response);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async request(
|
|
199
|
+
path: string,
|
|
200
|
+
searchParams: Record<string, string> = {},
|
|
201
|
+
options: {notFoundReason?: NotFoundReason} = {},
|
|
202
|
+
): Promise<Response> {
|
|
203
|
+
const response = await this.requestRaw(path, {searchParams});
|
|
204
|
+
if (!response.ok)
|
|
205
|
+
throw giteaHttpError(response, options.notFoundReason ?? 'repository-not-found');
|
|
206
|
+
return response;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async requestRaw(
|
|
210
|
+
path: string,
|
|
211
|
+
options: {method?: string; searchParams?: Record<string, string>; body?: unknown} = {},
|
|
212
|
+
): Promise<Response> {
|
|
213
|
+
const url = new URL(`${this.baseApiUrl()}/${path}`);
|
|
214
|
+
for (const [key, value] of Object.entries(options.searchParams ?? {})) {
|
|
215
|
+
url.searchParams.set(key, value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const headers: Record<string, string> = {
|
|
219
|
+
authorization: this.authHeader(),
|
|
220
|
+
accept: 'application/json',
|
|
221
|
+
};
|
|
222
|
+
const init: RequestInit = {
|
|
223
|
+
method: options.method ?? 'GET',
|
|
224
|
+
headers,
|
|
225
|
+
signal: AbortSignal.timeout(config.GITEA_REQUEST_TIMEOUT_MS),
|
|
226
|
+
};
|
|
227
|
+
if (options.body !== undefined) {
|
|
228
|
+
headers['content-type'] = 'application/json';
|
|
229
|
+
init.body = JSON.stringify(options.body);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
return await fetch(url, init);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// A bare `fetch` has no request timeout, so an unresponsive Gitea would
|
|
236
|
+
// otherwise hold the worker open indefinitely; the AbortSignal.timeout
|
|
237
|
+
// above surfaces as a fast `timeout` (503) instead of stalling.
|
|
238
|
+
if (
|
|
239
|
+
error instanceof Error &&
|
|
240
|
+
(error.name === 'TimeoutError' || error.name === 'AbortError')
|
|
241
|
+
) {
|
|
242
|
+
throw new GiteaIntegrationProviderError('timeout', 'Gitea request timed out');
|
|
243
|
+
}
|
|
244
|
+
throw new GiteaIntegrationProviderError(
|
|
245
|
+
'provider-unavailable',
|
|
246
|
+
`Gitea request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private baseApiUrl(): string {
|
|
252
|
+
if (!this.cachedBaseApiUrl) {
|
|
253
|
+
this.cachedBaseApiUrl = `${config.GITEA_BASE_URL.replace(TRAILING_SLASHES_RE, '')}/api/v1`;
|
|
254
|
+
}
|
|
255
|
+
return this.cachedBaseApiUrl;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private authHeader(): string {
|
|
259
|
+
if (!this.cachedAuthHeader) {
|
|
260
|
+
const credentials = `${config.GITEA_SERVICE_USERNAME}:${config.GITEA_SERVICE_TOKEN}`;
|
|
261
|
+
this.cachedAuthHeader = `Basic ${Buffer.from(credentials).toString('base64')}`;
|
|
262
|
+
}
|
|
263
|
+
return this.cachedAuthHeader;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
type NotFoundReason = 'repository-not-found' | 'file-not-found';
|
|
268
|
+
|
|
269
|
+
function giteaHttpError(
|
|
270
|
+
response: Response,
|
|
271
|
+
notFoundReason: NotFoundReason = 'repository-not-found',
|
|
272
|
+
): GiteaIntegrationProviderError {
|
|
273
|
+
const status = response.status;
|
|
274
|
+
if (status === 404) {
|
|
275
|
+
return new GiteaIntegrationProviderError(notFoundReason, `Gitea responded ${status}`);
|
|
276
|
+
}
|
|
277
|
+
if (isRateLimited(response)) {
|
|
278
|
+
return new GiteaIntegrationProviderError(
|
|
279
|
+
'rate-limited',
|
|
280
|
+
`Gitea responded ${status}`,
|
|
281
|
+
retryAfterSeconds(response),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (status === 401 || status === 403) {
|
|
285
|
+
return new GiteaIntegrationProviderError('access-denied', `Gitea responded ${status}`);
|
|
286
|
+
}
|
|
287
|
+
// Server errors and any other unexpected status mean the provider could not
|
|
288
|
+
// serve the request; surface it as unavailable rather than leaking a raw error.
|
|
289
|
+
return new GiteaIntegrationProviderError('provider-unavailable', `Gitea responded ${status}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isRateLimited(response: Response): boolean {
|
|
293
|
+
if (response.status === 429) return true;
|
|
294
|
+
return response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function retryAfterSeconds(response: Response): number | undefined {
|
|
298
|
+
const retryAfter = response.headers.get('retry-after');
|
|
299
|
+
if (!retryAfter) return undefined;
|
|
300
|
+
const parsed = Number.parseInt(retryAfter, 10);
|
|
301
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function cursorToPage(cursor: string | undefined): number {
|
|
305
|
+
if (!cursor) return 1;
|
|
306
|
+
const page = Number.parseInt(cursor, 10);
|
|
307
|
+
return Number.isNaN(page) || page < 1 ? 1 : page;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function nextCursorFromLink(link: string | null): string | null {
|
|
311
|
+
if (!link) return null;
|
|
312
|
+
const next = link.split(',').find((part) => part.includes('rel="next"'));
|
|
313
|
+
if (!next) return null;
|
|
314
|
+
const match = next.match(NEXT_PAGE_RE);
|
|
315
|
+
return match?.[1] ?? null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function encodePath(path: string): string {
|
|
319
|
+
const segments = path.split('/');
|
|
320
|
+
// `..`/`.`/empty segments survive encodeURIComponent and would let `new URL`
|
|
321
|
+
// normalize the request path out of `/contents/` and hit other authenticated
|
|
322
|
+
// Gitea endpoints, so reject them before building the URL.
|
|
323
|
+
for (const segment of segments) {
|
|
324
|
+
if (segment === '' || segment === '.' || segment === '..') {
|
|
325
|
+
throw new GiteaIntegrationProviderError(
|
|
326
|
+
'file-not-found',
|
|
327
|
+
`Gitea path contains an invalid segment: ${path}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return segments.map(encodeURIComponent).join('/');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
335
|
+
return typeof value === 'object' && value !== null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function toGiteaRepository(raw: unknown): GiteaRepository {
|
|
339
|
+
if (
|
|
340
|
+
!isRecord(raw) ||
|
|
341
|
+
!isRecord(raw.owner) ||
|
|
342
|
+
typeof raw.owner.login !== 'string' ||
|
|
343
|
+
typeof raw.name !== 'string' ||
|
|
344
|
+
typeof raw.full_name !== 'string' ||
|
|
345
|
+
typeof raw.default_branch !== 'string' ||
|
|
346
|
+
typeof raw.clone_url !== 'string' ||
|
|
347
|
+
typeof raw.html_url !== 'string'
|
|
348
|
+
) {
|
|
349
|
+
throw new GiteaIntegrationProviderError(
|
|
350
|
+
'malformed-provider-response',
|
|
351
|
+
'Gitea repository response is missing required fields',
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
ownerLogin: raw.owner.login,
|
|
357
|
+
name: raw.name,
|
|
358
|
+
fullName: raw.full_name,
|
|
359
|
+
defaultBranch: raw.default_branch,
|
|
360
|
+
private: raw.private === true,
|
|
361
|
+
cloneUrl: raw.clone_url,
|
|
362
|
+
htmlUrl: raw.html_url,
|
|
363
|
+
};
|
|
364
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {createConfig, num, str, url} from '@shipfox/config';
|
|
2
|
+
|
|
3
|
+
export const config = createConfig({
|
|
4
|
+
GITEA_BASE_URL: url({
|
|
5
|
+
desc: 'Base URL of the Gitea instance the provider connects to, including the scheme (for example https://gitea.example.com). Required.',
|
|
6
|
+
}),
|
|
7
|
+
GITEA_CLONE_BASE_URL: str({
|
|
8
|
+
desc: 'Base URL runners use when cloning Gitea repositories. Leave it unset to use clone URLs reported by Gitea, or set it when runners reach Gitea through a different scheme, host, or port.',
|
|
9
|
+
default: undefined,
|
|
10
|
+
}),
|
|
11
|
+
GITEA_SERVICE_USERNAME: str({
|
|
12
|
+
desc: 'Username of the Gitea service account the provider authenticates as. Required.',
|
|
13
|
+
}),
|
|
14
|
+
GITEA_SERVICE_TOKEN: str({
|
|
15
|
+
desc: 'Access token or bot password of the Gitea service account, used for REST API and git-http basic auth. Required.',
|
|
16
|
+
}),
|
|
17
|
+
GITEA_WEBHOOK_SECRET: str({
|
|
18
|
+
desc: 'Secret used to verify incoming Gitea webhooks. Must match the org webhook secret set by the instance admin. Required.',
|
|
19
|
+
}),
|
|
20
|
+
GITEA_CHECKOUT_TTL_SECONDS: num({
|
|
21
|
+
desc: 'Lifetime in seconds of the checkout credentials handed to the runner. Defaults to 300 (five minutes).',
|
|
22
|
+
default: 300,
|
|
23
|
+
}),
|
|
24
|
+
GITEA_REQUEST_TIMEOUT_MS: num({
|
|
25
|
+
desc: 'How long in milliseconds the provider waits for a Gitea REST API response before giving up. A request that exceeds this fails as a timeout instead of holding an API worker open. Defaults to 10000 (ten seconds).',
|
|
26
|
+
default: 10_000,
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const giteaCloneBaseOrigin = parseGiteaCloneBaseOrigin(config.GITEA_CLONE_BASE_URL);
|
|
31
|
+
|
|
32
|
+
function parseGiteaCloneBaseOrigin(value: string | undefined): URL | undefined {
|
|
33
|
+
if (!value) return undefined;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return new URL(value);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'GITEA_CLONE_BASE_URL must be an absolute URL with a scheme, host, and optional port.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type {GiteaApiClient} from '#api/client.js';
|
|
2
|
+
import {createGiteaIntegrationProvider} from '#index.js';
|
|
3
|
+
|
|
4
|
+
function giteaClient(): GiteaApiClient {
|
|
5
|
+
return {
|
|
6
|
+
listOrgRepositories: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
7
|
+
getRepository: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
8
|
+
resolveRef: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
9
|
+
listTree: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
10
|
+
fetchFileContent: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
11
|
+
organizationExists: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createProvider() {
|
|
16
|
+
return createGiteaIntegrationProvider({
|
|
17
|
+
gitea: giteaClient(),
|
|
18
|
+
getExistingGiteaConnection: vi.fn(() => Promise.resolve(undefined)),
|
|
19
|
+
connectGiteaConnection: vi.fn() as never,
|
|
20
|
+
// Provider-level mounting includes webhook routes; this test exercises only URLs.
|
|
21
|
+
coreDb: vi.fn() as never,
|
|
22
|
+
publishSourcePush: vi.fn() as never,
|
|
23
|
+
recordDeliveryOnly: vi.fn() as never,
|
|
24
|
+
getIntegrationConnectionById: vi.fn() as never,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('gitea connectionExternalUrl', () => {
|
|
29
|
+
it('points at the org page on the configured Gitea instance', async () => {
|
|
30
|
+
const provider = createProvider();
|
|
31
|
+
|
|
32
|
+
const url = await provider.connectionExternalUrl({externalAccountId: 'shipfox'});
|
|
33
|
+
|
|
34
|
+
expect(url).toBe('https://gitea.example.com/shipfox');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import type {GiteaApiClient} from '#api/client.js';
|
|
3
|
+
import {handleGiteaConnect} from '#core/connect.js';
|
|
4
|
+
import {GiteaOrgAlreadyLinkedError, GiteaOrganizationNotFoundError} from '#core/errors.js';
|
|
5
|
+
|
|
6
|
+
function giteaClient(overrides: Partial<GiteaApiClient> = {}): GiteaApiClient {
|
|
7
|
+
return {
|
|
8
|
+
listOrgRepositories: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
9
|
+
getRepository: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
10
|
+
resolveRef: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
11
|
+
listTree: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
12
|
+
fetchFileContent: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
13
|
+
organizationExists: vi.fn(() => Promise.resolve(true)),
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function connection(
|
|
19
|
+
overrides: Partial<IntegrationConnection<'gitea'>> = {},
|
|
20
|
+
): IntegrationConnection<'gitea'> {
|
|
21
|
+
return {
|
|
22
|
+
id: crypto.randomUUID(),
|
|
23
|
+
workspaceId: crypto.randomUUID(),
|
|
24
|
+
provider: 'gitea',
|
|
25
|
+
externalAccountId: 'shipfox',
|
|
26
|
+
slug: 'gitea_shipfox',
|
|
27
|
+
displayName: 'Gitea shipfox',
|
|
28
|
+
lifecycleStatus: 'active',
|
|
29
|
+
createdAt: new Date(),
|
|
30
|
+
updatedAt: new Date(),
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('handleGiteaConnect', () => {
|
|
36
|
+
it('rejects an org that does not exist on the Gitea instance', async () => {
|
|
37
|
+
const gitea = giteaClient({organizationExists: vi.fn(() => Promise.resolve(false))});
|
|
38
|
+
const connectGiteaConnection = vi.fn();
|
|
39
|
+
|
|
40
|
+
const result = handleGiteaConnect({
|
|
41
|
+
gitea,
|
|
42
|
+
workspaceId: crypto.randomUUID(),
|
|
43
|
+
org: 'ghost',
|
|
44
|
+
getExistingGiteaConnection: vi.fn(() => Promise.resolve(undefined)),
|
|
45
|
+
connectGiteaConnection,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await expect(result).rejects.toBeInstanceOf(GiteaOrganizationNotFoundError);
|
|
49
|
+
expect(connectGiteaConnection).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects an org already linked to another workspace', async () => {
|
|
53
|
+
const gitea = giteaClient();
|
|
54
|
+
const existing = connection({workspaceId: 'workspace-a'});
|
|
55
|
+
const connectGiteaConnection = vi.fn();
|
|
56
|
+
|
|
57
|
+
const result = handleGiteaConnect({
|
|
58
|
+
gitea,
|
|
59
|
+
workspaceId: 'workspace-b',
|
|
60
|
+
org: 'shipfox',
|
|
61
|
+
getExistingGiteaConnection: vi.fn(() => Promise.resolve(existing)),
|
|
62
|
+
connectGiteaConnection,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await expect(result).rejects.toBeInstanceOf(GiteaOrgAlreadyLinkedError);
|
|
66
|
+
expect(connectGiteaConnection).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns the existing active connection unchanged', async () => {
|
|
70
|
+
const gitea = giteaClient();
|
|
71
|
+
const workspaceId = crypto.randomUUID();
|
|
72
|
+
const existing = connection({workspaceId, lifecycleStatus: 'active'});
|
|
73
|
+
const connectGiteaConnection = vi.fn();
|
|
74
|
+
|
|
75
|
+
const result = await handleGiteaConnect({
|
|
76
|
+
gitea,
|
|
77
|
+
workspaceId,
|
|
78
|
+
org: 'shipfox',
|
|
79
|
+
getExistingGiteaConnection: vi.fn(() => Promise.resolve(existing)),
|
|
80
|
+
connectGiteaConnection,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result).toBe(existing);
|
|
84
|
+
expect(connectGiteaConnection).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('persists the connection for a new org', async () => {
|
|
88
|
+
const gitea = giteaClient();
|
|
89
|
+
const workspaceId = crypto.randomUUID();
|
|
90
|
+
const connected = connection({workspaceId, externalAccountId: 'shipfox'});
|
|
91
|
+
const connectGiteaConnection = vi.fn(() => Promise.resolve(connected));
|
|
92
|
+
|
|
93
|
+
const result = await handleGiteaConnect({
|
|
94
|
+
gitea,
|
|
95
|
+
workspaceId,
|
|
96
|
+
org: 'shipfox',
|
|
97
|
+
getExistingGiteaConnection: vi.fn(() => Promise.resolve(undefined)),
|
|
98
|
+
connectGiteaConnection,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(connectGiteaConnection).toHaveBeenCalledWith({
|
|
102
|
+
workspaceId,
|
|
103
|
+
org: 'shipfox',
|
|
104
|
+
displayName: 'Gitea shipfox',
|
|
105
|
+
});
|
|
106
|
+
expect(result).toBe(connected);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('canonicalizes org case so a case variant cannot bypass the cross-tenant guard', async () => {
|
|
110
|
+
const gitea = giteaClient();
|
|
111
|
+
const existing = connection({workspaceId: 'workspace-a', externalAccountId: 'acme'});
|
|
112
|
+
const getExistingGiteaConnection = vi.fn(() => Promise.resolve(existing));
|
|
113
|
+
const connectGiteaConnection = vi.fn();
|
|
114
|
+
|
|
115
|
+
const result = handleGiteaConnect({
|
|
116
|
+
gitea,
|
|
117
|
+
workspaceId: 'workspace-b',
|
|
118
|
+
org: 'Acme',
|
|
119
|
+
getExistingGiteaConnection,
|
|
120
|
+
connectGiteaConnection,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await expect(result).rejects.toBeInstanceOf(GiteaOrgAlreadyLinkedError);
|
|
124
|
+
expect(gitea.organizationExists).toHaveBeenCalledWith({org: 'acme'});
|
|
125
|
+
expect(getExistingGiteaConnection).toHaveBeenCalledWith({org: 'acme'});
|
|
126
|
+
expect(connectGiteaConnection).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('persists the canonical lowercase org for a new connection', async () => {
|
|
130
|
+
const gitea = giteaClient();
|
|
131
|
+
const workspaceId = crypto.randomUUID();
|
|
132
|
+
const connected = connection({workspaceId, externalAccountId: 'shipfox'});
|
|
133
|
+
const connectGiteaConnection = vi.fn(() => Promise.resolve(connected));
|
|
134
|
+
|
|
135
|
+
await handleGiteaConnect({
|
|
136
|
+
gitea,
|
|
137
|
+
workspaceId,
|
|
138
|
+
org: 'ShipFox',
|
|
139
|
+
getExistingGiteaConnection: vi.fn(() => Promise.resolve(undefined)),
|
|
140
|
+
connectGiteaConnection,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(connectGiteaConnection).toHaveBeenCalledWith({
|
|
144
|
+
workspaceId,
|
|
145
|
+
org: 'shipfox',
|
|
146
|
+
displayName: 'Gitea shipfox',
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import type {GiteaApiClient} from '#api/client.js';
|
|
3
|
+
import {GiteaOrgAlreadyLinkedError, GiteaOrganizationNotFoundError} from './errors.js';
|
|
4
|
+
|
|
5
|
+
export interface ConnectGiteaConnectionInput {
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
org: string;
|
|
8
|
+
displayName: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HandleGiteaConnectParams {
|
|
12
|
+
gitea: GiteaApiClient;
|
|
13
|
+
workspaceId: string;
|
|
14
|
+
org: string;
|
|
15
|
+
getExistingGiteaConnection: (input: {
|
|
16
|
+
org: string;
|
|
17
|
+
}) => Promise<IntegrationConnection<'gitea'> | undefined>;
|
|
18
|
+
connectGiteaConnection: (
|
|
19
|
+
input: ConnectGiteaConnectionInput,
|
|
20
|
+
) => Promise<IntegrationConnection<'gitea'>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function handleGiteaConnect(
|
|
24
|
+
params: HandleGiteaConnectParams,
|
|
25
|
+
): Promise<IntegrationConnection<'gitea'>> {
|
|
26
|
+
// Gitea resolves org names case-insensitively, but the ownership lookup and the
|
|
27
|
+
// unique indexes that back the cross-tenant guard compare the stored `org`
|
|
28
|
+
// case-sensitively. Canonicalize once here so a case variant ("Acme" vs "acme")
|
|
29
|
+
// cannot slip past getExistingGiteaConnection and link an org another workspace
|
|
30
|
+
// already owns. The source-control scope guard already lowercases both sides.
|
|
31
|
+
const org = params.org.toLowerCase();
|
|
32
|
+
|
|
33
|
+
if (!(await params.gitea.organizationExists({org}))) {
|
|
34
|
+
throw new GiteaOrganizationNotFoundError(org);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const existing = await params.getExistingGiteaConnection({org});
|
|
38
|
+
if (existing && existing.workspaceId !== params.workspaceId) {
|
|
39
|
+
throw new GiteaOrgAlreadyLinkedError(org);
|
|
40
|
+
}
|
|
41
|
+
if (existing && existing.lifecycleStatus === 'active') {
|
|
42
|
+
return existing;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The read-only service account cannot manage org hooks; the instance admin
|
|
46
|
+
// provisions push delivery out of band.
|
|
47
|
+
return await params.connectGiteaConnection({
|
|
48
|
+
workspaceId: params.workspaceId,
|
|
49
|
+
org,
|
|
50
|
+
displayName: `Gitea ${org}`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {config} from '#config.js';
|
|
2
|
+
|
|
3
|
+
const TRAILING_SLASHES_RE = /\/+$/;
|
|
4
|
+
|
|
5
|
+
export function giteaConnectionExternalUrl(externalAccountId: string): string {
|
|
6
|
+
const base = config.GITEA_BASE_URL.replace(TRAILING_SLASHES_RE, '');
|
|
7
|
+
return `${base}/${encodeURIComponent(externalAccountId)}`;
|
|
8
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {IntegrationProviderError} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
|
|
3
|
+
export class GiteaIntegrationProviderError extends IntegrationProviderError {}
|
|
4
|
+
|
|
5
|
+
export class GiteaOrganizationNotFoundError extends Error {
|
|
6
|
+
constructor(org: string) {
|
|
7
|
+
super(`Gitea organization not found: ${org}`);
|
|
8
|
+
this.name = 'GiteaOrganizationNotFoundError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class GiteaOrgAlreadyLinkedError extends Error {
|
|
13
|
+
constructor(org: string) {
|
|
14
|
+
super(`Gitea organization is already linked to another Shipfox workspace: ${org}`);
|
|
15
|
+
this.name = 'GiteaOrgAlreadyLinkedError';
|
|
16
|
+
}
|
|
17
|
+
}
|