@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,294 @@
|
|
|
1
|
+
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
2
|
+
import {
|
|
3
|
+
backoffActive,
|
|
4
|
+
encodeInstallationTokenEnvelope,
|
|
5
|
+
needsRefresh,
|
|
6
|
+
stillValid,
|
|
7
|
+
TOKEN_REFRESH_MARGIN_MS,
|
|
8
|
+
TOKEN_VALIDITY_BUFFER_MS,
|
|
9
|
+
} from './installation-token-envelope.js';
|
|
10
|
+
import {
|
|
11
|
+
type InstallationTokenLockResult,
|
|
12
|
+
type InstallationTokenSecretStore,
|
|
13
|
+
SharedInstallationTokenCache,
|
|
14
|
+
} from './shared-installation-token-cache.js';
|
|
15
|
+
|
|
16
|
+
const workspaceId = '00000000-0000-4000-8000-000000000001';
|
|
17
|
+
const installationId = 123;
|
|
18
|
+
|
|
19
|
+
function token(tokenValue: string, expiresAt = '2026-06-10T12:00:00.000Z') {
|
|
20
|
+
return {token: tokenValue, expiresAt: new Date(expiresAt)};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createStore(): InstallationTokenSecretStore & {
|
|
24
|
+
values: Map<string, string>;
|
|
25
|
+
failWrites: boolean;
|
|
26
|
+
} {
|
|
27
|
+
const values = new Map<string, string>();
|
|
28
|
+
const store = {
|
|
29
|
+
values,
|
|
30
|
+
failWrites: false,
|
|
31
|
+
read(readWorkspaceId: string, readInstallationId: number) {
|
|
32
|
+
return Promise.resolve(values.get(`${readWorkspaceId}:${readInstallationId}`) ?? null);
|
|
33
|
+
},
|
|
34
|
+
write(
|
|
35
|
+
writeWorkspaceId: string,
|
|
36
|
+
writeInstallationId: number,
|
|
37
|
+
envelope: Parameters<InstallationTokenSecretStore['write']>[2],
|
|
38
|
+
) {
|
|
39
|
+
if (store.failWrites) return Promise.reject(new Error('write failed'));
|
|
40
|
+
values.set(
|
|
41
|
+
`${writeWorkspaceId}:${writeInstallationId}`,
|
|
42
|
+
encodeInstallationTokenEnvelope(envelope),
|
|
43
|
+
);
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
return store;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cache(
|
|
51
|
+
options: {
|
|
52
|
+
store?: InstallationTokenSecretStore | undefined;
|
|
53
|
+
now?: Date | undefined;
|
|
54
|
+
withLock?:
|
|
55
|
+
| (<T>(
|
|
56
|
+
installationId: number,
|
|
57
|
+
fn: () => Promise<T>,
|
|
58
|
+
) => Promise<InstallationTokenLockResult<T>>)
|
|
59
|
+
| undefined;
|
|
60
|
+
resolveWorkspaceId?: ((installationId: number) => Promise<string>) | undefined;
|
|
61
|
+
sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
62
|
+
pollDelaysMs?: number[] | undefined;
|
|
63
|
+
} = {},
|
|
64
|
+
) {
|
|
65
|
+
return new SharedInstallationTokenCache({
|
|
66
|
+
secretStore: options.store ?? createStore(),
|
|
67
|
+
withLock: options.withLock ?? (async (_id, fn) => ({acquired: true, value: await fn()})),
|
|
68
|
+
resolveWorkspaceId: options.resolveWorkspaceId ?? (() => Promise.resolve(workspaceId)),
|
|
69
|
+
now: () => options.now ?? new Date('2026-06-10T11:00:00.000Z'),
|
|
70
|
+
sleep: options.sleep ?? (() => Promise.resolve()),
|
|
71
|
+
pollDelaysMs: options.pollDelaysMs ?? [],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function setEnvelope(
|
|
76
|
+
store: {values: Map<string, string>},
|
|
77
|
+
envelope: Parameters<typeof encodeInstallationTokenEnvelope>[0],
|
|
78
|
+
) {
|
|
79
|
+
store.values.set(`${workspaceId}:${installationId}`, encodeInstallationTokenEnvelope(envelope));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('SharedInstallationTokenCache', () => {
|
|
83
|
+
it('mints once on a cold winner miss and writes the secret envelope', async () => {
|
|
84
|
+
const store = createStore();
|
|
85
|
+
const mint = vi.fn(() => Promise.resolve(token('ghs_new')));
|
|
86
|
+
const shared = cache({store});
|
|
87
|
+
|
|
88
|
+
const result = await shared.getOrMint(installationId, mint);
|
|
89
|
+
|
|
90
|
+
expect(result).toEqual(token('ghs_new'));
|
|
91
|
+
expect(mint).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(store.values.get(`${workspaceId}:${installationId}`)).toContain('ghs_new');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns a warm store hit without minting', async () => {
|
|
96
|
+
const store = createStore();
|
|
97
|
+
setEnvelope(store, token('ghs_cached'));
|
|
98
|
+
const mint = vi.fn(() => Promise.resolve(token('ghs_new')));
|
|
99
|
+
const shared = cache({store});
|
|
100
|
+
|
|
101
|
+
const result = await shared.getOrMint(installationId, mint);
|
|
102
|
+
|
|
103
|
+
expect(result).toEqual(token('ghs_cached'));
|
|
104
|
+
expect(mint).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('serves a still-valid token on a contended refresh path', async () => {
|
|
108
|
+
const store = createStore();
|
|
109
|
+
setEnvelope(store, token('ghs_stale_but_valid', '2026-06-10T11:04:30.000Z'));
|
|
110
|
+
const mint = vi.fn(() => Promise.resolve(token('ghs_new')));
|
|
111
|
+
const shared = cache({
|
|
112
|
+
store,
|
|
113
|
+
withLock: () => Promise.resolve({acquired: false}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const result = await shared.getOrMint(installationId, mint);
|
|
117
|
+
|
|
118
|
+
expect(result).toEqual(token('ghs_stale_but_valid', '2026-06-10T11:04:30.000Z'));
|
|
119
|
+
expect(mint).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('polls for the winner commit on a contended cold miss', async () => {
|
|
123
|
+
const store = createStore();
|
|
124
|
+
const mint = vi.fn(() => Promise.resolve(token('ghs_new')));
|
|
125
|
+
let polls = 0;
|
|
126
|
+
const shared = cache({
|
|
127
|
+
store,
|
|
128
|
+
withLock: () => Promise.resolve({acquired: false}),
|
|
129
|
+
pollDelaysMs: [1, 1],
|
|
130
|
+
sleep: () => {
|
|
131
|
+
polls += 1;
|
|
132
|
+
if (polls === 1) setEnvelope(store, token('ghs_committed'));
|
|
133
|
+
return Promise.resolve();
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const result = await shared.getOrMint(installationId, mint);
|
|
138
|
+
|
|
139
|
+
expect(result).toEqual(token('ghs_committed'));
|
|
140
|
+
expect(mint).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('refreshes a near-expiry token and clears backoff', async () => {
|
|
144
|
+
const store = createStore();
|
|
145
|
+
setEnvelope(store, {
|
|
146
|
+
...token('ghs_old', '2026-06-10T11:04:00.000Z'),
|
|
147
|
+
backoffUntil: new Date('2026-06-10T10:00:00.000Z'),
|
|
148
|
+
backoffReason: 'rate-limited',
|
|
149
|
+
});
|
|
150
|
+
const shared = cache({store});
|
|
151
|
+
|
|
152
|
+
const result = await shared.getOrMint(installationId, () => Promise.resolve(token('ghs_new')));
|
|
153
|
+
|
|
154
|
+
expect(result).toEqual(token('ghs_new'));
|
|
155
|
+
expect(store.values.get(`${workspaceId}:${installationId}`)).not.toContain('backoff');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('records transient backoff and short-circuits the next call with the stored reason', async () => {
|
|
159
|
+
const store = createStore();
|
|
160
|
+
const mint = vi
|
|
161
|
+
.fn()
|
|
162
|
+
.mockRejectedValue(new GithubIntegrationProviderError('rate-limited', 'rate limited', 42));
|
|
163
|
+
const shared = cache({store});
|
|
164
|
+
|
|
165
|
+
await expect(shared.getOrMint(installationId, mint)).rejects.toMatchObject({
|
|
166
|
+
reason: 'rate-limited',
|
|
167
|
+
});
|
|
168
|
+
await expect(shared.getOrMint(installationId, mint)).rejects.toMatchObject({
|
|
169
|
+
reason: 'rate-limited',
|
|
170
|
+
retryAfterSeconds: 42,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(mint).toHaveBeenCalledTimes(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('records terminal backoff without hiding access denied as provider unavailable', async () => {
|
|
177
|
+
const store = createStore();
|
|
178
|
+
const mint = vi
|
|
179
|
+
.fn()
|
|
180
|
+
.mockRejectedValue(new GithubIntegrationProviderError('access-denied', 'denied'));
|
|
181
|
+
const shared = cache({store});
|
|
182
|
+
|
|
183
|
+
await expect(shared.getOrMint(installationId, mint)).rejects.toMatchObject({
|
|
184
|
+
reason: 'access-denied',
|
|
185
|
+
});
|
|
186
|
+
await expect(shared.getOrMint(installationId, mint)).rejects.toMatchObject({
|
|
187
|
+
reason: 'access-denied',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(mint).toHaveBeenCalledTimes(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('serves stale when refresh minting fails while the token is still valid', async () => {
|
|
194
|
+
const store = createStore();
|
|
195
|
+
setEnvelope(store, token('ghs_existing', '2026-06-10T11:04:30.000Z'));
|
|
196
|
+
const shared = cache({store});
|
|
197
|
+
|
|
198
|
+
const result = await shared.getOrMint(installationId, () =>
|
|
199
|
+
Promise.reject(new GithubIntegrationProviderError('provider-unavailable', 'down')),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
expect(result).toEqual(token('ghs_existing', '2026-06-10T11:04:30.000Z'));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('does not serve stale when refresh minting fails with a terminal reason', async () => {
|
|
206
|
+
const store = createStore();
|
|
207
|
+
setEnvelope(store, token('ghs_existing', '2026-06-10T11:04:30.000Z'));
|
|
208
|
+
const shared = cache({store});
|
|
209
|
+
|
|
210
|
+
await expect(
|
|
211
|
+
shared.getOrMint(installationId, () =>
|
|
212
|
+
Promise.reject(new GithubIntegrationProviderError('access-denied', 'denied')),
|
|
213
|
+
),
|
|
214
|
+
).rejects.toMatchObject({reason: 'access-denied'});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('does not serve stale from active terminal backoff on a contended refresh', async () => {
|
|
218
|
+
const store = createStore();
|
|
219
|
+
setEnvelope(store, {
|
|
220
|
+
...token('ghs_existing', '2026-06-10T11:04:30.000Z'),
|
|
221
|
+
backoffUntil: new Date('2026-06-10T11:15:00.000Z'),
|
|
222
|
+
backoffReason: 'installation-not-found',
|
|
223
|
+
});
|
|
224
|
+
const shared = cache({
|
|
225
|
+
store,
|
|
226
|
+
withLock: () => Promise.resolve({acquired: false}),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await expect(
|
|
230
|
+
shared.getOrMint(installationId, () => Promise.resolve(token('ghs_new'))),
|
|
231
|
+
).rejects.toMatchObject({reason: 'installation-not-found'});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('returns a minted token when the cache write fails', async () => {
|
|
235
|
+
const store = createStore();
|
|
236
|
+
store.failWrites = true;
|
|
237
|
+
const shared = cache({store});
|
|
238
|
+
|
|
239
|
+
const result = await shared.getOrMint(installationId, () => Promise.resolve(token('ghs_new')));
|
|
240
|
+
|
|
241
|
+
expect(result).toEqual(token('ghs_new'));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('treats an invalid envelope as a miss and overwrites it', async () => {
|
|
245
|
+
const store = createStore();
|
|
246
|
+
store.values.set(`${workspaceId}:${installationId}`, '{bad json');
|
|
247
|
+
const shared = cache({store});
|
|
248
|
+
|
|
249
|
+
const result = await shared.getOrMint(installationId, () => Promise.resolve(token('ghs_new')));
|
|
250
|
+
|
|
251
|
+
expect(result).toEqual(token('ghs_new'));
|
|
252
|
+
expect(store.values.get(`${workspaceId}:${installationId}`)).toContain('ghs_new');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('surfaces an unresolvable installation as installation-not-found', async () => {
|
|
256
|
+
const shared = cache({
|
|
257
|
+
resolveWorkspaceId: () =>
|
|
258
|
+
Promise.reject(new GithubIntegrationProviderError('installation-not-found', 'missing')),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await expect(
|
|
262
|
+
shared.getOrMint(installationId, () => Promise.resolve(token('ghs_new'))),
|
|
263
|
+
).rejects.toMatchObject({reason: 'installation-not-found'});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('installation token envelope predicates', () => {
|
|
268
|
+
it('uses exact refresh, validity, and backoff boundaries', () => {
|
|
269
|
+
const now = new Date('2026-06-10T11:00:00.000Z');
|
|
270
|
+
|
|
271
|
+
expect(needsRefresh(new Date(now.getTime() + TOKEN_REFRESH_MARGIN_MS), now)).toBe(true);
|
|
272
|
+
expect(needsRefresh(new Date(now.getTime() + TOKEN_REFRESH_MARGIN_MS + 1), now)).toBe(false);
|
|
273
|
+
expect(stillValid(new Date(now.getTime() + TOKEN_VALIDITY_BUFFER_MS), now)).toBe(false);
|
|
274
|
+
expect(stillValid(new Date(now.getTime() + TOKEN_VALIDITY_BUFFER_MS + 1), now)).toBe(true);
|
|
275
|
+
expect(
|
|
276
|
+
backoffActive(
|
|
277
|
+
{
|
|
278
|
+
backoffUntil: now,
|
|
279
|
+
backoffReason: 'provider-unavailable',
|
|
280
|
+
},
|
|
281
|
+
now,
|
|
282
|
+
),
|
|
283
|
+
).toBe(false);
|
|
284
|
+
expect(
|
|
285
|
+
backoffActive(
|
|
286
|
+
{
|
|
287
|
+
backoffUntil: new Date(now.getTime() + 1),
|
|
288
|
+
backoffReason: 'provider-unavailable',
|
|
289
|
+
},
|
|
290
|
+
now,
|
|
291
|
+
),
|
|
292
|
+
).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import {setTimeout as sleepTimeout} from 'node:timers/promises';
|
|
2
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
3
|
+
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
4
|
+
import {
|
|
5
|
+
recordInstallationTokenBackoff,
|
|
6
|
+
recordInstallationTokenLookup,
|
|
7
|
+
recordInstallationTokenMint,
|
|
8
|
+
} from '#metrics/index.js';
|
|
9
|
+
import type {GithubInstallationAccessToken} from './client.js';
|
|
10
|
+
import {
|
|
11
|
+
backoffActive,
|
|
12
|
+
backoffMs,
|
|
13
|
+
classifyMintError,
|
|
14
|
+
type InstallationTokenEnvelope,
|
|
15
|
+
mintErrorClassForReason,
|
|
16
|
+
parseInstallationTokenEnvelope,
|
|
17
|
+
providerErrorFromBackoff,
|
|
18
|
+
stillValid,
|
|
19
|
+
toProviderError,
|
|
20
|
+
usable,
|
|
21
|
+
} from './installation-token-envelope.js';
|
|
22
|
+
|
|
23
|
+
export interface InstallationTokenCache {
|
|
24
|
+
getOrMint(
|
|
25
|
+
installationId: number,
|
|
26
|
+
mint: () => Promise<GithubInstallationAccessToken>,
|
|
27
|
+
): Promise<GithubInstallationAccessToken>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type InstallationTokenLockResult<T> = {acquired: true; value: T} | {acquired: false};
|
|
31
|
+
|
|
32
|
+
export interface InstallationTokenSecretStore {
|
|
33
|
+
read(workspaceId: string, installationId: number): Promise<string | null>;
|
|
34
|
+
write(
|
|
35
|
+
workspaceId: string,
|
|
36
|
+
installationId: number,
|
|
37
|
+
envelope: InstallationTokenEnvelope,
|
|
38
|
+
): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SharedInstallationTokenCacheOptions {
|
|
42
|
+
secretStore: InstallationTokenSecretStore;
|
|
43
|
+
withLock: <T>(
|
|
44
|
+
installationId: number,
|
|
45
|
+
fn: () => Promise<T>,
|
|
46
|
+
) => Promise<InstallationTokenLockResult<T>>;
|
|
47
|
+
resolveWorkspaceId: (installationId: number) => Promise<string>;
|
|
48
|
+
now?: (() => Date) | undefined;
|
|
49
|
+
sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
50
|
+
pollDelaysMs?: number[] | undefined;
|
|
51
|
+
workspaceCacheTtlMs?: number | undefined;
|
|
52
|
+
mintTimeoutMs?: number | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_POLL_DELAYS_MS = [100, 200, 400, 500, 800];
|
|
56
|
+
const DEFAULT_WORKSPACE_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
57
|
+
const DEFAULT_MINT_TIMEOUT_MS = 30 * 1000;
|
|
58
|
+
|
|
59
|
+
export class SharedInstallationTokenCache implements InstallationTokenCache {
|
|
60
|
+
private readonly workspaceIds = new Map<number, {workspaceId: string; expiresAtMs: number}>();
|
|
61
|
+
private readonly now: () => Date;
|
|
62
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
63
|
+
private readonly pollDelaysMs: number[];
|
|
64
|
+
private readonly workspaceCacheTtlMs: number;
|
|
65
|
+
private readonly mintTimeoutMs: number;
|
|
66
|
+
|
|
67
|
+
constructor(private readonly options: SharedInstallationTokenCacheOptions) {
|
|
68
|
+
this.now = options.now ?? (() => new Date());
|
|
69
|
+
this.sleep = options.sleep ?? ((ms) => sleepTimeout(ms).then(() => undefined));
|
|
70
|
+
this.pollDelaysMs = options.pollDelaysMs ?? DEFAULT_POLL_DELAYS_MS;
|
|
71
|
+
this.workspaceCacheTtlMs = options.workspaceCacheTtlMs ?? DEFAULT_WORKSPACE_CACHE_TTL_MS;
|
|
72
|
+
this.mintTimeoutMs = options.mintTimeoutMs ?? DEFAULT_MINT_TIMEOUT_MS;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async getOrMint(
|
|
76
|
+
installationId: number,
|
|
77
|
+
mint: () => Promise<GithubInstallationAccessToken>,
|
|
78
|
+
): Promise<GithubInstallationAccessToken> {
|
|
79
|
+
const workspaceId = await this.resolveWorkspaceId(installationId);
|
|
80
|
+
const envelope = await this.readEnvelope(workspaceId, installationId);
|
|
81
|
+
if (usable(envelope, this.now())) {
|
|
82
|
+
recordInstallationTokenLookup('db-hit');
|
|
83
|
+
return tokenFromEnvelope(envelope);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = await this.options.withLock(installationId, () =>
|
|
87
|
+
this.mintUnderLock({workspaceId, installationId, mint}),
|
|
88
|
+
);
|
|
89
|
+
if (result.acquired) return result.value;
|
|
90
|
+
|
|
91
|
+
return await this.serveStaleOrPoll({workspaceId, installationId, envelope});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async mintUnderLock(params: {
|
|
95
|
+
workspaceId: string;
|
|
96
|
+
installationId: number;
|
|
97
|
+
mint: () => Promise<GithubInstallationAccessToken>;
|
|
98
|
+
}): Promise<GithubInstallationAccessToken> {
|
|
99
|
+
const envelope = await this.readEnvelope(params.workspaceId, params.installationId);
|
|
100
|
+
const now = this.now();
|
|
101
|
+
if (usable(envelope, now)) {
|
|
102
|
+
recordInstallationTokenLookup('db-hit');
|
|
103
|
+
return tokenFromEnvelope(envelope);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (activeBackoff(envelope, now)) {
|
|
107
|
+
if (canServeStale(envelope, now)) {
|
|
108
|
+
recordInstallationTokenLookup('served-stale');
|
|
109
|
+
return tokenFromEnvelope(envelope);
|
|
110
|
+
}
|
|
111
|
+
recordInstallationTokenLookup('backoff');
|
|
112
|
+
throw providerErrorFromBackoff(
|
|
113
|
+
envelope?.backoffReason ?? 'provider-unavailable',
|
|
114
|
+
(envelope?.backoffUntil?.getTime() ?? now.getTime()) - now.getTime(),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let token: GithubInstallationAccessToken;
|
|
119
|
+
try {
|
|
120
|
+
token = await this.recordMint(params.mint);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const providerError = toProviderError(error);
|
|
123
|
+
const classified = classifyMintError(providerError);
|
|
124
|
+
const until = new Date(this.now().getTime() + backoffMs(classified));
|
|
125
|
+
recordInstallationTokenBackoff({reason: classified.reason, class: classified.class});
|
|
126
|
+
|
|
127
|
+
await this.writeEnvelope(params.workspaceId, params.installationId, {
|
|
128
|
+
token: envelope?.token,
|
|
129
|
+
expiresAt: envelope?.expiresAt,
|
|
130
|
+
permissions: envelope?.permissions,
|
|
131
|
+
backoffUntil: until,
|
|
132
|
+
backoffReason: classified.reason,
|
|
133
|
+
}).catch((writeError) => {
|
|
134
|
+
logger().warn(
|
|
135
|
+
{installationId: params.installationId, reason: classified.reason, error: writeError},
|
|
136
|
+
'github installation token backoff write failed',
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
classified.class === 'transient' &&
|
|
142
|
+
envelope?.token &&
|
|
143
|
+
stillValid(envelope.expiresAt, this.now())
|
|
144
|
+
) {
|
|
145
|
+
logger().warn(
|
|
146
|
+
{
|
|
147
|
+
installationId: params.installationId,
|
|
148
|
+
expiresAt: envelope.expiresAt?.toISOString(),
|
|
149
|
+
reason: classified.reason,
|
|
150
|
+
backoffUntil: until.toISOString(),
|
|
151
|
+
},
|
|
152
|
+
'github installation token mint failed; serving stale token',
|
|
153
|
+
);
|
|
154
|
+
recordInstallationTokenLookup('served-stale');
|
|
155
|
+
return tokenFromEnvelope(envelope);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
logger().warn(
|
|
159
|
+
{
|
|
160
|
+
installationId: params.installationId,
|
|
161
|
+
reason: classified.reason,
|
|
162
|
+
backoffUntil: until.toISOString(),
|
|
163
|
+
error: providerError,
|
|
164
|
+
},
|
|
165
|
+
'github installation token mint failed; backoff recorded',
|
|
166
|
+
);
|
|
167
|
+
recordInstallationTokenLookup('backoff');
|
|
168
|
+
throw providerError;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
await this.writeEnvelope(params.workspaceId, params.installationId, {
|
|
173
|
+
token: token.token,
|
|
174
|
+
expiresAt: token.expiresAt,
|
|
175
|
+
permissions: token.permissions,
|
|
176
|
+
});
|
|
177
|
+
} catch (error) {
|
|
178
|
+
logger().warn(
|
|
179
|
+
{installationId: params.installationId, expiresAt: token.expiresAt.toISOString(), error},
|
|
180
|
+
'github installation token cache write failed after mint',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
logger().info(
|
|
185
|
+
{installationId: params.installationId, expiresAt: token.expiresAt.toISOString()},
|
|
186
|
+
'github installation token minted',
|
|
187
|
+
);
|
|
188
|
+
recordInstallationTokenLookup('minted');
|
|
189
|
+
return token;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async serveStaleOrPoll(params: {
|
|
193
|
+
workspaceId: string;
|
|
194
|
+
installationId: number;
|
|
195
|
+
envelope: InstallationTokenEnvelope | undefined;
|
|
196
|
+
}): Promise<GithubInstallationAccessToken> {
|
|
197
|
+
const initialNow = this.now();
|
|
198
|
+
if (canServeStale(params.envelope, initialNow)) {
|
|
199
|
+
recordInstallationTokenLookup('served-stale');
|
|
200
|
+
return tokenFromEnvelope(params.envelope);
|
|
201
|
+
}
|
|
202
|
+
if (activeBackoff(params.envelope, initialNow)) {
|
|
203
|
+
recordInstallationTokenLookup('backoff');
|
|
204
|
+
throw providerErrorFromBackoff(
|
|
205
|
+
params.envelope.backoffReason,
|
|
206
|
+
params.envelope.backoffUntil.getTime() - initialNow.getTime(),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const delayMs of this.pollDelaysMs) {
|
|
211
|
+
await this.sleep(delayMs);
|
|
212
|
+
const envelope = await this.readEnvelope(params.workspaceId, params.installationId);
|
|
213
|
+
const now = this.now();
|
|
214
|
+
if (usable(envelope, now)) {
|
|
215
|
+
recordInstallationTokenLookup('contended-poll');
|
|
216
|
+
return tokenFromEnvelope(envelope);
|
|
217
|
+
}
|
|
218
|
+
if (backoffActive(envelope, now)) {
|
|
219
|
+
recordInstallationTokenLookup('backoff');
|
|
220
|
+
throw providerErrorFromBackoff(
|
|
221
|
+
envelope?.backoffReason ?? 'provider-unavailable',
|
|
222
|
+
(envelope?.backoffUntil?.getTime() ?? now.getTime()) - now.getTime(),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw new GithubIntegrationProviderError(
|
|
228
|
+
'provider-unavailable',
|
|
229
|
+
'GitHub installation token mint is still in progress',
|
|
230
|
+
1,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async recordMint(
|
|
235
|
+
mint: () => Promise<GithubInstallationAccessToken>,
|
|
236
|
+
): Promise<GithubInstallationAccessToken> {
|
|
237
|
+
const startedAt = Date.now();
|
|
238
|
+
try {
|
|
239
|
+
const token = await withTimeout(mint(), this.mintTimeoutMs);
|
|
240
|
+
recordInstallationTokenMint({outcome: 'success', durationMs: Date.now() - startedAt});
|
|
241
|
+
return token;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
recordInstallationTokenMint({outcome: 'failure', durationMs: Date.now() - startedAt});
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async readEnvelope(
|
|
249
|
+
workspaceId: string,
|
|
250
|
+
installationId: number,
|
|
251
|
+
): Promise<InstallationTokenEnvelope | undefined> {
|
|
252
|
+
const raw = await this.options.secretStore.read(workspaceId, installationId);
|
|
253
|
+
if (raw === null) return undefined;
|
|
254
|
+
|
|
255
|
+
const envelope = parseInstallationTokenEnvelope(raw);
|
|
256
|
+
if (envelope === undefined) {
|
|
257
|
+
logger().warn({installationId}, 'github installation token cache envelope failed to decode');
|
|
258
|
+
}
|
|
259
|
+
return envelope;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async writeEnvelope(
|
|
263
|
+
workspaceId: string,
|
|
264
|
+
installationId: number,
|
|
265
|
+
envelope: InstallationTokenEnvelope,
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
await this.options.secretStore.write(workspaceId, installationId, envelope);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private async resolveWorkspaceId(installationId: number): Promise<string> {
|
|
271
|
+
const nowMs = this.now().getTime();
|
|
272
|
+
const cached = this.workspaceIds.get(installationId);
|
|
273
|
+
if (cached && cached.expiresAtMs > nowMs) return cached.workspaceId;
|
|
274
|
+
|
|
275
|
+
const workspaceId = await this.options.resolveWorkspaceId(installationId);
|
|
276
|
+
this.workspaceIds.set(installationId, {
|
|
277
|
+
workspaceId,
|
|
278
|
+
expiresAtMs: nowMs + this.workspaceCacheTtlMs,
|
|
279
|
+
});
|
|
280
|
+
return workspaceId;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
type ActiveBackoffEnvelope = InstallationTokenEnvelope & {
|
|
285
|
+
backoffUntil: Date;
|
|
286
|
+
backoffReason: NonNullable<InstallationTokenEnvelope['backoffReason']>;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
type TokenEnvelope = InstallationTokenEnvelope & {token: string; expiresAt: Date};
|
|
290
|
+
|
|
291
|
+
function activeBackoff(
|
|
292
|
+
envelope: InstallationTokenEnvelope | undefined,
|
|
293
|
+
now: Date,
|
|
294
|
+
): envelope is ActiveBackoffEnvelope {
|
|
295
|
+
return (
|
|
296
|
+
backoffActive(envelope, now) &&
|
|
297
|
+
envelope?.backoffUntil !== undefined &&
|
|
298
|
+
envelope.backoffReason !== undefined
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function canServeStale(
|
|
303
|
+
envelope: InstallationTokenEnvelope | undefined,
|
|
304
|
+
now: Date,
|
|
305
|
+
): envelope is TokenEnvelope {
|
|
306
|
+
const terminalBackoff =
|
|
307
|
+
activeBackoff(envelope, now) && mintErrorClassForReason(envelope.backoffReason) === 'terminal';
|
|
308
|
+
return (
|
|
309
|
+
envelope?.token !== undefined &&
|
|
310
|
+
envelope.expiresAt !== undefined &&
|
|
311
|
+
stillValid(envelope.expiresAt, now) &&
|
|
312
|
+
!terminalBackoff
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function tokenFromEnvelope(envelope: InstallationTokenEnvelope): GithubInstallationAccessToken {
|
|
317
|
+
if (!envelope.token || !envelope.expiresAt) {
|
|
318
|
+
throw new GithubIntegrationProviderError(
|
|
319
|
+
'malformed-provider-response',
|
|
320
|
+
'GitHub installation token cache envelope is missing a token or expiry',
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
token: envelope.token,
|
|
325
|
+
expiresAt: envelope.expiresAt,
|
|
326
|
+
...(envelope.permissions === undefined ? {} : {permissions: envelope.permissions}),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
331
|
+
let timer: NodeJS.Timeout | undefined;
|
|
332
|
+
const timeout = new Promise<T>((_, reject) => {
|
|
333
|
+
timer = setTimeout(() => {
|
|
334
|
+
reject(
|
|
335
|
+
new GithubIntegrationProviderError(
|
|
336
|
+
'timeout',
|
|
337
|
+
'Timed out minting GitHub installation access token',
|
|
338
|
+
),
|
|
339
|
+
);
|
|
340
|
+
}, timeoutMs);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
344
|
+
if (timer) clearTimeout(timer);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {describe, expect, it} from '@shipfox/vitest/vi';
|
|
2
|
+
import {normalizeGithubApiBaseUrl} from './config.js';
|
|
3
|
+
|
|
4
|
+
describe('normalizeGithubApiBaseUrl', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
['https://api.github.com', 'https://api.github.com'],
|
|
7
|
+
['https://api.github.com/', 'https://api.github.com'],
|
|
8
|
+
['https://github.example.test/api/v3///', 'https://github.example.test/api/v3'],
|
|
9
|
+
])('normalizes %s', (baseUrl, expected) => {
|
|
10
|
+
const result = normalizeGithubApiBaseUrl(baseUrl);
|
|
11
|
+
|
|
12
|
+
expect(result).toBe(expected);
|
|
13
|
+
});
|
|
14
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {createConfig, str, url} from '@shipfox/config';
|
|
2
|
+
|
|
3
|
+
const trailingSlashesPattern = /\/+$/u;
|
|
4
|
+
|
|
5
|
+
export const config = createConfig({
|
|
6
|
+
GITHUB_APP_ID: str({
|
|
7
|
+
desc: "Numeric ID of the GitHub App, found on the app's settings page. Required.",
|
|
8
|
+
}),
|
|
9
|
+
GITHUB_APP_PRIVATE_KEY: str({
|
|
10
|
+
desc: 'Private key of the GitHub App in PEM format, used to sign API requests. Newlines may be written as \\n and are restored at runtime. Required.',
|
|
11
|
+
}),
|
|
12
|
+
GITHUB_APP_CLIENT_ID: str({
|
|
13
|
+
desc: 'OAuth client ID of the GitHub App, used for user sign-in. Required.',
|
|
14
|
+
}),
|
|
15
|
+
GITHUB_APP_CLIENT_SECRET: str({
|
|
16
|
+
desc: 'OAuth client secret of the GitHub App. Required.',
|
|
17
|
+
}),
|
|
18
|
+
GITHUB_APP_WEBHOOK_SECRET: str({
|
|
19
|
+
desc: 'Secret used to verify the signature of incoming GitHub webhooks. Must match the value set on the GitHub App. Required.',
|
|
20
|
+
}),
|
|
21
|
+
GITHUB_APP_SLUG: str({
|
|
22
|
+
desc: 'URL slug of the GitHub App, used to build install and callback links. Required.',
|
|
23
|
+
}),
|
|
24
|
+
GITHUB_APP_USERNAME: str({
|
|
25
|
+
desc: 'GitHub App username used as the Git commit author when checkout credentials are persisted. Set this to the app username, such as my-app. The [bot] suffix is added automatically. Leave unset to keep Git author identity unset.',
|
|
26
|
+
default: undefined,
|
|
27
|
+
}),
|
|
28
|
+
GITHUB_API_BASE_URL: url({
|
|
29
|
+
desc: 'Base URL used for GitHub REST API requests. Set this only for GitHub Enterprise Server or a compatible test server.',
|
|
30
|
+
default: 'https://api.github.com',
|
|
31
|
+
}),
|
|
32
|
+
GITHUB_INSTALL_STATE_SECRET: str({
|
|
33
|
+
desc: 'Secret used to sign the state token that protects the GitHub App install flow. Required.',
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export function normalizedGithubPrivateKey(): string {
|
|
38
|
+
return config.GITHUB_APP_PRIVATE_KEY.replaceAll('\\n', '\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeGithubApiBaseUrl(baseUrl: string): string {
|
|
42
|
+
return baseUrl.replace(trailingSlashesPattern, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function normalizedGithubApiBaseUrl(): string {
|
|
46
|
+
return normalizeGithubApiBaseUrl(config.GITHUB_API_BASE_URL);
|
|
47
|
+
}
|