@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,83 @@
|
|
|
1
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import type {GiteaApiClient, GiteaRepository} from '#api/client.js';
|
|
3
|
+
|
|
4
|
+
const REPOSITORY: GiteaRepository = {
|
|
5
|
+
ownerLogin: 'shipfox',
|
|
6
|
+
name: 'platform',
|
|
7
|
+
fullName: 'shipfox/platform',
|
|
8
|
+
defaultBranch: 'main',
|
|
9
|
+
private: true,
|
|
10
|
+
cloneUrl: 'https://gitea.example.com/shipfox/platform.git',
|
|
11
|
+
htmlUrl: 'https://gitea.example.com/shipfox/platform',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function giteaClient(repository: GiteaRepository): GiteaApiClient {
|
|
15
|
+
return {
|
|
16
|
+
listOrgRepositories: vi.fn(),
|
|
17
|
+
getRepository: vi.fn(() => Promise.resolve(repository)),
|
|
18
|
+
resolveRef: vi.fn(),
|
|
19
|
+
listTree: vi.fn(),
|
|
20
|
+
fetchFileContent: vi.fn(),
|
|
21
|
+
organizationExists: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function connection(): IntegrationConnection<'gitea'> {
|
|
26
|
+
return {
|
|
27
|
+
id: crypto.randomUUID(),
|
|
28
|
+
workspaceId: crypto.randomUUID(),
|
|
29
|
+
provider: 'gitea',
|
|
30
|
+
externalAccountId: 'shipfox',
|
|
31
|
+
slug: 'gitea_shipfox',
|
|
32
|
+
displayName: 'Gitea shipfox',
|
|
33
|
+
lifecycleStatus: 'active',
|
|
34
|
+
createdAt: new Date(),
|
|
35
|
+
updatedAt: new Date(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function createCheckoutSpec(repository: GiteaRepository) {
|
|
40
|
+
const {GiteaSourceControlProvider} = await import('./source-control.js');
|
|
41
|
+
const provider = new GiteaSourceControlProvider(giteaClient(repository));
|
|
42
|
+
|
|
43
|
+
return provider.createCheckoutSpec({
|
|
44
|
+
connection: connection(),
|
|
45
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('GiteaSourceControlProvider clone URL override', () => {
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
vi.unstubAllEnvs();
|
|
52
|
+
vi.resetModules();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('uses the provider clone URL when GITEA_CLONE_BASE_URL is unset', async () => {
|
|
56
|
+
vi.resetModules();
|
|
57
|
+
|
|
58
|
+
const result = await createCheckoutSpec(REPOSITORY);
|
|
59
|
+
|
|
60
|
+
expect(result.repositoryUrl).toBe('https://gitea.example.com/shipfox/platform.git');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('replaces the checkout clone URL origin when GITEA_CLONE_BASE_URL is set', async () => {
|
|
64
|
+
vi.stubEnv('GITEA_CLONE_BASE_URL', 'http://gitea:3000');
|
|
65
|
+
vi.resetModules();
|
|
66
|
+
|
|
67
|
+
const result = await createCheckoutSpec(REPOSITORY);
|
|
68
|
+
|
|
69
|
+
expect(result.repositoryUrl).toBe('http://gitea:3000/shipfox/platform.git');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('keeps a non-root repository path prefix from the provider clone URL', async () => {
|
|
73
|
+
vi.stubEnv('GITEA_CLONE_BASE_URL', 'http://gitea:3000');
|
|
74
|
+
vi.resetModules();
|
|
75
|
+
|
|
76
|
+
const result = await createCheckoutSpec({
|
|
77
|
+
...REPOSITORY,
|
|
78
|
+
cloneUrl: 'https://gitea.example.com/git/shipfox/platform.git',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.repositoryUrl).toBe('http://gitea:3000/git/shipfox/platform.git');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import type {GiteaApiClient, GiteaRepository} from '#api/client.js';
|
|
3
|
+
import {GiteaIntegrationProviderError} from './errors.js';
|
|
4
|
+
import {GiteaSourceControlProvider} from './source-control.js';
|
|
5
|
+
|
|
6
|
+
const REPOSITORY: GiteaRepository = {
|
|
7
|
+
ownerLogin: 'shipfox',
|
|
8
|
+
name: 'platform',
|
|
9
|
+
fullName: 'shipfox/platform',
|
|
10
|
+
defaultBranch: 'main',
|
|
11
|
+
private: true,
|
|
12
|
+
cloneUrl: 'https://gitea.example.com/shipfox/platform.git',
|
|
13
|
+
htmlUrl: 'https://gitea.example.com/shipfox/platform',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function giteaClient(overrides: Partial<GiteaApiClient> = {}): GiteaApiClient {
|
|
17
|
+
return {
|
|
18
|
+
listOrgRepositories: vi.fn(() =>
|
|
19
|
+
Promise.resolve({repositories: [REPOSITORY], nextCursor: '2'}),
|
|
20
|
+
),
|
|
21
|
+
getRepository: vi.fn(() => Promise.resolve(REPOSITORY)),
|
|
22
|
+
resolveRef: vi.fn(() => Promise.resolve('abc123')),
|
|
23
|
+
listTree: vi.fn(() =>
|
|
24
|
+
Promise.resolve({
|
|
25
|
+
blobs: [{path: '.shipfox/workflows/ci.yml', size: 64}],
|
|
26
|
+
truncated: false,
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
fetchFileContent: vi.fn(() =>
|
|
30
|
+
Promise.resolve({
|
|
31
|
+
path: '.shipfox/workflows/ci.yml',
|
|
32
|
+
content: 'name: CI\njobs:\n build:\n steps:\n - run: pnpm test\n',
|
|
33
|
+
size: 58,
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
organizationExists: vi.fn(() => Promise.resolve(true)),
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function connection(): IntegrationConnection<'gitea'> {
|
|
42
|
+
return {
|
|
43
|
+
id: crypto.randomUUID(),
|
|
44
|
+
workspaceId: crypto.randomUUID(),
|
|
45
|
+
provider: 'gitea',
|
|
46
|
+
externalAccountId: 'shipfox',
|
|
47
|
+
slug: 'gitea_shipfox',
|
|
48
|
+
displayName: 'Gitea shipfox',
|
|
49
|
+
lifecycleStatus: 'active',
|
|
50
|
+
createdAt: new Date(),
|
|
51
|
+
updatedAt: new Date(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('GiteaSourceControlProvider', () => {
|
|
56
|
+
it('lists org repositories scoped to the connection account', async () => {
|
|
57
|
+
const gitea = giteaClient();
|
|
58
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
59
|
+
|
|
60
|
+
const result = await provider.listRepositories({connection: connection(), limit: 50});
|
|
61
|
+
|
|
62
|
+
expect(result.repositories[0]?.externalRepositoryId).toBe('gitea:shipfox/platform');
|
|
63
|
+
expect(result.repositories[0]?.visibility).toBe('private');
|
|
64
|
+
expect(result.nextCursor).toBe('2');
|
|
65
|
+
expect(gitea.listOrgRepositories).toHaveBeenCalledWith({
|
|
66
|
+
org: 'shipfox',
|
|
67
|
+
limit: 50,
|
|
68
|
+
cursor: undefined,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('filters repositories by search across scanned pages', async () => {
|
|
73
|
+
const gitea = giteaClient({
|
|
74
|
+
listOrgRepositories: vi.fn(() =>
|
|
75
|
+
Promise.resolve({
|
|
76
|
+
repositories: [REPOSITORY, {...REPOSITORY, name: 'docs', fullName: 'shipfox/docs'}],
|
|
77
|
+
nextCursor: null,
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
82
|
+
|
|
83
|
+
const result = await provider.listRepositories({
|
|
84
|
+
connection: connection(),
|
|
85
|
+
limit: 50,
|
|
86
|
+
search: 'docs',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result.repositories.map((repo) => repo.fullName)).toEqual(['shipfox/docs']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('resolves a repository from the provider-owned id', async () => {
|
|
93
|
+
const gitea = giteaClient();
|
|
94
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
95
|
+
|
|
96
|
+
const result = await provider.resolveRepository({
|
|
97
|
+
connection: connection(),
|
|
98
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result.fullName).toBe('shipfox/platform');
|
|
102
|
+
expect(gitea.getRepository).toHaveBeenCalledWith({owner: 'shipfox', repo: 'platform'});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('lists files under a prefix from the recursive tree', async () => {
|
|
106
|
+
const gitea = giteaClient({
|
|
107
|
+
listTree: vi.fn(() =>
|
|
108
|
+
Promise.resolve({
|
|
109
|
+
blobs: [
|
|
110
|
+
{path: 'README.md', size: 10},
|
|
111
|
+
{path: '.shipfox/workflows/ci.yml', size: 64},
|
|
112
|
+
{path: '.shipfox/workflows/release.yml', size: 80},
|
|
113
|
+
],
|
|
114
|
+
truncated: false,
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
});
|
|
118
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
119
|
+
|
|
120
|
+
const result = await provider.listFiles({
|
|
121
|
+
connection: connection(),
|
|
122
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
123
|
+
ref: 'main',
|
|
124
|
+
prefix: '.shipfox/workflows/',
|
|
125
|
+
limit: 100,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result.files.map((file) => file.path)).toEqual([
|
|
129
|
+
'.shipfox/workflows/ci.yml',
|
|
130
|
+
'.shipfox/workflows/release.yml',
|
|
131
|
+
]);
|
|
132
|
+
expect(result.files[0]).toEqual({path: '.shipfox/workflows/ci.yml', type: 'file', size: 64});
|
|
133
|
+
expect(gitea.resolveRef).toHaveBeenCalledWith({
|
|
134
|
+
owner: 'shipfox',
|
|
135
|
+
repo: 'platform',
|
|
136
|
+
ref: 'main',
|
|
137
|
+
});
|
|
138
|
+
expect(gitea.listTree).toHaveBeenCalledWith({
|
|
139
|
+
owner: 'shipfox',
|
|
140
|
+
repo: 'platform',
|
|
141
|
+
sha: 'abc123',
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('paginates files with an offset cursor', async () => {
|
|
146
|
+
const gitea = giteaClient({
|
|
147
|
+
listTree: vi.fn(() =>
|
|
148
|
+
Promise.resolve({
|
|
149
|
+
blobs: [
|
|
150
|
+
{path: 'a.txt', size: 1},
|
|
151
|
+
{path: 'b.txt', size: 1},
|
|
152
|
+
{path: 'c.txt', size: 1},
|
|
153
|
+
],
|
|
154
|
+
truncated: false,
|
|
155
|
+
}),
|
|
156
|
+
),
|
|
157
|
+
});
|
|
158
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
159
|
+
|
|
160
|
+
const firstPage = await provider.listFiles({
|
|
161
|
+
connection: connection(),
|
|
162
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
163
|
+
ref: 'main',
|
|
164
|
+
prefix: '',
|
|
165
|
+
limit: 2,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(firstPage.files.map((file) => file.path)).toEqual(['a.txt', 'b.txt']);
|
|
169
|
+
expect(firstPage.nextCursor).toBe('2');
|
|
170
|
+
|
|
171
|
+
const secondPage = await provider.listFiles({
|
|
172
|
+
connection: connection(),
|
|
173
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
174
|
+
ref: 'main',
|
|
175
|
+
prefix: '',
|
|
176
|
+
limit: 2,
|
|
177
|
+
cursor: firstPage.nextCursor ?? undefined,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(secondPage.files.map((file) => file.path)).toEqual(['c.txt']);
|
|
181
|
+
expect(secondPage.nextCursor).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('rejects listing files when the tree is truncated', async () => {
|
|
185
|
+
const gitea = giteaClient({
|
|
186
|
+
listTree: vi.fn(() => Promise.resolve({blobs: [], truncated: true})),
|
|
187
|
+
});
|
|
188
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
189
|
+
|
|
190
|
+
const result = provider.listFiles({
|
|
191
|
+
connection: connection(),
|
|
192
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
193
|
+
ref: 'main',
|
|
194
|
+
prefix: '',
|
|
195
|
+
limit: 100,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await expect(result).rejects.toMatchObject({reason: 'too-many-files'});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('fetches file content from the provider-owned id', async () => {
|
|
202
|
+
const gitea = giteaClient();
|
|
203
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
204
|
+
|
|
205
|
+
const result = await provider.fetchFile({
|
|
206
|
+
connection: connection(),
|
|
207
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
208
|
+
ref: 'main',
|
|
209
|
+
path: '.shipfox/workflows/ci.yml',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(result.content).toContain('name: CI');
|
|
213
|
+
expect(result.ref).toBe('main');
|
|
214
|
+
expect(gitea.fetchFileContent).toHaveBeenCalledWith({
|
|
215
|
+
owner: 'shipfox',
|
|
216
|
+
repo: 'platform',
|
|
217
|
+
path: '.shipfox/workflows/ci.yml',
|
|
218
|
+
ref: 'main',
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('rejects oversized file content', async () => {
|
|
223
|
+
const gitea = giteaClient({
|
|
224
|
+
fetchFileContent: vi.fn(() =>
|
|
225
|
+
Promise.resolve({path: 'big.bin', content: 'x'.repeat(1_000_001), size: 1_000_001}),
|
|
226
|
+
),
|
|
227
|
+
});
|
|
228
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
229
|
+
|
|
230
|
+
const result = provider.fetchFile({
|
|
231
|
+
connection: connection(),
|
|
232
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
233
|
+
ref: 'main',
|
|
234
|
+
path: 'big.bin',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await expect(result).rejects.toMatchObject({reason: 'content-too-large'});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it.each([
|
|
241
|
+
'shipfox/platform',
|
|
242
|
+
'gitea:',
|
|
243
|
+
'gitea:platform',
|
|
244
|
+
'gitea:shipfox/',
|
|
245
|
+
'gitea:/platform',
|
|
246
|
+
'gitea:shipfox/platform/extra',
|
|
247
|
+
'github:shipfox/platform',
|
|
248
|
+
'',
|
|
249
|
+
])('rejects the malformed external repository id %s before any api call', async (id) => {
|
|
250
|
+
const gitea = giteaClient();
|
|
251
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
252
|
+
|
|
253
|
+
const result = provider.resolveRepository({connection: connection(), externalRepositoryId: id});
|
|
254
|
+
|
|
255
|
+
await expect(result).rejects.toMatchObject({reason: 'repository-not-found'});
|
|
256
|
+
expect(gitea.getRepository).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('rejects an external repository id outside the connection account before any api call', async () => {
|
|
260
|
+
const gitea = giteaClient();
|
|
261
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
262
|
+
|
|
263
|
+
const result = provider.resolveRepository({
|
|
264
|
+
connection: connection(),
|
|
265
|
+
externalRepositoryId: 'gitea:intruder/platform',
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await expect(result).rejects.toMatchObject({reason: 'repository-not-found'});
|
|
269
|
+
expect(gitea.getRepository).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('creates a credential-free checkout spec carrying the service credentials', async () => {
|
|
273
|
+
vi.useFakeTimers();
|
|
274
|
+
vi.setSystemTime(new Date('2026-06-20T00:00:00.000Z'));
|
|
275
|
+
try {
|
|
276
|
+
const gitea = giteaClient();
|
|
277
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
278
|
+
|
|
279
|
+
const result = await provider.createCheckoutSpec({
|
|
280
|
+
connection: connection(),
|
|
281
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
282
|
+
ref: 'feature/x',
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(result).toEqual({
|
|
286
|
+
repositoryUrl: 'https://gitea.example.com/shipfox/platform.git',
|
|
287
|
+
ref: 'feature/x',
|
|
288
|
+
credentials: {
|
|
289
|
+
username: 'shipfox-bot',
|
|
290
|
+
token: 'test-service-token',
|
|
291
|
+
expiresAt: new Date('2026-06-20T00:05:00.000Z'),
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
const url = new URL(result.repositoryUrl);
|
|
295
|
+
expect(url.username).toBe('');
|
|
296
|
+
expect(url.password).toBe('');
|
|
297
|
+
expect(result.repositoryUrl).not.toContain('test-service-token');
|
|
298
|
+
} finally {
|
|
299
|
+
vi.useRealTimers();
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('defaults the checkout ref to the repository default branch', async () => {
|
|
304
|
+
const gitea = giteaClient();
|
|
305
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
306
|
+
|
|
307
|
+
const result = await provider.createCheckoutSpec({
|
|
308
|
+
connection: connection(),
|
|
309
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(result.ref).toBe('main');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('builds the checkout url from the provider clone url, not the configured base url', async () => {
|
|
316
|
+
const gitea = giteaClient({
|
|
317
|
+
getRepository: vi.fn(() =>
|
|
318
|
+
Promise.resolve({
|
|
319
|
+
...REPOSITORY,
|
|
320
|
+
cloneUrl: 'https://git.internal.example/shipfox/platform.git',
|
|
321
|
+
}),
|
|
322
|
+
),
|
|
323
|
+
});
|
|
324
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
325
|
+
|
|
326
|
+
const result = await provider.createCheckoutSpec({
|
|
327
|
+
connection: connection(),
|
|
328
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(result.repositoryUrl).toBe('https://git.internal.example/shipfox/platform.git');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('propagates provider errors raised while reading the repository', async () => {
|
|
335
|
+
const gitea = giteaClient({
|
|
336
|
+
getRepository: vi.fn(() =>
|
|
337
|
+
Promise.reject(new GiteaIntegrationProviderError('access-denied', 'denied')),
|
|
338
|
+
),
|
|
339
|
+
});
|
|
340
|
+
const provider = new GiteaSourceControlProvider(gitea);
|
|
341
|
+
|
|
342
|
+
const result = provider.createCheckoutSpec({
|
|
343
|
+
connection: connection(),
|
|
344
|
+
externalRepositoryId: 'gitea:shipfox/platform',
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await expect(result).rejects.toMatchObject({reason: 'access-denied'});
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {
|
|
3
|
+
buildProviderRepositoryId,
|
|
4
|
+
type CheckoutSpec,
|
|
5
|
+
type CreateCheckoutSpecInput,
|
|
6
|
+
type FetchFileInput,
|
|
7
|
+
type FilePage,
|
|
8
|
+
type FileSnapshot,
|
|
9
|
+
type IntegrationConnection,
|
|
10
|
+
type ListFilesInput,
|
|
11
|
+
type ListRepositoriesInput,
|
|
12
|
+
MAX_REPOSITORY_FILE_BYTES,
|
|
13
|
+
parseProviderRepositoryId,
|
|
14
|
+
type RepositoryPage,
|
|
15
|
+
type RepositorySnapshot,
|
|
16
|
+
type RepositoryVisibility,
|
|
17
|
+
type ResolveRepositoryInput,
|
|
18
|
+
type SourceControlProvider,
|
|
19
|
+
} from '@shipfox/api-integration-core-dto';
|
|
20
|
+
import {giteaProviderKind} from '@shipfox/api-integration-gitea-dto';
|
|
21
|
+
import type {GiteaApiClient, GiteaRepository} from '#api/client.js';
|
|
22
|
+
import {config, giteaCloneBaseOrigin} from '#config.js';
|
|
23
|
+
import {GiteaIntegrationProviderError} from './errors.js';
|
|
24
|
+
|
|
25
|
+
type GiteaIntegrationConnection = IntegrationConnection<'gitea'>;
|
|
26
|
+
|
|
27
|
+
const TRAILING_SLASHES_RE = /\/+$/;
|
|
28
|
+
const SEARCH_PAGE_SIZE = 100;
|
|
29
|
+
const SEARCH_MAX_PAGES_PER_REQUEST = 5;
|
|
30
|
+
|
|
31
|
+
export class GiteaSourceControlProvider
|
|
32
|
+
implements SourceControlProvider<GiteaIntegrationConnection>
|
|
33
|
+
{
|
|
34
|
+
constructor(private readonly gitea: GiteaApiClient) {}
|
|
35
|
+
|
|
36
|
+
async listRepositories(
|
|
37
|
+
input: ListRepositoriesInput<GiteaIntegrationConnection>,
|
|
38
|
+
): Promise<RepositoryPage> {
|
|
39
|
+
const org = input.connection.externalAccountId;
|
|
40
|
+
const needle = input.search?.trim().toLowerCase();
|
|
41
|
+
|
|
42
|
+
if (!needle) {
|
|
43
|
+
const page = await this.gitea.listOrgRepositories({
|
|
44
|
+
org,
|
|
45
|
+
limit: input.limit,
|
|
46
|
+
cursor: input.cursor,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
repositories: page.repositories.map(toRepositorySnapshot),
|
|
50
|
+
nextCursor: page.nextCursor,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const matches: RepositorySnapshot[] = [];
|
|
55
|
+
let cursor = input.cursor;
|
|
56
|
+
let pagesScanned = 0;
|
|
57
|
+
while (matches.length < input.limit && pagesScanned < SEARCH_MAX_PAGES_PER_REQUEST) {
|
|
58
|
+
const page = await this.gitea.listOrgRepositories({org, limit: SEARCH_PAGE_SIZE, cursor});
|
|
59
|
+
pagesScanned += 1;
|
|
60
|
+
for (const repo of page.repositories) {
|
|
61
|
+
if (repo.fullName.toLowerCase().includes(needle)) {
|
|
62
|
+
matches.push(toRepositorySnapshot(repo));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
cursor = page.nextCursor ?? undefined;
|
|
66
|
+
if (!cursor) break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
repositories: matches.slice(0, input.limit),
|
|
71
|
+
nextCursor: cursor ?? null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async resolveRepository(
|
|
76
|
+
input: ResolveRepositoryInput<GiteaIntegrationConnection>,
|
|
77
|
+
): Promise<RepositorySnapshot> {
|
|
78
|
+
const {owner, repo} = parseGiteaRepositoryLocator(
|
|
79
|
+
input.externalRepositoryId,
|
|
80
|
+
input.connection.externalAccountId,
|
|
81
|
+
);
|
|
82
|
+
return toRepositorySnapshot(await this.gitea.getRepository({owner, repo}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async listFiles(input: ListFilesInput<GiteaIntegrationConnection>): Promise<FilePage> {
|
|
86
|
+
const {owner, repo} = parseGiteaRepositoryLocator(
|
|
87
|
+
input.externalRepositoryId,
|
|
88
|
+
input.connection.externalAccountId,
|
|
89
|
+
);
|
|
90
|
+
const sha = await this.gitea.resolveRef({owner, repo, ref: input.ref});
|
|
91
|
+
const tree = await this.gitea.listTree({owner, repo, sha});
|
|
92
|
+
if (tree.truncated) {
|
|
93
|
+
throw new GiteaIntegrationProviderError(
|
|
94
|
+
'too-many-files',
|
|
95
|
+
`Gitea repository tree for ${input.externalRepositoryId} is too large to enumerate`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const prefix = input.prefix.replace(TRAILING_SLASHES_RE, '');
|
|
100
|
+
const matched = tree.blobs
|
|
101
|
+
.filter((blob) => prefixMatches(blob.path, prefix))
|
|
102
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
103
|
+
const offset = parseOffset(input.cursor);
|
|
104
|
+
const page = matched.slice(offset, offset + input.limit);
|
|
105
|
+
const consumed = offset + page.length;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
files: page.map((blob) => ({path: blob.path, type: 'file', size: blob.size})),
|
|
109
|
+
nextCursor: consumed < matched.length ? String(consumed) : null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async fetchFile(input: FetchFileInput<GiteaIntegrationConnection>): Promise<FileSnapshot> {
|
|
114
|
+
const {owner, repo} = parseGiteaRepositoryLocator(
|
|
115
|
+
input.externalRepositoryId,
|
|
116
|
+
input.connection.externalAccountId,
|
|
117
|
+
);
|
|
118
|
+
const file = await this.gitea.fetchFileContent({owner, repo, path: input.path, ref: input.ref});
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
file.size > MAX_REPOSITORY_FILE_BYTES ||
|
|
122
|
+
Buffer.byteLength(file.content, 'utf8') > MAX_REPOSITORY_FILE_BYTES
|
|
123
|
+
) {
|
|
124
|
+
throw new GiteaIntegrationProviderError(
|
|
125
|
+
'content-too-large',
|
|
126
|
+
'Gitea file content is larger than the supported limit',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
path: file.path,
|
|
132
|
+
ref: input.ref,
|
|
133
|
+
content: file.content,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async createCheckoutSpec(
|
|
138
|
+
input: CreateCheckoutSpecInput<GiteaIntegrationConnection>,
|
|
139
|
+
): Promise<CheckoutSpec> {
|
|
140
|
+
const {owner, repo} = parseGiteaRepositoryLocator(
|
|
141
|
+
input.externalRepositoryId,
|
|
142
|
+
input.connection.externalAccountId,
|
|
143
|
+
);
|
|
144
|
+
const repository = await this.gitea.getRepository({owner, repo});
|
|
145
|
+
const ref = input.ref?.trim() || repository.defaultBranch;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
repositoryUrl: createCheckoutRepositoryUrl(repository.cloneUrl),
|
|
149
|
+
ref,
|
|
150
|
+
// Gitea has no per-repo, auto-expiring token like a GitHub App installation
|
|
151
|
+
// token, so checkout reuses the long-lived service credential. `expiresAt`
|
|
152
|
+
// is the runner's lease/refresh window, not the token's real expiry: this
|
|
153
|
+
// credential does not actually expire and stays valid if it leaks.
|
|
154
|
+
credentials: {
|
|
155
|
+
username: config.GITEA_SERVICE_USERNAME,
|
|
156
|
+
token: config.GITEA_SERVICE_TOKEN,
|
|
157
|
+
expiresAt: new Date(Date.now() + config.GITEA_CHECKOUT_TTL_SECONDS * 1000),
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function createCheckoutRepositoryUrl(cloneUrl: string): string {
|
|
164
|
+
if (!giteaCloneBaseOrigin) return cloneUrl;
|
|
165
|
+
|
|
166
|
+
const repositoryUrl = new URL(cloneUrl);
|
|
167
|
+
|
|
168
|
+
repositoryUrl.protocol = giteaCloneBaseOrigin.protocol;
|
|
169
|
+
repositoryUrl.host = giteaCloneBaseOrigin.host;
|
|
170
|
+
|
|
171
|
+
return repositoryUrl.toString();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toRepositorySnapshot(repository: GiteaRepository): RepositorySnapshot {
|
|
175
|
+
return {
|
|
176
|
+
externalRepositoryId: buildProviderRepositoryId(
|
|
177
|
+
giteaProviderKind,
|
|
178
|
+
`${repository.ownerLogin}/${repository.name}`,
|
|
179
|
+
),
|
|
180
|
+
owner: repository.ownerLogin,
|
|
181
|
+
name: repository.name,
|
|
182
|
+
fullName: repository.fullName,
|
|
183
|
+
defaultBranch: repository.defaultBranch,
|
|
184
|
+
visibility: toRepositoryVisibility(repository),
|
|
185
|
+
cloneUrl: repository.cloneUrl,
|
|
186
|
+
htmlUrl: repository.htmlUrl,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function toRepositoryVisibility(repository: GiteaRepository): RepositoryVisibility {
|
|
191
|
+
return repository.private ? 'private' : 'public';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseGiteaRepositoryLocator(
|
|
195
|
+
externalRepositoryId: string,
|
|
196
|
+
expectedOwner: string,
|
|
197
|
+
): {owner: string; repo: string} {
|
|
198
|
+
const value = parseProviderRepositoryId(externalRepositoryId, giteaProviderKind);
|
|
199
|
+
const separatorIndex = value.indexOf('/');
|
|
200
|
+
const owner = separatorIndex > 0 ? value.slice(0, separatorIndex) : '';
|
|
201
|
+
const repo = separatorIndex > 0 ? value.slice(separatorIndex + 1) : '';
|
|
202
|
+
if (!owner || !repo || repo.includes('/')) {
|
|
203
|
+
throw new GiteaIntegrationProviderError(
|
|
204
|
+
'repository-not-found',
|
|
205
|
+
`Gitea repository id ${externalRepositoryId} must follow the form ${giteaProviderKind}:<owner>/<repo>`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
// The service token is instance-wide, so the adapter must scope every request
|
|
209
|
+
// to the connection's own account itself; without this an external id naming
|
|
210
|
+
// another org would read its private repos and mint checkout credentials for
|
|
211
|
+
// them. Reported as not-found so it does not confirm an out-of-scope repo.
|
|
212
|
+
if (owner.toLowerCase() !== expectedOwner.toLowerCase()) {
|
|
213
|
+
throw new GiteaIntegrationProviderError(
|
|
214
|
+
'repository-not-found',
|
|
215
|
+
`Gitea repository id ${externalRepositoryId} is not in the ${expectedOwner} account`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {owner, repo};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function prefixMatches(path: string, prefix: string): boolean {
|
|
223
|
+
if (!prefix) return true;
|
|
224
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseOffset(cursor: string | undefined): number {
|
|
228
|
+
if (!cursor) return 0;
|
|
229
|
+
const offset = Number.parseInt(cursor, 10);
|
|
230
|
+
return Number.isNaN(offset) || offset < 0 ? 0 : offset;
|
|
231
|
+
}
|