@shipfox/api-integration-github 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 +138 -0
- package/LICENSE +21 -0
- package/dist/api/client.d.ts +90 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +306 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/installation-token-envelope.d.ts +36 -0
- package/dist/api/installation-token-envelope.d.ts.map +1 -0
- package/dist/api/installation-token-envelope.js +123 -0
- package/dist/api/installation-token-envelope.js.map +1 -0
- package/dist/api/installation-token-provider.d.ts +23 -0
- package/dist/api/installation-token-provider.d.ts.map +1 -0
- package/dist/api/installation-token-provider.js +130 -0
- package/dist/api/installation-token-provider.js.map +1 -0
- package/dist/api/shared-installation-token-cache.d.ts +43 -0
- package/dist/api/shared-installation-token-cache.d.ts.map +1 -0
- package/dist/api/shared-installation-token-cache.js +224 -0
- package/dist/api/shared-installation-token-cache.js.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +44 -0
- package/dist/config.js.map +1 -0
- package/dist/core/actions-logs.d.ts +47 -0
- package/dist/core/actions-logs.d.ts.map +1 -0
- package/dist/core/actions-logs.js +134 -0
- package/dist/core/actions-logs.js.map +1 -0
- package/dist/core/agent-tool-selection.d.ts +4 -0
- package/dist/core/agent-tool-selection.d.ts.map +1 -0
- package/dist/core/agent-tool-selection.js +38 -0
- package/dist/core/agent-tool-selection.js.map +1 -0
- package/dist/core/agent-tools.d.ts +50 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +1122 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/errors.d.ts +15 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +22 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/install.d.ts +38 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +64 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/source-control.d.ts +15 -0
- package/dist/core/source-control.d.ts.map +1 -0
- package/dist/core/source-control.js +175 -0
- package/dist/core/source-control.js.map +1 -0
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/state.js +54 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/webhook.d.ts +20 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +216 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +433 -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/installation-token-lock.d.ts +8 -0
- package/dist/db/installation-token-lock.d.ts.map +1 -0
- package/dist/db/installation-token-lock.js +37 -0
- package/dist/db/installation-token-lock.js.map +1 -0
- package/dist/db/installations.d.ts +33 -0
- package/dist/db/installations.d.ts.map +1 -0
- package/dist/db/installations.js +56 -0
- package/dist/db/installations.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/installations.d.ts +218 -0
- package/dist/db/schema/installations.d.ts.map +1 -0
- package/dist/db/schema/installations.js +46 -0
- package/dist/db/schema/installations.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +3 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +14 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +75 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/presentation/dto/integrations.d.ts +15 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +10 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts +11 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.js +57 -0
- package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +5 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +11 -0
- package/dist/presentation/e2eRoutes/index.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 +46 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +13 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +77 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/presentation/routes/webhooks.d.ts +16 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +110 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +17 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +71 -0
- package/src/api/client.test.ts +94 -0
- package/src/api/client.ts +525 -0
- package/src/api/installation-token-envelope.ts +171 -0
- package/src/api/installation-token-provider.test.ts +241 -0
- package/src/api/installation-token-provider.ts +224 -0
- package/src/api/shared-installation-token-cache.test.ts +294 -0
- package/src/api/shared-installation-token-cache.ts +346 -0
- package/src/config.test.ts +14 -0
- package/src/config.ts +47 -0
- package/src/connection-external-url.test.ts +78 -0
- package/src/core/actions-logs.test.ts +156 -0
- package/src/core/actions-logs.ts +186 -0
- package/src/core/agent-tool-selection.test.ts +41 -0
- package/src/core/agent-tool-selection.ts +42 -0
- package/src/core/agent-tools.test.ts +539 -0
- package/src/core/agent-tools.ts +1269 -0
- package/src/core/errors.ts +23 -0
- package/src/core/install.test.ts +238 -0
- package/src/core/install.ts +119 -0
- package/src/core/source-control.test.ts +327 -0
- package/src/core/source-control.ts +223 -0
- package/src/core/state.test.ts +43 -0
- package/src/core/state.ts +85 -0
- package/src/core/webhook.test.ts +522 -0
- package/src/core/webhook.ts +296 -0
- package/src/db/db.ts +18 -0
- package/src/db/installation-token-lock.test.ts +68 -0
- package/src/db/installation-token-lock.ts +39 -0
- package/src/db/installations.test.ts +67 -0
- package/src/db/installations.ts +108 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/installations.ts +46 -0
- package/src/index.ts +124 -0
- package/src/metrics/index.ts +1 -0
- package/src/metrics/instance.ts +82 -0
- package/src/presentation/dto/integrations.ts +8 -0
- package/src/presentation/e2eRoutes/create-connection.ts +70 -0
- package/src/presentation/e2eRoutes/index.test.ts +126 -0
- package/src/presentation/e2eRoutes/index.ts +14 -0
- package/src/presentation/routes/errors.ts +43 -0
- package/src/presentation/routes/install.test.ts +279 -0
- package/src/presentation/routes/install.ts +93 -0
- package/src/presentation/routes/webhooks.test.ts +560 -0
- package/src/presentation/routes/webhooks.ts +115 -0
- package/test/env.ts +16 -0
- package/test/factories/github-installation.ts +35 -0
- package/test/factories/index.ts +1 -0
- package/test/fixtures/github-webhook.ts +20 -0
- package/test/globalSetup.ts +14 -0
- package/test/index.ts +2 -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,525 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {MAX_REPOSITORY_FILE_BYTES} from '@shipfox/api-integration-core-dto';
|
|
3
|
+
import ky, {HTTPError, TimeoutError} from 'ky';
|
|
4
|
+
import {App, Octokit, RequestError} from 'octokit';
|
|
5
|
+
import {config, normalizedGithubApiBaseUrl, normalizedGithubPrivateKey} from '#config.js';
|
|
6
|
+
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
7
|
+
|
|
8
|
+
const NEXT_PAGE_RE = /[?&]page=(\d+)/;
|
|
9
|
+
const TRAILING_SLASHES_RE = /\/+$/;
|
|
10
|
+
const MAX_TREE_WALK_DEPTH = 10;
|
|
11
|
+
|
|
12
|
+
export interface GithubAccount {
|
|
13
|
+
login: string;
|
|
14
|
+
type: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GithubInstallationDetails {
|
|
18
|
+
id: number;
|
|
19
|
+
account: GithubAccount;
|
|
20
|
+
repositorySelection: string;
|
|
21
|
+
suspendedAt: Date | null;
|
|
22
|
+
htmlUrl: string;
|
|
23
|
+
raw: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GithubRepository {
|
|
27
|
+
id: number;
|
|
28
|
+
ownerLogin: string;
|
|
29
|
+
name: string;
|
|
30
|
+
fullName: string;
|
|
31
|
+
defaultBranch: string;
|
|
32
|
+
private: boolean;
|
|
33
|
+
visibility?: string | undefined;
|
|
34
|
+
cloneUrl: string;
|
|
35
|
+
htmlUrl: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GithubRepositoryPage {
|
|
39
|
+
repositories: GithubRepository[];
|
|
40
|
+
nextCursor: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GithubFileEntry {
|
|
44
|
+
path: string;
|
|
45
|
+
size: number | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GithubFilePage {
|
|
49
|
+
files: GithubFileEntry[];
|
|
50
|
+
nextCursor: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GithubFileContent {
|
|
54
|
+
path: string;
|
|
55
|
+
content: string;
|
|
56
|
+
size: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface GithubUserInstallationPage {
|
|
60
|
+
installationIds: number[];
|
|
61
|
+
nextCursor: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface GithubApiClient {
|
|
65
|
+
exchangeOAuthCode(code: string): Promise<string>;
|
|
66
|
+
listUserInstallations(input: {
|
|
67
|
+
userAccessToken: string;
|
|
68
|
+
cursor?: string | undefined;
|
|
69
|
+
}): Promise<GithubUserInstallationPage>;
|
|
70
|
+
getInstallation(installationId: number): Promise<GithubInstallationDetails>;
|
|
71
|
+
listInstallationRepositories(input: {
|
|
72
|
+
installationId: number;
|
|
73
|
+
limit: number;
|
|
74
|
+
cursor?: string | undefined;
|
|
75
|
+
}): Promise<GithubRepositoryPage>;
|
|
76
|
+
getRepository(input: {installationId: number; repositoryId: number}): Promise<GithubRepository>;
|
|
77
|
+
listRepositoryFiles(input: {
|
|
78
|
+
installationId: number;
|
|
79
|
+
repositoryId: number;
|
|
80
|
+
ref: string;
|
|
81
|
+
prefix: string;
|
|
82
|
+
limit: number;
|
|
83
|
+
cursor?: string | undefined;
|
|
84
|
+
}): Promise<GithubFilePage>;
|
|
85
|
+
fetchRepositoryFile(input: {
|
|
86
|
+
installationId: number;
|
|
87
|
+
repositoryId: number;
|
|
88
|
+
ref: string;
|
|
89
|
+
path: string;
|
|
90
|
+
}): Promise<GithubFileContent>;
|
|
91
|
+
createInstallationAccessToken(input: {
|
|
92
|
+
installationId: number;
|
|
93
|
+
repositoryId: number;
|
|
94
|
+
permissions?: {contents: 'read' | 'write'} | undefined;
|
|
95
|
+
}): Promise<GithubInstallationAccessToken>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface GithubInstallationAccessToken {
|
|
99
|
+
token: string;
|
|
100
|
+
expiresAt: Date;
|
|
101
|
+
permissions?: Record<string, 'read' | 'write' | 'admin'> | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createGithubApiClient(): GithubApiClient {
|
|
105
|
+
return new OctokitGithubApiClient();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class OctokitGithubApiClient implements GithubApiClient {
|
|
109
|
+
private app: App | undefined;
|
|
110
|
+
|
|
111
|
+
async exchangeOAuthCode(code: string): Promise<string> {
|
|
112
|
+
const body = await mapGithubOAuthError(() =>
|
|
113
|
+
ky
|
|
114
|
+
.post('https://github.com/login/oauth/access_token', {
|
|
115
|
+
headers: {accept: 'application/json'},
|
|
116
|
+
json: {
|
|
117
|
+
client_id: config.GITHUB_APP_CLIENT_ID,
|
|
118
|
+
client_secret: config.GITHUB_APP_CLIENT_SECRET,
|
|
119
|
+
code,
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
.json<{access_token?: unknown}>(),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (typeof body.access_token !== 'string') {
|
|
126
|
+
throw new GithubIntegrationProviderError(
|
|
127
|
+
'malformed-provider-response',
|
|
128
|
+
'GitHub OAuth response did not include an access token',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return body.access_token;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async listUserInstallations(input: {
|
|
135
|
+
userAccessToken: string;
|
|
136
|
+
cursor?: string | undefined;
|
|
137
|
+
}): Promise<GithubUserInstallationPage> {
|
|
138
|
+
const octokit = new Octokit({
|
|
139
|
+
auth: input.userAccessToken,
|
|
140
|
+
baseUrl: normalizedGithubApiBaseUrl(),
|
|
141
|
+
});
|
|
142
|
+
const page = cursorToPage(input.cursor);
|
|
143
|
+
const response = await mapGithubError(() =>
|
|
144
|
+
octokit.request('GET /user/installations', {
|
|
145
|
+
per_page: 100,
|
|
146
|
+
page,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const installations = response.data.installations ?? [];
|
|
151
|
+
return {
|
|
152
|
+
installationIds: installations.map((installation) => installation.id),
|
|
153
|
+
nextCursor: nextCursorFromLink(response.headers.link),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async getInstallation(installationId: number): Promise<GithubInstallationDetails> {
|
|
158
|
+
const response = await mapGithubError(() =>
|
|
159
|
+
this.getApp().octokit.rest.apps.getInstallation({installation_id: installationId}),
|
|
160
|
+
);
|
|
161
|
+
const data = response.data;
|
|
162
|
+
const account = data.account;
|
|
163
|
+
if (!account) {
|
|
164
|
+
throw new GithubIntegrationProviderError(
|
|
165
|
+
'malformed-provider-response',
|
|
166
|
+
'GitHub installation response did not include an account',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const login = 'login' in account ? account.login : account.slug;
|
|
171
|
+
return {
|
|
172
|
+
id: data.id,
|
|
173
|
+
account: {
|
|
174
|
+
login,
|
|
175
|
+
type: 'type' in account ? account.type : 'Enterprise',
|
|
176
|
+
},
|
|
177
|
+
repositorySelection: data.repository_selection,
|
|
178
|
+
suspendedAt: data.suspended_at ? new Date(data.suspended_at) : null,
|
|
179
|
+
htmlUrl: data.html_url,
|
|
180
|
+
raw: data as unknown as Record<string, unknown>,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async listInstallationRepositories(input: {
|
|
185
|
+
installationId: number;
|
|
186
|
+
limit: number;
|
|
187
|
+
cursor?: string | undefined;
|
|
188
|
+
}): Promise<GithubRepositoryPage> {
|
|
189
|
+
const octokit = await this.getApp().getInstallationOctokit(input.installationId);
|
|
190
|
+
const page = cursorToPage(input.cursor);
|
|
191
|
+
const response = await mapGithubError(() =>
|
|
192
|
+
octokit.rest.apps.listReposAccessibleToInstallation({
|
|
193
|
+
per_page: input.limit,
|
|
194
|
+
page,
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
repositories: response.data.repositories.map(toGithubRepository),
|
|
200
|
+
nextCursor: nextCursorFromLink(response.headers.link),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async getRepository(input: {
|
|
205
|
+
installationId: number;
|
|
206
|
+
repositoryId: number;
|
|
207
|
+
}): Promise<GithubRepository> {
|
|
208
|
+
const octokit = await this.getApp().getInstallationOctokit(input.installationId);
|
|
209
|
+
const response = await mapGithubError(() =>
|
|
210
|
+
octokit.request('GET /repositories/{repository_id}', {
|
|
211
|
+
repository_id: input.repositoryId,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
return toGithubRepository(response.data);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async listRepositoryFiles(input: {
|
|
219
|
+
installationId: number;
|
|
220
|
+
repositoryId: number;
|
|
221
|
+
ref: string;
|
|
222
|
+
prefix: string;
|
|
223
|
+
limit: number;
|
|
224
|
+
cursor?: string | undefined;
|
|
225
|
+
}): Promise<GithubFilePage> {
|
|
226
|
+
const octokit = await this.getApp().getInstallationOctokit(input.installationId);
|
|
227
|
+
const repository = await this.getRepository({
|
|
228
|
+
installationId: input.installationId,
|
|
229
|
+
repositoryId: input.repositoryId,
|
|
230
|
+
});
|
|
231
|
+
const owner = repository.ownerLogin;
|
|
232
|
+
const repo = repository.name;
|
|
233
|
+
const startPath = input.prefix.replace(TRAILING_SLASHES_RE, '');
|
|
234
|
+
const offset = input.cursor ? Number.parseInt(input.cursor, 10) : 0;
|
|
235
|
+
const start = Number.isNaN(offset) || offset < 0 ? 0 : offset;
|
|
236
|
+
const collected: GithubFileEntry[] = [];
|
|
237
|
+
const overflowLimit = start + input.limit + 1;
|
|
238
|
+
|
|
239
|
+
type GetContentData = Awaited<ReturnType<typeof octokit.rest.repos.getContent>>['data'];
|
|
240
|
+
|
|
241
|
+
const walk = async (path: string, depth: number): Promise<void> => {
|
|
242
|
+
if (collected.length >= overflowLimit) return;
|
|
243
|
+
if (depth > MAX_TREE_WALK_DEPTH) return;
|
|
244
|
+
|
|
245
|
+
let data: GetContentData;
|
|
246
|
+
try {
|
|
247
|
+
const response = await mapGithubError(
|
|
248
|
+
() =>
|
|
249
|
+
octokit.rest.repos.getContent({
|
|
250
|
+
owner,
|
|
251
|
+
repo,
|
|
252
|
+
path,
|
|
253
|
+
ref: input.ref,
|
|
254
|
+
}),
|
|
255
|
+
'file-not-found',
|
|
256
|
+
);
|
|
257
|
+
data = response.data;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error instanceof GithubIntegrationProviderError && error.reason === 'file-not-found') {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!Array.isArray(data)) {
|
|
266
|
+
if (data.type === 'file' && data.path) {
|
|
267
|
+
collected.push({path: data.path, size: typeof data.size === 'number' ? data.size : null});
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const entries = [...data].sort((a, b) => (a.path ?? '').localeCompare(b.path ?? ''));
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
if (collected.length >= overflowLimit) return;
|
|
275
|
+
if (!entry.path) continue;
|
|
276
|
+
if (entry.type === 'file') {
|
|
277
|
+
collected.push({
|
|
278
|
+
path: entry.path,
|
|
279
|
+
size: typeof entry.size === 'number' ? entry.size : null,
|
|
280
|
+
});
|
|
281
|
+
} else if (entry.type === 'dir') {
|
|
282
|
+
await walk(entry.path, depth + 1);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
await walk(startPath, 0);
|
|
288
|
+
|
|
289
|
+
const sorted = collected.sort((a, b) => a.path.localeCompare(b.path));
|
|
290
|
+
const page = sorted.slice(start, start + input.limit);
|
|
291
|
+
const consumed = start + page.length;
|
|
292
|
+
const hasMore = consumed < sorted.length;
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
files: page,
|
|
296
|
+
nextCursor: hasMore ? String(consumed) : null,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async fetchRepositoryFile(input: {
|
|
301
|
+
installationId: number;
|
|
302
|
+
repositoryId: number;
|
|
303
|
+
ref: string;
|
|
304
|
+
path: string;
|
|
305
|
+
}): Promise<GithubFileContent> {
|
|
306
|
+
const octokit = await this.getApp().getInstallationOctokit(input.installationId);
|
|
307
|
+
const repository = await this.getRepository({
|
|
308
|
+
installationId: input.installationId,
|
|
309
|
+
repositoryId: input.repositoryId,
|
|
310
|
+
});
|
|
311
|
+
const response = await mapGithubError(
|
|
312
|
+
() =>
|
|
313
|
+
octokit.rest.repos.getContent({
|
|
314
|
+
owner: repository.ownerLogin,
|
|
315
|
+
repo: repository.name,
|
|
316
|
+
path: input.path,
|
|
317
|
+
ref: input.ref,
|
|
318
|
+
}),
|
|
319
|
+
'file-not-found',
|
|
320
|
+
);
|
|
321
|
+
const data = response.data;
|
|
322
|
+
|
|
323
|
+
if (Array.isArray(data) || data.type !== 'file') {
|
|
324
|
+
throw new GithubIntegrationProviderError('file-not-found', 'GitHub path is not a file');
|
|
325
|
+
}
|
|
326
|
+
if (data.size > MAX_REPOSITORY_FILE_BYTES) {
|
|
327
|
+
throw new GithubIntegrationProviderError(
|
|
328
|
+
'content-too-large',
|
|
329
|
+
'GitHub file content is larger than the supported limit',
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (typeof data.content !== 'string' || data.encoding !== 'base64') {
|
|
333
|
+
throw new GithubIntegrationProviderError(
|
|
334
|
+
'malformed-provider-response',
|
|
335
|
+
'GitHub file response did not include base64 content',
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
path: data.path,
|
|
341
|
+
size: data.size,
|
|
342
|
+
content: Buffer.from(data.content, 'base64').toString('utf8'),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async createInstallationAccessToken(input: {
|
|
347
|
+
installationId: number;
|
|
348
|
+
repositoryId: number;
|
|
349
|
+
permissions?: {contents: 'read' | 'write'} | undefined;
|
|
350
|
+
}): Promise<GithubInstallationAccessToken> {
|
|
351
|
+
const response = await mapGithubError(() =>
|
|
352
|
+
this.getApp().octokit.rest.apps.createInstallationAccessToken({
|
|
353
|
+
installation_id: input.installationId,
|
|
354
|
+
repository_ids: [input.repositoryId],
|
|
355
|
+
permissions: input.permissions ?? {contents: 'read'},
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
if (typeof response.data.token !== 'string') {
|
|
360
|
+
throw new GithubIntegrationProviderError(
|
|
361
|
+
'malformed-provider-response',
|
|
362
|
+
'GitHub installation access token response did not include a token',
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const expiresAt = new Date(response.data.expires_at);
|
|
367
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
368
|
+
throw new GithubIntegrationProviderError(
|
|
369
|
+
'malformed-provider-response',
|
|
370
|
+
'GitHub installation access token response did not include a valid expiry',
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
token: response.data.token,
|
|
376
|
+
expiresAt,
|
|
377
|
+
...(response.data.permissions === undefined ? {} : {permissions: response.data.permissions}),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private getApp(): App {
|
|
382
|
+
if (!this.app) {
|
|
383
|
+
this.app = new App({
|
|
384
|
+
appId: config.GITHUB_APP_ID,
|
|
385
|
+
privateKey: normalizedGithubPrivateKey(),
|
|
386
|
+
Octokit: Octokit.defaults({baseUrl: normalizedGithubApiBaseUrl()}),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return this.app;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function mapGithubOAuthError<T>(operation: () => Promise<T>): Promise<T> {
|
|
394
|
+
try {
|
|
395
|
+
return await operation();
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (error instanceof HTTPError) {
|
|
398
|
+
if (error.response.status === 429) {
|
|
399
|
+
throw new GithubIntegrationProviderError(
|
|
400
|
+
'rate-limited',
|
|
401
|
+
'GitHub OAuth code exchange failed',
|
|
402
|
+
retryAfterSecondsFromHeaders(error.response.headers),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (error.response.status >= 500) {
|
|
406
|
+
throw new GithubIntegrationProviderError(
|
|
407
|
+
'provider-unavailable',
|
|
408
|
+
'GitHub OAuth code exchange failed',
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
throw new GithubIntegrationProviderError(
|
|
412
|
+
'access-denied',
|
|
413
|
+
'GitHub OAuth code exchange failed',
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
if (error instanceof TimeoutError) {
|
|
417
|
+
throw new GithubIntegrationProviderError('timeout', 'GitHub OAuth request timed out');
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function mapGithubError<T>(
|
|
424
|
+
operation: () => Promise<T>,
|
|
425
|
+
notFoundReason:
|
|
426
|
+
| 'repository-not-found'
|
|
427
|
+
| 'installation-not-found'
|
|
428
|
+
| 'file-not-found' = 'repository-not-found',
|
|
429
|
+
): Promise<T> {
|
|
430
|
+
try {
|
|
431
|
+
return await operation();
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (error instanceof GithubIntegrationProviderError) throw error;
|
|
434
|
+
if (error instanceof RequestError) {
|
|
435
|
+
if (error.status === 404) {
|
|
436
|
+
throw new GithubIntegrationProviderError(notFoundReason, error.message);
|
|
437
|
+
}
|
|
438
|
+
if (error.status === 429 || isGithubRateLimitError(error)) {
|
|
439
|
+
throw new GithubIntegrationProviderError(
|
|
440
|
+
'rate-limited',
|
|
441
|
+
error.message,
|
|
442
|
+
retryAfterSeconds(error),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
if (error.status === 401 || error.status === 403) {
|
|
446
|
+
throw new GithubIntegrationProviderError(
|
|
447
|
+
'access-denied',
|
|
448
|
+
error.message,
|
|
449
|
+
retryAfterSeconds(error),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (error.status >= 500) {
|
|
453
|
+
throw new GithubIntegrationProviderError('provider-unavailable', error.message);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
457
|
+
throw new GithubIntegrationProviderError('timeout', 'GitHub request timed out');
|
|
458
|
+
}
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function isGithubRateLimitError(error: RequestError): boolean {
|
|
464
|
+
return error.status === 403 && error.response?.headers['x-ratelimit-remaining'] === '0';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function retryAfterSeconds(error: RequestError): number | undefined {
|
|
468
|
+
return parseRetryAfterSeconds(error.response?.headers['retry-after']);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function retryAfterSecondsFromHeaders(headers: Headers): number | undefined {
|
|
472
|
+
return parseRetryAfterSeconds(headers.get('retry-after'));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function parseRetryAfterSeconds(
|
|
476
|
+
retryAfter: string | number | null | undefined,
|
|
477
|
+
): number | undefined {
|
|
478
|
+
if (!retryAfter) return undefined;
|
|
479
|
+
const parsed = Number.parseInt(String(retryAfter), 10);
|
|
480
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function cursorToPage(cursor: string | undefined): number {
|
|
484
|
+
if (!cursor) return 1;
|
|
485
|
+
const page = Number.parseInt(cursor, 10);
|
|
486
|
+
return Number.isNaN(page) || page < 1 ? 1 : page;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function nextCursorFromLink(link: string | undefined): string | null {
|
|
490
|
+
if (!link) return null;
|
|
491
|
+
const next = link.split(',').find((part) => part.includes('rel="next"'));
|
|
492
|
+
if (!next) return null;
|
|
493
|
+
const match = next.match(NEXT_PAGE_RE);
|
|
494
|
+
return match?.[1] ?? null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function toGithubRepository(repository: {
|
|
498
|
+
id: number;
|
|
499
|
+
owner: {login: string};
|
|
500
|
+
name: string;
|
|
501
|
+
full_name: string;
|
|
502
|
+
default_branch?: string | null | undefined;
|
|
503
|
+
private: boolean;
|
|
504
|
+
visibility?: string | undefined;
|
|
505
|
+
clone_url?: string | null | undefined;
|
|
506
|
+
html_url?: string | null | undefined;
|
|
507
|
+
}): GithubRepository {
|
|
508
|
+
if (!repository.default_branch || !repository.clone_url || !repository.html_url) {
|
|
509
|
+
throw new GithubIntegrationProviderError(
|
|
510
|
+
'malformed-provider-response',
|
|
511
|
+
'GitHub repository response is missing required fields',
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
id: repository.id,
|
|
516
|
+
ownerLogin: repository.owner.login,
|
|
517
|
+
name: repository.name,
|
|
518
|
+
fullName: repository.full_name,
|
|
519
|
+
defaultBranch: repository.default_branch,
|
|
520
|
+
private: repository.private,
|
|
521
|
+
visibility: repository.visibility,
|
|
522
|
+
cloneUrl: repository.clone_url,
|
|
523
|
+
htmlUrl: repository.html_url,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IntegrationProviderError,
|
|
3
|
+
type IntegrationProviderErrorReason,
|
|
4
|
+
} from '@shipfox/api-integration-core-dto';
|
|
5
|
+
import {z} from 'zod';
|
|
6
|
+
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
7
|
+
|
|
8
|
+
export const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
9
|
+
export const TOKEN_VALIDITY_BUFFER_MS = 60 * 1000;
|
|
10
|
+
export const TRANSIENT_BACKOFF_MIN_MS = 30 * 1000;
|
|
11
|
+
export const TRANSIENT_BACKOFF_MAX_MS = 5 * 60 * 1000;
|
|
12
|
+
export const TERMINAL_BACKOFF_MS = 15 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
const providerErrorReasons = [
|
|
15
|
+
'repository-not-found',
|
|
16
|
+
'installation-not-found',
|
|
17
|
+
'file-not-found',
|
|
18
|
+
'access-denied',
|
|
19
|
+
'rate-limited',
|
|
20
|
+
'timeout',
|
|
21
|
+
'provider-unavailable',
|
|
22
|
+
'malformed-provider-response',
|
|
23
|
+
'content-too-large',
|
|
24
|
+
'too-many-files',
|
|
25
|
+
] as const satisfies readonly IntegrationProviderErrorReason[];
|
|
26
|
+
|
|
27
|
+
const providerErrorReasonSchema = z.enum(providerErrorReasons);
|
|
28
|
+
const terminalMintErrorReasons = new Set<IntegrationProviderErrorReason>([
|
|
29
|
+
'access-denied',
|
|
30
|
+
'installation-not-found',
|
|
31
|
+
'malformed-provider-response',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
type MissingProviderErrorReason = Exclude<
|
|
35
|
+
IntegrationProviderErrorReason,
|
|
36
|
+
(typeof providerErrorReasons)[number]
|
|
37
|
+
>;
|
|
38
|
+
const providerErrorReasonSchemaCoversUnion: Record<MissingProviderErrorReason, never> = {};
|
|
39
|
+
void providerErrorReasonSchemaCoversUnion;
|
|
40
|
+
|
|
41
|
+
const installationTokenEnvelopeSchema = z.object({
|
|
42
|
+
token: z.string().min(1).optional(),
|
|
43
|
+
expiresAt: z.string().datetime().optional(),
|
|
44
|
+
permissions: z.record(z.string(), z.enum(['read', 'write', 'admin'])).optional(),
|
|
45
|
+
backoffUntil: z.string().datetime().optional(),
|
|
46
|
+
backoffReason: providerErrorReasonSchema.optional(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export interface InstallationTokenEnvelope {
|
|
50
|
+
token?: string | undefined;
|
|
51
|
+
expiresAt?: Date | undefined;
|
|
52
|
+
permissions?: Record<string, 'read' | 'write' | 'admin'> | undefined;
|
|
53
|
+
backoffUntil?: Date | undefined;
|
|
54
|
+
backoffReason?: IntegrationProviderErrorReason | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type MintErrorClass = 'transient' | 'terminal';
|
|
58
|
+
|
|
59
|
+
export interface ClassifiedMintError {
|
|
60
|
+
class: MintErrorClass;
|
|
61
|
+
reason: IntegrationProviderErrorReason;
|
|
62
|
+
retryAfterSeconds?: number | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function githubInstallationTokenNamespace(installationId: number): string {
|
|
66
|
+
return `system/github/installation-token/${installationId}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function encodeInstallationTokenEnvelope(envelope: InstallationTokenEnvelope): string {
|
|
70
|
+
return JSON.stringify({
|
|
71
|
+
...(envelope.token !== undefined && {token: envelope.token}),
|
|
72
|
+
...(envelope.expiresAt !== undefined && {expiresAt: envelope.expiresAt.toISOString()}),
|
|
73
|
+
...(envelope.permissions !== undefined && {permissions: envelope.permissions}),
|
|
74
|
+
...(envelope.backoffUntil !== undefined && {
|
|
75
|
+
backoffUntil: envelope.backoffUntil.toISOString(),
|
|
76
|
+
}),
|
|
77
|
+
...(envelope.backoffReason !== undefined && {backoffReason: envelope.backoffReason}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseInstallationTokenEnvelope(raw: string): InstallationTokenEnvelope | undefined {
|
|
82
|
+
let parsed: unknown;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const result = installationTokenEnvelopeSchema.safeParse(parsed);
|
|
90
|
+
if (!result.success) return undefined;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
token: result.data.token,
|
|
94
|
+
expiresAt: result.data.expiresAt ? new Date(result.data.expiresAt) : undefined,
|
|
95
|
+
permissions: result.data.permissions,
|
|
96
|
+
backoffUntil: result.data.backoffUntil ? new Date(result.data.backoffUntil) : undefined,
|
|
97
|
+
backoffReason: result.data.backoffReason,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function usable(
|
|
102
|
+
envelope: InstallationTokenEnvelope | undefined,
|
|
103
|
+
now: Date,
|
|
104
|
+
): envelope is InstallationTokenEnvelope & {token: string; expiresAt: Date} {
|
|
105
|
+
return (
|
|
106
|
+
envelope?.token !== undefined &&
|
|
107
|
+
envelope.expiresAt !== undefined &&
|
|
108
|
+
!needsRefresh(envelope.expiresAt, now)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function needsRefresh(expiresAt: Date, now: Date): boolean {
|
|
113
|
+
return expiresAt.getTime() <= now.getTime() + TOKEN_REFRESH_MARGIN_MS;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function stillValid(expiresAt: Date | undefined, now: Date): boolean {
|
|
117
|
+
return expiresAt !== undefined && expiresAt.getTime() > now.getTime() + TOKEN_VALIDITY_BUFFER_MS;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function backoffActive(envelope: InstallationTokenEnvelope | undefined, now: Date): boolean {
|
|
121
|
+
return (
|
|
122
|
+
envelope?.backoffUntil !== undefined &&
|
|
123
|
+
envelope.backoffReason !== undefined &&
|
|
124
|
+
envelope.backoffUntil.getTime() > now.getTime()
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function classifyMintError(error: unknown): ClassifiedMintError {
|
|
129
|
+
if (error instanceof IntegrationProviderError) {
|
|
130
|
+
return {
|
|
131
|
+
reason: error.reason,
|
|
132
|
+
retryAfterSeconds: error.retryAfterSeconds,
|
|
133
|
+
class: mintErrorClassForReason(error.reason),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {reason: 'provider-unavailable', class: 'transient'};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function mintErrorClassForReason(reason: IntegrationProviderErrorReason): MintErrorClass {
|
|
141
|
+
return terminalMintErrorReasons.has(reason) ? 'terminal' : 'transient';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function backoffMs(classified: ClassifiedMintError): number {
|
|
145
|
+
if (classified.class === 'terminal') return TERMINAL_BACKOFF_MS;
|
|
146
|
+
|
|
147
|
+
const retryAfterMs = (classified.retryAfterSeconds ?? 0) * 1000;
|
|
148
|
+
return Math.min(TRANSIENT_BACKOFF_MAX_MS, Math.max(TRANSIENT_BACKOFF_MIN_MS, retryAfterMs));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function providerErrorFromBackoff(
|
|
152
|
+
reason: IntegrationProviderErrorReason,
|
|
153
|
+
retryAfterMs: number,
|
|
154
|
+
): GithubIntegrationProviderError {
|
|
155
|
+
return new GithubIntegrationProviderError(
|
|
156
|
+
reason,
|
|
157
|
+
`GitHub installation token mint is backed off after ${reason}`,
|
|
158
|
+
Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function toProviderError(error: unknown): GithubIntegrationProviderError {
|
|
163
|
+
if (error instanceof GithubIntegrationProviderError) return error;
|
|
164
|
+
if (error instanceof IntegrationProviderError) {
|
|
165
|
+
return new GithubIntegrationProviderError(error.reason, error.message, error.retryAfterSeconds);
|
|
166
|
+
}
|
|
167
|
+
return new GithubIntegrationProviderError(
|
|
168
|
+
'provider-unavailable',
|
|
169
|
+
error instanceof Error ? error.message : 'GitHub installation token mint failed',
|
|
170
|
+
);
|
|
171
|
+
}
|