@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.
Files changed (119) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +96 -0
  5. package/LICENSE +21 -0
  6. package/README.md +61 -0
  7. package/dist/api/client.d.ts +58 -0
  8. package/dist/api/client.d.ts.map +1 -0
  9. package/dist/api/client.js +225 -0
  10. package/dist/api/client.js.map +1 -0
  11. package/dist/config.d.ts +11 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +38 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/connect.d.ts +18 -0
  16. package/dist/core/connect.d.ts.map +1 -0
  17. package/dist/core/connect.js +32 -0
  18. package/dist/core/connect.js.map +1 -0
  19. package/dist/core/connection-url.d.ts +2 -0
  20. package/dist/core/connection-url.d.ts.map +1 -0
  21. package/dist/core/connection-url.js +8 -0
  22. package/dist/core/connection-url.js.map +1 -0
  23. package/dist/core/errors.d.ts +10 -0
  24. package/dist/core/errors.d.ts.map +1 -0
  25. package/dist/core/errors.js +17 -0
  26. package/dist/core/errors.js.map +1 -0
  27. package/dist/core/source-control.d.ts +14 -0
  28. package/dist/core/source-control.d.ts.map +1 -0
  29. package/dist/core/source-control.js +177 -0
  30. package/dist/core/source-control.js.map +1 -0
  31. package/dist/core/webhook.d.ts +38 -0
  32. package/dist/core/webhook.d.ts.map +1 -0
  33. package/dist/core/webhook.js +162 -0
  34. package/dist/core/webhook.js.map +1 -0
  35. package/dist/db/connections.d.ts +19 -0
  36. package/dist/db/connections.d.ts.map +1 -0
  37. package/dist/db/connections.js +42 -0
  38. package/dist/db/connections.js.map +1 -0
  39. package/dist/db/db.d.ts +191 -0
  40. package/dist/db/db.d.ts.map +1 -0
  41. package/dist/db/db.js +18 -0
  42. package/dist/db/db.js.map +1 -0
  43. package/dist/db/migrations.d.ts +2 -0
  44. package/dist/db/migrations.d.ts.map +1 -0
  45. package/dist/db/migrations.js +5 -0
  46. package/dist/db/migrations.js.map +1 -0
  47. package/dist/db/schema/common.d.ts +2 -0
  48. package/dist/db/schema/common.d.ts.map +1 -0
  49. package/dist/db/schema/common.js +4 -0
  50. package/dist/db/schema/common.js.map +1 -0
  51. package/dist/db/schema/connections.d.ts +97 -0
  52. package/dist/db/schema/connections.d.ts.map +1 -0
  53. package/dist/db/schema/connections.js +28 -0
  54. package/dist/db/schema/connections.js.map +1 -0
  55. package/dist/index.d.ts +39 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +42 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/presentation/dto/integrations.d.ts +17 -0
  60. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  61. package/dist/presentation/dto/integrations.js +11 -0
  62. package/dist/presentation/dto/integrations.js.map +1 -0
  63. package/dist/presentation/routes/connections.d.ts +13 -0
  64. package/dist/presentation/routes/connections.d.ts.map +1 -0
  65. package/dist/presentation/routes/connections.js +47 -0
  66. package/dist/presentation/routes/connections.js.map +1 -0
  67. package/dist/presentation/routes/errors.d.ts +2 -0
  68. package/dist/presentation/routes/errors.d.ts.map +1 -0
  69. package/dist/presentation/routes/errors.js +36 -0
  70. package/dist/presentation/routes/errors.js.map +1 -0
  71. package/dist/presentation/routes/webhooks.d.ts +11 -0
  72. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  73. package/dist/presentation/routes/webhooks.js +109 -0
  74. package/dist/presentation/routes/webhooks.js.map +1 -0
  75. package/dist/tsconfig.test.tsbuildinfo +1 -0
  76. package/drizzle/0000_initial.sql +10 -0
  77. package/drizzle/meta/0000_snapshot.json +96 -0
  78. package/drizzle/meta/_journal.json +13 -0
  79. package/drizzle.config.ts +7 -0
  80. package/package.json +66 -0
  81. package/src/api/client.test.ts +367 -0
  82. package/src/api/client.ts +364 -0
  83. package/src/config.ts +42 -0
  84. package/src/connection-external-url.test.ts +36 -0
  85. package/src/core/connect.test.ts +149 -0
  86. package/src/core/connect.ts +52 -0
  87. package/src/core/connection-url.ts +8 -0
  88. package/src/core/errors.ts +17 -0
  89. package/src/core/source-control-clone-url.test.ts +83 -0
  90. package/src/core/source-control.test.ts +349 -0
  91. package/src/core/source-control.ts +231 -0
  92. package/src/core/webhook.test.ts +396 -0
  93. package/src/core/webhook.ts +203 -0
  94. package/src/db/connections.test.ts +69 -0
  95. package/src/db/connections.ts +80 -0
  96. package/src/db/db.ts +18 -0
  97. package/src/db/migrations.ts +4 -0
  98. package/src/db/schema/common.ts +3 -0
  99. package/src/db/schema/connections.ts +32 -0
  100. package/src/index.test.ts +23 -0
  101. package/src/index.ts +83 -0
  102. package/src/presentation/dto/integrations.ts +14 -0
  103. package/src/presentation/routes/connections.test.ts +200 -0
  104. package/src/presentation/routes/connections.ts +63 -0
  105. package/src/presentation/routes/errors.ts +35 -0
  106. package/src/presentation/routes/webhooks.test.ts +188 -0
  107. package/src/presentation/routes/webhooks.ts +111 -0
  108. package/test/env.ts +11 -0
  109. package/test/factories/gitea-connection.ts +19 -0
  110. package/test/factories/index.ts +1 -0
  111. package/test/fixtures/gitea-webhook.ts +237 -0
  112. package/test/globalSetup.ts +14 -0
  113. package/test/index.ts +6 -0
  114. package/test/setup.ts +17 -0
  115. package/tsconfig.build.json +9 -0
  116. package/tsconfig.build.tsbuildinfo +1 -0
  117. package/tsconfig.json +3 -0
  118. package/tsconfig.test.json +9 -0
  119. package/vitest.config.ts +12 -0
@@ -0,0 +1,80 @@
1
+ import {eq} from 'drizzle-orm';
2
+ import {GiteaOrgAlreadyLinkedError} from '#core/errors.js';
3
+ import {db} from './db.js';
4
+ import {giteaConnections, toGiteaConnection} from './schema/connections.js';
5
+
6
+ export interface GiteaConnection {
7
+ id: string;
8
+ connectionId: string;
9
+ org: string;
10
+ createdAt: Date;
11
+ updatedAt: Date;
12
+ }
13
+
14
+ export interface UpsertGiteaConnectionParams {
15
+ connectionId: string;
16
+ org: string;
17
+ }
18
+
19
+ type GiteaDb = ReturnType<typeof db>;
20
+ type GiteaTx = Parameters<Parameters<GiteaDb['transaction']>[0]>[0];
21
+
22
+ export async function upsertGiteaConnection(
23
+ params: UpsertGiteaConnectionParams,
24
+ options: {tx?: unknown} = {},
25
+ ): Promise<GiteaConnection> {
26
+ const executor = (options.tx ?? db()) as GiteaDb | GiteaTx;
27
+ const now = new Date();
28
+ const [row] = await executor
29
+ .insert(giteaConnections)
30
+ .values({
31
+ connectionId: params.connectionId,
32
+ org: params.org,
33
+ })
34
+ .onConflictDoUpdate({
35
+ target: giteaConnections.org,
36
+ // TOCTOU guard: only (re)point this org at the connection that already
37
+ // owns it. A concurrent connect of the same org to a different workspace
38
+ // inserts its own connection row, so its connectionId differs here; the
39
+ // predicate is false, Postgres updates nothing, and the empty RETURNING
40
+ // below rolls the losing transaction back instead of silently repointing
41
+ // the org (cross-tenant connection takeover).
42
+ setWhere: eq(giteaConnections.connectionId, params.connectionId),
43
+ set: {
44
+ connectionId: params.connectionId,
45
+ updatedAt: now,
46
+ },
47
+ })
48
+ .returning();
49
+
50
+ if (!row) throw new GiteaOrgAlreadyLinkedError(params.org);
51
+ return toGiteaConnection(row);
52
+ }
53
+
54
+ export async function getGiteaConnectionByOrg(
55
+ org: string,
56
+ options: {tx?: unknown} = {},
57
+ ): Promise<GiteaConnection | undefined> {
58
+ const executor = (options.tx ?? db()) as GiteaDb | GiteaTx;
59
+ const rows = await executor
60
+ .select()
61
+ .from(giteaConnections)
62
+ .where(eq(giteaConnections.org, org))
63
+ .limit(1);
64
+ const row = rows[0];
65
+ if (!row) return undefined;
66
+ return toGiteaConnection(row);
67
+ }
68
+
69
+ export async function getGiteaConnectionByConnectionId(
70
+ connectionId: string,
71
+ ): Promise<GiteaConnection | undefined> {
72
+ const rows = await db()
73
+ .select()
74
+ .from(giteaConnections)
75
+ .where(eq(giteaConnections.connectionId, connectionId))
76
+ .limit(1);
77
+ const row = rows[0];
78
+ if (!row) return undefined;
79
+ return toGiteaConnection(row);
80
+ }
package/src/db/db.ts ADDED
@@ -0,0 +1,18 @@
1
+ import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
+ import {pgClient} from '@shipfox/node-postgres';
3
+ import {giteaConnections} from './schema/connections.js';
4
+
5
+ export const schema = {
6
+ giteaConnections,
7
+ };
8
+
9
+ let _db: NodePgDatabase<typeof schema> | undefined;
10
+
11
+ export function db() {
12
+ if (!_db) _db = drizzle(pgClient(), {schema});
13
+ return _db;
14
+ }
15
+
16
+ export function closeDb(): void {
17
+ _db = undefined;
18
+ }
@@ -0,0 +1,4 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+
4
+ export const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../drizzle');
@@ -0,0 +1,3 @@
1
+ import {pgTableCreator} from 'drizzle-orm/pg-core';
2
+
3
+ export const pgTable = pgTableCreator((name) => `integrations_gitea_${name}`);
@@ -0,0 +1,32 @@
1
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
+ import type {GiteaConnection} from '#db/connections.js';
4
+ import {pgTable} from './common.js';
5
+
6
+ export const giteaConnections = pgTable(
7
+ 'connections',
8
+ {
9
+ id: uuidv7PrimaryKey(),
10
+ connectionId: uuid('connection_id').notNull(),
11
+ org: text('org').notNull(),
12
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
13
+ updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
14
+ },
15
+ (table) => [
16
+ uniqueIndex('integrations_gitea_connections_connection_unique').on(table.connectionId),
17
+ uniqueIndex('integrations_gitea_connections_org_unique').on(table.org),
18
+ ],
19
+ );
20
+
21
+ export type GiteaConnectionDb = typeof giteaConnections.$inferSelect;
22
+ export type GiteaConnectionCreateDb = typeof giteaConnections.$inferInsert;
23
+
24
+ export function toGiteaConnection(row: GiteaConnectionDb): GiteaConnection {
25
+ return {
26
+ id: row.id,
27
+ connectionId: row.connectionId,
28
+ org: row.org,
29
+ createdAt: row.createdAt,
30
+ updatedAt: row.updatedAt,
31
+ };
32
+ }
@@ -0,0 +1,23 @@
1
+ import {createGiteaIntegrationProvider, GiteaSourceControlProvider} from '#index.js';
2
+
3
+ describe('createGiteaIntegrationProvider', () => {
4
+ it('exposes the gitea source-control adapter and the connection + webhook route groups', () => {
5
+ const provider = createGiteaIntegrationProvider({
6
+ getExistingGiteaConnection: vi.fn(() => Promise.resolve(undefined)),
7
+ connectGiteaConnection: vi.fn() as never,
8
+ coreDb: vi.fn() as never,
9
+ publishSourcePush: vi.fn() as never,
10
+ recordDeliveryOnly: vi.fn() as never,
11
+ getIntegrationConnectionById: vi.fn() as never,
12
+ });
13
+
14
+ expect(provider.provider).toBe('gitea');
15
+ expect(provider.displayName).toBe('Gitea');
16
+ expect(provider.adapters.source_control).toBeInstanceOf(GiteaSourceControlProvider);
17
+ expect(provider.routes).toHaveLength(2);
18
+ expect(provider.routes.map((group) => group.prefix)).toEqual([
19
+ '/integrations/gitea',
20
+ '/webhooks/integrations/gitea',
21
+ ]);
22
+ });
23
+ });
package/src/index.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type {
2
+ GetIntegrationConnectionByIdFn,
3
+ IntegrationConnection,
4
+ PublishSourcePushFn,
5
+ RecordDeliveryOnlyFn,
6
+ } from '@shipfox/api-integration-core-dto';
7
+ import {giteaProviderKind} from '@shipfox/api-integration-gitea-dto';
8
+ import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
9
+ import {createGiteaApiClient, type GiteaApiClient} from '#api/client.js';
10
+ import type {ConnectGiteaConnectionInput} from '#core/connect.js';
11
+ import {giteaConnectionExternalUrl} from '#core/connection-url.js';
12
+ import {GiteaSourceControlProvider} from '#core/source-control.js';
13
+ import {closeDb, db} from '#db/db.js';
14
+ import {migrationsPath} from '#db/migrations.js';
15
+ import {createGiteaConnectionRoutes} from '#presentation/routes/connections.js';
16
+ import {createGiteaWebhookRoutes} from '#presentation/routes/webhooks.js';
17
+
18
+ export type {
19
+ GiteaApiClient,
20
+ GiteaFileContent,
21
+ GiteaRepository,
22
+ GiteaRepositoryPage,
23
+ GiteaTree,
24
+ GiteaTreeBlob,
25
+ } from '#api/client.js';
26
+ export {createGiteaApiClient} from '#api/client.js';
27
+ export type {ConnectGiteaConnectionInput} from '#core/connect.js';
28
+ export {handleGiteaConnect} from '#core/connect.js';
29
+ export {
30
+ GiteaIntegrationProviderError,
31
+ GiteaOrgAlreadyLinkedError,
32
+ GiteaOrganizationNotFoundError,
33
+ } from '#core/errors.js';
34
+ export {GiteaSourceControlProvider} from '#core/source-control.js';
35
+ export type {GiteaConnection, UpsertGiteaConnectionParams} from '#db/connections.js';
36
+ export {
37
+ getGiteaConnectionByConnectionId,
38
+ getGiteaConnectionByOrg,
39
+ upsertGiteaConnection,
40
+ } from '#db/connections.js';
41
+ export {closeDb, db, migrationsPath};
42
+
43
+ export interface CreateGiteaIntegrationProviderOptions {
44
+ gitea?: GiteaApiClient | undefined;
45
+ getExistingGiteaConnection: (input: {
46
+ org: string;
47
+ }) => Promise<IntegrationConnection<'gitea'> | undefined>;
48
+ connectGiteaConnection: (
49
+ input: ConnectGiteaConnectionInput,
50
+ ) => Promise<IntegrationConnection<'gitea'>>;
51
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
52
+ publishSourcePush: PublishSourcePushFn;
53
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
54
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
55
+ }
56
+
57
+ export function createGiteaIntegrationProvider(options: CreateGiteaIntegrationProviderOptions) {
58
+ const gitea = options.gitea ?? createGiteaApiClient();
59
+
60
+ return {
61
+ provider: giteaProviderKind,
62
+ displayName: 'Gitea',
63
+ adapters: {
64
+ source_control: new GiteaSourceControlProvider(gitea),
65
+ },
66
+ connectionExternalUrl(connection: {externalAccountId: string}): Promise<string | undefined> {
67
+ return Promise.resolve(giteaConnectionExternalUrl(connection.externalAccountId));
68
+ },
69
+ routes: [
70
+ createGiteaConnectionRoutes({
71
+ gitea,
72
+ getExistingGiteaConnection: options.getExistingGiteaConnection,
73
+ connectGiteaConnection: options.connectGiteaConnection,
74
+ }),
75
+ createGiteaWebhookRoutes({
76
+ coreDb: options.coreDb,
77
+ publishSourcePush: options.publishSourcePush,
78
+ recordDeliveryOnly: options.recordDeliveryOnly,
79
+ getIntegrationConnectionById: options.getIntegrationConnectionById,
80
+ }),
81
+ ],
82
+ };
83
+ }
@@ -0,0 +1,14 @@
1
+ import {
2
+ type IntegrationConnection,
3
+ toIntegrationConnectionDto as toCoreIntegrationConnectionDto,
4
+ } from '@shipfox/api-integration-core-dto';
5
+
6
+ export function toIntegrationConnectionDto(
7
+ connection: IntegrationConnection<'gitea'>,
8
+ options: {externalUrl?: string | undefined} = {},
9
+ ) {
10
+ return toCoreIntegrationConnectionDto(connection, {
11
+ capabilities: ['source_control'],
12
+ externalUrl: options.externalUrl,
13
+ });
14
+ }
@@ -0,0 +1,200 @@
1
+ import {
2
+ AUTH_USER,
3
+ buildUserContext,
4
+ setUserContext,
5
+ type UserContextMembership,
6
+ } from '@shipfox/api-auth-context';
7
+ import {
8
+ ConnectionSlugConflictError,
9
+ type IntegrationConnection,
10
+ } from '@shipfox/api-integration-core-dto';
11
+ import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
12
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
13
+ import type {GiteaApiClient} from '#api/client.js';
14
+ import type {ConnectGiteaConnectionInput} from '#core/connect.js';
15
+ import {createGiteaIntegrationProvider} from '#index.js';
16
+
17
+ let authenticatedMemberships: UserContextMembership[] = [];
18
+
19
+ const fakeUserAuth: AuthMethod = {
20
+ name: AUTH_USER,
21
+ authenticate: (request: FastifyRequest) => {
22
+ if (request.headers.authorization !== 'Bearer user') {
23
+ throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
24
+ }
25
+
26
+ setUserContext(
27
+ request,
28
+ buildUserContext({
29
+ userId: 'user-1',
30
+ email: 'user@example.com',
31
+ memberships: authenticatedMemberships,
32
+ }),
33
+ );
34
+ return Promise.resolve();
35
+ },
36
+ };
37
+
38
+ function giteaClient(overrides: Partial<GiteaApiClient> = {}): GiteaApiClient {
39
+ return {
40
+ listOrgRepositories: vi.fn(() => Promise.reject(new Error('not used'))),
41
+ getRepository: vi.fn(() => Promise.reject(new Error('not used'))),
42
+ resolveRef: vi.fn(() => Promise.reject(new Error('not used'))),
43
+ listTree: vi.fn(() => Promise.reject(new Error('not used'))),
44
+ fetchFileContent: vi.fn(() => Promise.reject(new Error('not used'))),
45
+ organizationExists: vi.fn(() => Promise.resolve(true)),
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ interface CreateTestAppOptions {
51
+ gitea?: GiteaApiClient;
52
+ existingConnection?: IntegrationConnection<'gitea'> | undefined;
53
+ connectGiteaConnection?:
54
+ | ((input: ConnectGiteaConnectionInput) => Promise<IntegrationConnection<'gitea'>>)
55
+ | undefined;
56
+ }
57
+
58
+ async function createTestApp(options: CreateTestAppOptions = {}): Promise<FastifyInstance> {
59
+ const provider = createGiteaIntegrationProvider({
60
+ gitea: options.gitea ?? giteaClient(),
61
+ getExistingGiteaConnection: vi.fn(() => Promise.resolve(options.existingConnection)),
62
+ connectGiteaConnection:
63
+ options.connectGiteaConnection ??
64
+ vi.fn((input: ConnectGiteaConnectionInput) => {
65
+ const connection: IntegrationConnection<'gitea'> = {
66
+ id: crypto.randomUUID(),
67
+ workspaceId: input.workspaceId,
68
+ provider: 'gitea',
69
+ externalAccountId: input.org,
70
+ slug: 'gitea_shipfox',
71
+ displayName: input.displayName,
72
+ lifecycleStatus: 'active',
73
+ createdAt: new Date(),
74
+ updatedAt: new Date(),
75
+ };
76
+
77
+ return Promise.resolve(connection);
78
+ }),
79
+ // Provider-level mounting includes webhook routes; these tests exercise only connections.
80
+ coreDb: vi.fn() as never,
81
+ publishSourcePush: vi.fn() as never,
82
+ recordDeliveryOnly: vi.fn() as never,
83
+ getIntegrationConnectionById: vi.fn() as never,
84
+ });
85
+ const app = await createApp({
86
+ auth: [fakeUserAuth],
87
+ routes: provider.routes,
88
+ swagger: false,
89
+ });
90
+ await app.ready();
91
+ return app;
92
+ }
93
+
94
+ describe('Gitea connection routes', () => {
95
+ beforeEach(async () => {
96
+ authenticatedMemberships = [];
97
+ await closeApp();
98
+ });
99
+
100
+ afterEach(async () => {
101
+ await closeApp();
102
+ });
103
+
104
+ it('requires auth to connect an org', async () => {
105
+ const app = await createTestApp();
106
+
107
+ const res = await app.inject({
108
+ method: 'POST',
109
+ url: '/integrations/gitea/connections',
110
+ payload: {workspace_id: crypto.randomUUID(), org: 'shipfox'},
111
+ });
112
+
113
+ expect(res.statusCode).toBe(401);
114
+ });
115
+
116
+ it('connects an org and returns the connection DTO', async () => {
117
+ const app = await createTestApp();
118
+ const workspaceId = crypto.randomUUID();
119
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
120
+
121
+ const res = await app.inject({
122
+ method: 'POST',
123
+ url: '/integrations/gitea/connections',
124
+ headers: {authorization: 'Bearer user'},
125
+ payload: {workspace_id: workspaceId, org: 'shipfox'},
126
+ });
127
+
128
+ expect(res.statusCode).toBe(200);
129
+ expect(res.json().provider).toBe('gitea');
130
+ expect(res.json().external_account_id).toBe('shipfox');
131
+ expect(res.json().lifecycle_status).toBe('active');
132
+ expect(res.json().external_url).toBe('https://gitea.example.com/shipfox');
133
+ });
134
+
135
+ it('returns 404 when the org does not exist', async () => {
136
+ const app = await createTestApp({
137
+ gitea: giteaClient({organizationExists: vi.fn(() => Promise.resolve(false))}),
138
+ });
139
+ const workspaceId = crypto.randomUUID();
140
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
141
+
142
+ const res = await app.inject({
143
+ method: 'POST',
144
+ url: '/integrations/gitea/connections',
145
+ headers: {authorization: 'Bearer user'},
146
+ payload: {workspace_id: workspaceId, org: 'ghost'},
147
+ });
148
+
149
+ expect(res.statusCode).toBe(404);
150
+ expect(res.json().code).toBe('gitea-organization-not-found');
151
+ });
152
+
153
+ it('returns 409 when the org is already linked to another workspace', async () => {
154
+ const workspaceId = crypto.randomUUID();
155
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
156
+ const app = await createTestApp({
157
+ existingConnection: {
158
+ id: crypto.randomUUID(),
159
+ workspaceId: crypto.randomUUID(),
160
+ provider: 'gitea',
161
+ externalAccountId: 'shipfox',
162
+ slug: 'gitea_shipfox',
163
+ displayName: 'Gitea shipfox',
164
+ lifecycleStatus: 'active',
165
+ createdAt: new Date(),
166
+ updatedAt: new Date(),
167
+ },
168
+ });
169
+
170
+ const res = await app.inject({
171
+ method: 'POST',
172
+ url: '/integrations/gitea/connections',
173
+ headers: {authorization: 'Bearer user'},
174
+ payload: {workspace_id: workspaceId, org: 'shipfox'},
175
+ });
176
+
177
+ expect(res.statusCode).toBe(409);
178
+ expect(res.json().code).toBe('gitea-org-already-linked');
179
+ });
180
+
181
+ it('returns 409 when connection slug allocation conflicts repeatedly', async () => {
182
+ const workspaceId = crypto.randomUUID();
183
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
184
+ const app = await createTestApp({
185
+ connectGiteaConnection: vi.fn(() =>
186
+ Promise.reject(new ConnectionSlugConflictError(new Error('duplicate slug'))),
187
+ ),
188
+ });
189
+
190
+ const res = await app.inject({
191
+ method: 'POST',
192
+ url: '/integrations/gitea/connections',
193
+ headers: {authorization: 'Bearer user'},
194
+ payload: {workspace_id: workspaceId, org: 'shipfox'},
195
+ });
196
+
197
+ expect(res.statusCode).toBe(409);
198
+ expect(res.json().code).toBe('slug-conflict');
199
+ });
200
+ });
@@ -0,0 +1,63 @@
1
+ import {AUTH_USER, requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {
4
+ createGiteaConnectionBodySchema,
5
+ createGiteaConnectionResponseSchema,
6
+ } from '@shipfox/api-integration-gitea-dto';
7
+ import {defineRoute, type RouteGroup} from '@shipfox/node-fastify';
8
+ import type {GiteaApiClient} from '#api/client.js';
9
+ import {type ConnectGiteaConnectionInput, handleGiteaConnect} from '#core/connect.js';
10
+ import {giteaConnectionExternalUrl} from '#core/connection-url.js';
11
+ import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
12
+ import {giteaRouteErrorHandler} from './errors.js';
13
+
14
+ export interface CreateGiteaConnectionRoutesOptions {
15
+ gitea: GiteaApiClient;
16
+ getExistingGiteaConnection: (input: {
17
+ org: string;
18
+ }) => Promise<IntegrationConnection<'gitea'> | undefined>;
19
+ connectGiteaConnection: (
20
+ input: ConnectGiteaConnectionInput,
21
+ ) => Promise<IntegrationConnection<'gitea'>>;
22
+ }
23
+
24
+ export function createGiteaConnectionRoutes({
25
+ gitea,
26
+ getExistingGiteaConnection,
27
+ connectGiteaConnection,
28
+ }: CreateGiteaConnectionRoutesOptions): RouteGroup {
29
+ const createConnectionRoute = defineRoute({
30
+ method: 'POST',
31
+ path: '/connections',
32
+ auth: AUTH_USER,
33
+ description: 'Connect a Gitea organization to a workspace.',
34
+ schema: {
35
+ body: createGiteaConnectionBodySchema,
36
+ response: {
37
+ 200: createGiteaConnectionResponseSchema,
38
+ },
39
+ },
40
+ errorHandler: giteaRouteErrorHandler,
41
+ handler: async (request) => {
42
+ const {workspace_id: workspaceId, org} = request.body;
43
+
44
+ requireWorkspaceAccess({request, workspaceId});
45
+ const connection = await handleGiteaConnect({
46
+ gitea,
47
+ workspaceId,
48
+ org,
49
+ getExistingGiteaConnection,
50
+ connectGiteaConnection,
51
+ });
52
+
53
+ return toIntegrationConnectionDto(connection, {
54
+ externalUrl: giteaConnectionExternalUrl(connection.externalAccountId),
55
+ });
56
+ },
57
+ });
58
+
59
+ return {
60
+ prefix: '/integrations/gitea',
61
+ routes: [createConnectionRoute],
62
+ };
63
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ ConnectionSlugConflictError,
3
+ type IntegrationProviderErrorReason,
4
+ } from '@shipfox/api-integration-core-dto';
5
+ import {ClientError} from '@shipfox/node-fastify';
6
+ import {
7
+ GiteaIntegrationProviderError,
8
+ GiteaOrgAlreadyLinkedError,
9
+ GiteaOrganizationNotFoundError,
10
+ } from '#core/errors.js';
11
+
12
+ function providerStatus(reason: IntegrationProviderErrorReason): number {
13
+ if (reason === 'rate-limited') return 429;
14
+ if (reason === 'timeout' || reason === 'provider-unavailable') return 503;
15
+ return 422;
16
+ }
17
+
18
+ export function giteaRouteErrorHandler(error: unknown): never {
19
+ if (error instanceof GiteaOrganizationNotFoundError) {
20
+ throw new ClientError(error.message, 'gitea-organization-not-found', {status: 404});
21
+ }
22
+ if (error instanceof GiteaOrgAlreadyLinkedError) {
23
+ throw new ClientError(error.message, 'gitea-org-already-linked', {status: 409});
24
+ }
25
+ if (error instanceof ConnectionSlugConflictError) {
26
+ throw new ClientError(error.message, 'slug-conflict', {status: 409});
27
+ }
28
+ if (error instanceof GiteaIntegrationProviderError) {
29
+ throw new ClientError(error.message, error.reason, {
30
+ details: {retry_after_seconds: error.retryAfterSeconds},
31
+ status: providerStatus(error.reason),
32
+ });
33
+ }
34
+ throw error;
35
+ }