@postman-cse/onboarding-bootstrap 0.11.0 → 0.13.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/LICENSE +21 -0
- package/README.md +101 -10
- package/action.yml +22 -2
- package/dist/cli.cjs +34555 -2332
- package/dist/index.cjs +34434 -2290
- package/package.json +23 -4
- package/.github/actionlint.yaml +0 -2
- package/.github/workflows/ci.yml +0 -36
- package/.github/workflows/contract-smoke.yml +0 -155
- package/.github/workflows/release.yml +0 -62
- package/.omc/sessions/b36ac16d-ce26-4e94-ae7e-dd817f2430a3.json +0 -8
- package/CLAUDE.md +0 -57
- package/src/cli.ts +0 -281
- package/src/contracts.ts +0 -172
- package/src/index.ts +0 -1150
- package/src/lib/github/github-api-client.ts +0 -262
- package/src/lib/http-error.ts +0 -80
- package/src/lib/postman/internal-integration-adapter.ts +0 -358
- package/src/lib/postman/postman-assets-client.ts +0 -786
- package/src/lib/postman/workspace-selection.ts +0 -121
- package/src/lib/repo/context.ts +0 -119
- package/src/lib/retry.ts +0 -79
- package/src/lib/secrets.ts +0 -104
- package/tests/bootstrap-action.test.ts +0 -1301
- package/tests/cli.test.ts +0 -153
- package/tests/contract.test.ts +0 -131
- package/tests/github-api-client.test.ts +0 -91
- package/tests/internal-integration-adapter.test.ts +0 -313
- package/tests/postman-assets-client.test.ts +0 -268
- package/tests/retry.test.ts +0 -54
- package/tests/secrets.test.ts +0 -66
- package/tests/workspace-selection.test.ts +0 -301
- package/tmp/cli-run-result.json +0 -11
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -8
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
import { createSecretMasker, type SecretMasker } from '../secrets.js';
|
|
2
|
-
|
|
3
|
-
export type GitHubApiClientAuthMode =
|
|
4
|
-
| 'github_token_first'
|
|
5
|
-
| 'fallback_pat_first'
|
|
6
|
-
| 'app_token';
|
|
7
|
-
|
|
8
|
-
export interface GitHubApiClientOptions {
|
|
9
|
-
apiBase?: string;
|
|
10
|
-
appToken?: string;
|
|
11
|
-
authMode?: GitHubApiClientAuthMode;
|
|
12
|
-
fallbackToken?: string;
|
|
13
|
-
fetch?: typeof fetch;
|
|
14
|
-
repository: string;
|
|
15
|
-
secretMasker?: SecretMasker;
|
|
16
|
-
token: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function buildErrorMessage(
|
|
20
|
-
method: string,
|
|
21
|
-
path: string,
|
|
22
|
-
response: Response,
|
|
23
|
-
body: string,
|
|
24
|
-
masker: SecretMasker
|
|
25
|
-
): string {
|
|
26
|
-
const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`;
|
|
27
|
-
const sanitizedBody = masker(body || '');
|
|
28
|
-
return sanitizedBody
|
|
29
|
-
? masker(`${method} ${path} failed with ${status} - ${sanitizedBody}`)
|
|
30
|
-
: masker(`${method} ${path} failed with ${status} - [REDACTED]`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export class GitHubApiClient {
|
|
34
|
-
private readonly apiBase: string;
|
|
35
|
-
private readonly authMode: GitHubApiClientAuthMode;
|
|
36
|
-
private readonly appToken: string;
|
|
37
|
-
private readonly fallbackToken: string;
|
|
38
|
-
private readonly fetchImpl: typeof fetch;
|
|
39
|
-
private readonly owner: string;
|
|
40
|
-
private readonly repo: string;
|
|
41
|
-
private readonly repository: string;
|
|
42
|
-
private readonly secretMasker: SecretMasker;
|
|
43
|
-
private readonly token: string;
|
|
44
|
-
|
|
45
|
-
constructor(options: GitHubApiClientOptions) {
|
|
46
|
-
this.apiBase = String(options.apiBase || 'https://api.github.com').replace(
|
|
47
|
-
/\/+$/,
|
|
48
|
-
''
|
|
49
|
-
);
|
|
50
|
-
this.appToken = String(options.appToken || '').trim();
|
|
51
|
-
this.authMode = options.authMode || 'github_token_first';
|
|
52
|
-
this.fallbackToken = String(options.fallbackToken || '').trim();
|
|
53
|
-
this.fetchImpl = options.fetch ?? fetch;
|
|
54
|
-
this.repository = options.repository;
|
|
55
|
-
const [owner, repo] = options.repository.split('/');
|
|
56
|
-
this.owner = owner;
|
|
57
|
-
this.repo = repo;
|
|
58
|
-
this.token = String(options.token || '').trim();
|
|
59
|
-
this.secretMasker =
|
|
60
|
-
options.secretMasker ??
|
|
61
|
-
createSecretMasker([this.token, this.fallbackToken, this.appToken]);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
getTokenOrder(): string[] {
|
|
65
|
-
const ordered: string[] = [];
|
|
66
|
-
|
|
67
|
-
if (this.authMode === 'app_token') {
|
|
68
|
-
if (this.appToken) ordered.push(this.appToken);
|
|
69
|
-
if (this.token && this.token !== this.appToken) ordered.push(this.token);
|
|
70
|
-
if (
|
|
71
|
-
this.fallbackToken &&
|
|
72
|
-
this.fallbackToken !== this.appToken &&
|
|
73
|
-
this.fallbackToken !== this.token
|
|
74
|
-
) {
|
|
75
|
-
ordered.push(this.fallbackToken);
|
|
76
|
-
}
|
|
77
|
-
return ordered;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (this.authMode === 'fallback_pat_first') {
|
|
81
|
-
if (this.fallbackToken) ordered.push(this.fallbackToken);
|
|
82
|
-
if (this.token && this.token !== this.fallbackToken) ordered.push(this.token);
|
|
83
|
-
return ordered;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (this.token) ordered.push(this.token);
|
|
87
|
-
if (this.fallbackToken && this.fallbackToken !== this.token) {
|
|
88
|
-
ordered.push(this.fallbackToken);
|
|
89
|
-
}
|
|
90
|
-
return ordered;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
private isVariablesEndpoint(path: string): boolean {
|
|
94
|
-
return path.startsWith(`/repos/${this.owner}/${this.repo}/actions/variables`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
private canUseFallback(path: string): boolean {
|
|
98
|
-
return (
|
|
99
|
-
this.isVariablesEndpoint(path) ||
|
|
100
|
-
path.includes(`/repos/${this.owner}/${this.repo}/contents`) ||
|
|
101
|
-
path.includes('/dispatches')
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
private rateLimitDelayMs(response: Response, attempt: number): number {
|
|
106
|
-
const retryAfter = Number(response.headers.get('retry-after') || '');
|
|
107
|
-
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
108
|
-
return Math.min(retryAfter * 1000, 120_000);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const resetAtSeconds = Number(response.headers.get('x-ratelimit-reset') || '');
|
|
112
|
-
if (Number.isFinite(resetAtSeconds) && resetAtSeconds > 0) {
|
|
113
|
-
const delta = resetAtSeconds * 1000 - Date.now();
|
|
114
|
-
if (delta > 0) {
|
|
115
|
-
return Math.min(delta + 250, 120_000);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const base = Math.min(5000 * Math.pow(2, attempt), 120_000);
|
|
120
|
-
const jitter = Math.floor(Math.random() * 250);
|
|
121
|
-
return Math.min(base + jitter, 120_000);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private async requestWithToken(
|
|
125
|
-
path: string,
|
|
126
|
-
init: RequestInit,
|
|
127
|
-
token: string
|
|
128
|
-
): Promise<Response> {
|
|
129
|
-
const MAX_RETRIES = 5;
|
|
130
|
-
const normalizedToken = String(token || '').trim();
|
|
131
|
-
if (!normalizedToken) {
|
|
132
|
-
throw new Error(`Missing GitHub auth token for request ${path}`);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
for (let attempt = 0; ; attempt++) {
|
|
136
|
-
const response = await this.fetchImpl(`${this.apiBase}${path}`, {
|
|
137
|
-
...init,
|
|
138
|
-
headers: {
|
|
139
|
-
Accept: 'application/vnd.github+json',
|
|
140
|
-
Authorization: `Bearer ${normalizedToken}`,
|
|
141
|
-
'Content-Type': 'application/json',
|
|
142
|
-
...(init.headers || {})
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
if (attempt < MAX_RETRIES && (response.status === 403 || response.status === 429)) {
|
|
147
|
-
const body = await response.clone().text().catch(() => '');
|
|
148
|
-
if (isRateLimitedResponse(response, body)) {
|
|
149
|
-
const delay = this.rateLimitDelayMs(response, attempt);
|
|
150
|
-
console.log(
|
|
151
|
-
`GitHub API rate limited, retrying in ${Math.ceil(delay / 1000)}s (attempt ${attempt + 1}/${MAX_RETRIES})...`
|
|
152
|
-
);
|
|
153
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return response;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
private async request(path: string, init: RequestInit = {}): Promise<Response> {
|
|
163
|
-
const orderedTokens = this.getTokenOrder();
|
|
164
|
-
if (orderedTokens.length === 0) {
|
|
165
|
-
throw new Error('No GitHub auth token configured');
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const first = await this.requestWithToken(path, init, orderedTokens[0]);
|
|
169
|
-
|
|
170
|
-
if (orderedTokens.length < 2 || !this.canUseFallback(path)) {
|
|
171
|
-
return first;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// GitHub returns 404 (not 403) when GITHUB_TOKEN lacks permission to
|
|
175
|
-
// read repo variables — this prevents information disclosure but means
|
|
176
|
-
// the action silently treats existing variables as missing. Retry with
|
|
177
|
-
// the fallback PAT for both 403 and variable-GET-404 cases.
|
|
178
|
-
const isVariableGet404 =
|
|
179
|
-
first.status === 404 &&
|
|
180
|
-
(!init.method || init.method === 'GET') &&
|
|
181
|
-
this.isVariablesEndpoint(path);
|
|
182
|
-
|
|
183
|
-
if (first.status !== 403 && !isVariableGet404) {
|
|
184
|
-
return first;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return this.requestWithToken(path, init, orderedTokens[1]);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async setRepositoryVariable(name: string, value: string): Promise<void> {
|
|
191
|
-
if (!value) {
|
|
192
|
-
throw new Error(`Repo variable ${name} is empty`);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const path = `/repos/${this.repository}/actions/variables`;
|
|
196
|
-
const body = JSON.stringify({ name, value: String(value) });
|
|
197
|
-
const createResponse = await this.request(path, {
|
|
198
|
-
method: 'POST',
|
|
199
|
-
body
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
if (createResponse.ok || createResponse.status === 201) {
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (createResponse.status === 409 || createResponse.status === 422) {
|
|
207
|
-
const updatePath = `/repos/${this.repository}/actions/variables/${name}`;
|
|
208
|
-
const updateResponse = await this.request(updatePath, {
|
|
209
|
-
method: 'PATCH',
|
|
210
|
-
body
|
|
211
|
-
});
|
|
212
|
-
if (updateResponse.ok) {
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
const text = await updateResponse.text().catch(() => '');
|
|
216
|
-
throw new Error(
|
|
217
|
-
buildErrorMessage('PATCH', updatePath, updateResponse, text, this.secretMasker)
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const text = await createResponse.text().catch(() => '');
|
|
222
|
-
throw new Error(
|
|
223
|
-
buildErrorMessage('POST', path, createResponse, text, this.secretMasker)
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async getRepositoryVariable(name: string): Promise<string> {
|
|
228
|
-
const path = `/repos/${this.repository}/actions/variables/${name}`;
|
|
229
|
-
const response = await this.request(path, {
|
|
230
|
-
method: 'GET'
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
if (response.status === 404) {
|
|
234
|
-
return '';
|
|
235
|
-
}
|
|
236
|
-
if (!response.ok) {
|
|
237
|
-
const text = await response.text().catch(() => '');
|
|
238
|
-
throw new Error(
|
|
239
|
-
buildErrorMessage('GET', path, response, text, this.secretMasker)
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const data = (await response.json()) as { value?: string };
|
|
244
|
-
return String(data.value || '');
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function isRateLimitedResponse(response: Response, body: string): boolean {
|
|
249
|
-
if (response.status !== 403 && response.status !== 429) return false;
|
|
250
|
-
|
|
251
|
-
const remaining = response.headers.get('x-ratelimit-remaining');
|
|
252
|
-
const retryAfter = response.headers.get('retry-after');
|
|
253
|
-
|
|
254
|
-
if (remaining === '0') return true;
|
|
255
|
-
if (retryAfter) return true;
|
|
256
|
-
|
|
257
|
-
const message = body.toLowerCase();
|
|
258
|
-
if (message.includes('secondary rate limit')) return true;
|
|
259
|
-
if (message.includes('api rate limit exceeded')) return true;
|
|
260
|
-
|
|
261
|
-
return response.status === 429;
|
|
262
|
-
}
|
package/src/lib/http-error.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { redactSecrets, sanitizeHeaders, type HeaderBag } from './secrets.js';
|
|
2
|
-
|
|
3
|
-
export interface HttpErrorInit {
|
|
4
|
-
method: string;
|
|
5
|
-
url: string;
|
|
6
|
-
status: number;
|
|
7
|
-
statusText: string;
|
|
8
|
-
requestHeaders?: HeaderBag;
|
|
9
|
-
responseBody?: string;
|
|
10
|
-
secretValues?: unknown;
|
|
11
|
-
bodyLimit?: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function truncate(value: string, limit: number): string {
|
|
15
|
-
if (value.length <= limit) {
|
|
16
|
-
return value;
|
|
17
|
-
}
|
|
18
|
-
return `${value.slice(0, limit)}...[truncated]`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function buildMessage(init: HttpErrorInit): string {
|
|
22
|
-
const method = String(init.method || 'GET').toUpperCase();
|
|
23
|
-
const status = `${init.status}${init.statusText ? ` ${init.statusText}` : ''}`;
|
|
24
|
-
const url = redactSecrets(init.url, init.secretValues);
|
|
25
|
-
const body = truncate(
|
|
26
|
-
redactSecrets(init.responseBody || '', init.secretValues),
|
|
27
|
-
Math.max(0, init.bodyLimit ?? 800)
|
|
28
|
-
);
|
|
29
|
-
return body ? `${method} ${url} failed: ${status} - ${body}` : `${method} ${url} failed: ${status}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export class HttpError extends Error {
|
|
33
|
-
readonly method: string;
|
|
34
|
-
readonly requestHeaders: HeaderBag | undefined;
|
|
35
|
-
readonly responseBody: string;
|
|
36
|
-
readonly secretValues: unknown;
|
|
37
|
-
readonly status: number;
|
|
38
|
-
readonly statusText: string;
|
|
39
|
-
readonly url: string;
|
|
40
|
-
|
|
41
|
-
constructor(init: HttpErrorInit) {
|
|
42
|
-
super(buildMessage(init));
|
|
43
|
-
this.name = 'HttpError';
|
|
44
|
-
this.method = String(init.method || 'GET').toUpperCase();
|
|
45
|
-
this.requestHeaders = init.requestHeaders;
|
|
46
|
-
this.responseBody = init.responseBody || '';
|
|
47
|
-
this.secretValues = init.secretValues;
|
|
48
|
-
this.status = init.status;
|
|
49
|
-
this.statusText = init.statusText;
|
|
50
|
-
this.url = init.url;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
static async fromResponse(
|
|
54
|
-
response: Response,
|
|
55
|
-
init: Omit<HttpErrorInit, 'responseBody' | 'status' | 'statusText'> & {
|
|
56
|
-
responseBody?: string;
|
|
57
|
-
}
|
|
58
|
-
): Promise<HttpError> {
|
|
59
|
-
const responseBody =
|
|
60
|
-
init.responseBody ?? (await response.text().catch(() => ''));
|
|
61
|
-
return new HttpError({
|
|
62
|
-
...init,
|
|
63
|
-
responseBody,
|
|
64
|
-
status: response.status,
|
|
65
|
-
statusText: response.statusText
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
toJSON() {
|
|
70
|
-
return {
|
|
71
|
-
method: this.method,
|
|
72
|
-
name: this.name,
|
|
73
|
-
requestHeaders: sanitizeHeaders(this.requestHeaders, this.secretValues),
|
|
74
|
-
responseBody: redactSecrets(this.responseBody, this.secretValues),
|
|
75
|
-
status: this.status,
|
|
76
|
-
statusText: this.statusText,
|
|
77
|
-
url: redactSecrets(this.url, this.secretValues)
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
}
|
|
@@ -1,358 +0,0 @@
|
|
|
1
|
-
import { HttpError } from '../http-error.js';
|
|
2
|
-
import { normalizeGitRepoUrl } from './postman-assets-client.js';
|
|
3
|
-
import { createSecretMasker, type SecretMasker } from '../secrets.js';
|
|
4
|
-
|
|
5
|
-
export type InternalIntegrationBackend = 'bifrost';
|
|
6
|
-
|
|
7
|
-
export interface GovernanceAssociation {
|
|
8
|
-
envUid: string;
|
|
9
|
-
systemEnvId: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface SpecificationCollectionLink {
|
|
13
|
-
collectionId: string;
|
|
14
|
-
syncOptions?: {
|
|
15
|
-
syncExamples: boolean;
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface InternalIntegrationAdapterOptions {
|
|
20
|
-
accessToken: string;
|
|
21
|
-
backend: string;
|
|
22
|
-
fetchImpl?: typeof fetch;
|
|
23
|
-
secretMasker?: SecretMasker;
|
|
24
|
-
teamId: string;
|
|
25
|
-
workerBaseUrl?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface InternalIntegrationAdapter {
|
|
29
|
-
assignWorkspaceToGovernanceGroup(
|
|
30
|
-
workspaceId: string,
|
|
31
|
-
domain: string,
|
|
32
|
-
mappingJson: string
|
|
33
|
-
): Promise<void>;
|
|
34
|
-
associateSystemEnvironments(
|
|
35
|
-
workspaceId: string,
|
|
36
|
-
associations: GovernanceAssociation[]
|
|
37
|
-
): Promise<void>;
|
|
38
|
-
connectWorkspaceToRepository(
|
|
39
|
-
workspaceId: string,
|
|
40
|
-
repoUrl: string
|
|
41
|
-
): Promise<void>;
|
|
42
|
-
linkCollectionsToSpecification(
|
|
43
|
-
specificationId: string,
|
|
44
|
-
collections: SpecificationCollectionLink[]
|
|
45
|
-
): Promise<void>;
|
|
46
|
-
syncCollection(
|
|
47
|
-
specificationId: string,
|
|
48
|
-
collectionId: string
|
|
49
|
-
): Promise<void>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
class BifrostInternalIntegrationAdapter implements InternalIntegrationAdapter {
|
|
53
|
-
private readonly accessToken: string;
|
|
54
|
-
private readonly fetchImpl: typeof fetch;
|
|
55
|
-
private readonly secretMasker: SecretMasker;
|
|
56
|
-
private readonly teamId: string;
|
|
57
|
-
private readonly workerBaseUrl: string;
|
|
58
|
-
|
|
59
|
-
constructor(options: InternalIntegrationAdapterOptions) {
|
|
60
|
-
this.accessToken = String(options.accessToken || '').trim();
|
|
61
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
62
|
-
this.secretMasker =
|
|
63
|
-
options.secretMasker ?? createSecretMasker([this.accessToken]);
|
|
64
|
-
this.teamId = String(options.teamId || '').trim();
|
|
65
|
-
this.workerBaseUrl = String(
|
|
66
|
-
options.workerBaseUrl ||
|
|
67
|
-
'https://catalog-admin.postman-account2009.workers.dev'
|
|
68
|
-
).replace(/\/+$/, '');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
private async proxyRequest(
|
|
72
|
-
service: string,
|
|
73
|
-
method: string,
|
|
74
|
-
requestPath: string,
|
|
75
|
-
body?: unknown
|
|
76
|
-
): Promise<Response> {
|
|
77
|
-
const url = 'https://bifrost-premium-https-v4.gw.postman.com/ws/proxy';
|
|
78
|
-
const headers: Record<string, string> = {
|
|
79
|
-
'Content-Type': 'application/json',
|
|
80
|
-
'x-access-token': this.accessToken
|
|
81
|
-
};
|
|
82
|
-
if (this.teamId) {
|
|
83
|
-
headers['x-entity-team-id'] = this.teamId;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return this.fetchImpl(url, {
|
|
87
|
-
method: 'POST',
|
|
88
|
-
headers,
|
|
89
|
-
body: JSON.stringify({
|
|
90
|
-
service,
|
|
91
|
-
method,
|
|
92
|
-
path: requestPath,
|
|
93
|
-
...(body !== undefined ? { body } : {})
|
|
94
|
-
})
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async assignWorkspaceToGovernanceGroup(
|
|
99
|
-
workspaceId: string,
|
|
100
|
-
domain: string,
|
|
101
|
-
mappingJson: string
|
|
102
|
-
): Promise<void> {
|
|
103
|
-
let mapping: Record<string, string>;
|
|
104
|
-
try {
|
|
105
|
-
mapping = JSON.parse(mappingJson || '{}');
|
|
106
|
-
} catch {
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const groupName = String(mapping[domain] || '').trim();
|
|
111
|
-
if (!groupName) {
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const listResponse = await this.fetchImpl(
|
|
116
|
-
'https://gateway.postman.com/configure/workspace-groups',
|
|
117
|
-
{
|
|
118
|
-
headers: {
|
|
119
|
-
'x-access-token': this.accessToken
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
if (!listResponse.ok) {
|
|
125
|
-
throw await HttpError.fromResponse(listResponse, {
|
|
126
|
-
method: 'GET',
|
|
127
|
-
requestHeaders: {
|
|
128
|
-
'x-access-token': this.accessToken
|
|
129
|
-
},
|
|
130
|
-
secretValues: [this.accessToken],
|
|
131
|
-
url: 'https://gateway.postman.com/configure/workspace-groups'
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const groups = (await listResponse.json()) as {
|
|
136
|
-
data?: Array<{ id: string; name: string }>;
|
|
137
|
-
};
|
|
138
|
-
const group = groups.data?.find((entry) => entry.name === groupName);
|
|
139
|
-
if (!group?.id) {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const patchResponse = await this.fetchImpl(
|
|
144
|
-
`https://gateway.postman.com/configure/workspace-groups/${group.id}`,
|
|
145
|
-
{
|
|
146
|
-
method: 'PATCH',
|
|
147
|
-
headers: {
|
|
148
|
-
'Content-Type': 'application/json',
|
|
149
|
-
'x-access-token': this.accessToken
|
|
150
|
-
},
|
|
151
|
-
body: JSON.stringify({
|
|
152
|
-
workspaces: [workspaceId]
|
|
153
|
-
})
|
|
154
|
-
}
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
if (!patchResponse.ok) {
|
|
158
|
-
throw await HttpError.fromResponse(patchResponse, {
|
|
159
|
-
method: 'PATCH',
|
|
160
|
-
requestHeaders: {
|
|
161
|
-
'Content-Type': 'application/json',
|
|
162
|
-
'x-access-token': this.accessToken
|
|
163
|
-
},
|
|
164
|
-
secretValues: [this.accessToken],
|
|
165
|
-
url: `https://gateway.postman.com/configure/workspace-groups/${group.id}`
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async associateSystemEnvironments(
|
|
171
|
-
workspaceId: string,
|
|
172
|
-
associations: GovernanceAssociation[]
|
|
173
|
-
): Promise<void> {
|
|
174
|
-
if (associations.length === 0) {
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const response = await this.fetchImpl(
|
|
179
|
-
`${this.workerBaseUrl}/api/internal/system-envs/associate`,
|
|
180
|
-
{
|
|
181
|
-
method: 'POST',
|
|
182
|
-
headers: {
|
|
183
|
-
Authorization: `Bearer ${this.accessToken}`,
|
|
184
|
-
'Content-Type': 'application/json'
|
|
185
|
-
},
|
|
186
|
-
body: JSON.stringify({
|
|
187
|
-
workspace_id: workspaceId,
|
|
188
|
-
associations: associations.map((entry) => ({
|
|
189
|
-
env_uid: entry.envUid,
|
|
190
|
-
system_env_id: entry.systemEnvId
|
|
191
|
-
}))
|
|
192
|
-
})
|
|
193
|
-
}
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
if (!response.ok) {
|
|
197
|
-
throw await HttpError.fromResponse(response, {
|
|
198
|
-
method: 'POST',
|
|
199
|
-
requestHeaders: {
|
|
200
|
-
Authorization: `Bearer ${this.accessToken}`,
|
|
201
|
-
'Content-Type': 'application/json'
|
|
202
|
-
},
|
|
203
|
-
secretValues: [this.accessToken],
|
|
204
|
-
url: `${this.workerBaseUrl}/api/internal/system-envs/associate`
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async connectWorkspaceToRepository(
|
|
210
|
-
workspaceId: string,
|
|
211
|
-
repoUrl: string
|
|
212
|
-
): Promise<void> {
|
|
213
|
-
const payload = {
|
|
214
|
-
service: 'workspaces',
|
|
215
|
-
method: 'POST',
|
|
216
|
-
path: `/workspaces/${workspaceId}/filesystem`,
|
|
217
|
-
body: {
|
|
218
|
-
path: '/',
|
|
219
|
-
repo: repoUrl,
|
|
220
|
-
versionControl: true
|
|
221
|
-
}
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
const response = await this.proxyRequest(
|
|
225
|
-
payload.service,
|
|
226
|
-
payload.method,
|
|
227
|
-
payload.path,
|
|
228
|
-
payload.body
|
|
229
|
-
);
|
|
230
|
-
|
|
231
|
-
if (response.ok) return;
|
|
232
|
-
|
|
233
|
-
if (response.status === 400) {
|
|
234
|
-
const body = await response.text();
|
|
235
|
-
// Handle both legacy ('invalidParamError' + 'already exists') and
|
|
236
|
-
// current ('projectAlreadyConnected') Bifrost duplicate-link errors.
|
|
237
|
-
const isDuplicate =
|
|
238
|
-
(body.includes('invalidParamError') && body.includes('already exists')) ||
|
|
239
|
-
body.includes('projectAlreadyConnected');
|
|
240
|
-
if (isDuplicate) {
|
|
241
|
-
const linkedUrl = await this.getWorkspaceGitRepoUrl(workspaceId);
|
|
242
|
-
if (normalizeGitRepoUrl(linkedUrl) === normalizeGitRepoUrl(repoUrl)) {
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
throw new Error(
|
|
246
|
-
`Bifrost link already exists for workspace ${workspaceId}, but linked to a different repo`
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
throw await HttpError.fromResponse(response, {
|
|
252
|
-
method: 'POST',
|
|
253
|
-
requestHeaders: {
|
|
254
|
-
'Content-Type': 'application/json',
|
|
255
|
-
'x-access-token': this.accessToken,
|
|
256
|
-
...(this.teamId ? { 'x-entity-team-id': this.teamId } : {})
|
|
257
|
-
},
|
|
258
|
-
secretValues: [this.accessToken],
|
|
259
|
-
url: 'https://bifrost-premium-https-v4.gw.postman.com/ws/proxy'
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
async linkCollectionsToSpecification(
|
|
264
|
-
specificationId: string,
|
|
265
|
-
collections: SpecificationCollectionLink[]
|
|
266
|
-
): Promise<void> {
|
|
267
|
-
if (collections.length === 0) {
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const response = await this.proxyRequest(
|
|
272
|
-
'specification',
|
|
273
|
-
'put',
|
|
274
|
-
`/specifications/${specificationId}/collections`,
|
|
275
|
-
collections.map((collection) => ({
|
|
276
|
-
collectionId: collection.collectionId,
|
|
277
|
-
...(collection.syncOptions ? { syncOptions: collection.syncOptions } : {})
|
|
278
|
-
}))
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
if (response.ok) {
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
throw await HttpError.fromResponse(response, {
|
|
286
|
-
method: 'POST',
|
|
287
|
-
requestHeaders: {
|
|
288
|
-
'Content-Type': 'application/json',
|
|
289
|
-
'x-access-token': this.accessToken,
|
|
290
|
-
...(this.teamId ? { 'x-entity-team-id': this.teamId } : {})
|
|
291
|
-
},
|
|
292
|
-
secretValues: [this.accessToken],
|
|
293
|
-
url: 'https://bifrost-premium-https-v4.gw.postman.com/ws/proxy'
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
async syncCollection(
|
|
298
|
-
specificationId: string,
|
|
299
|
-
collectionId: string
|
|
300
|
-
): Promise<void> {
|
|
301
|
-
const response = await this.proxyRequest(
|
|
302
|
-
'specification',
|
|
303
|
-
'post',
|
|
304
|
-
`/specifications/${specificationId}/collections/${collectionId}/sync`
|
|
305
|
-
);
|
|
306
|
-
|
|
307
|
-
if (response.ok) {
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
throw await HttpError.fromResponse(response, {
|
|
312
|
-
method: 'POST',
|
|
313
|
-
requestHeaders: {
|
|
314
|
-
'Content-Type': 'application/json',
|
|
315
|
-
'x-access-token': this.accessToken,
|
|
316
|
-
...(this.teamId ? { 'x-entity-team-id': this.teamId } : {})
|
|
317
|
-
},
|
|
318
|
-
secretValues: [this.accessToken],
|
|
319
|
-
url: 'https://bifrost-premium-https-v4.gw.postman.com/ws/proxy'
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
private async getWorkspaceGitRepoUrl(workspaceId: string): Promise<string | null> {
|
|
324
|
-
const response = await this.proxyRequest(
|
|
325
|
-
'workspaces',
|
|
326
|
-
'GET',
|
|
327
|
-
`/workspaces/${workspaceId}/filesystem`
|
|
328
|
-
);
|
|
329
|
-
|
|
330
|
-
if (response.status === 404) return null;
|
|
331
|
-
if (!response.ok) return null;
|
|
332
|
-
|
|
333
|
-
const body = await response.text();
|
|
334
|
-
if (!body.trim()) return null;
|
|
335
|
-
|
|
336
|
-
try {
|
|
337
|
-
const data = JSON.parse(body);
|
|
338
|
-
const repo = data?.repo || data?.repository || data?.repoUrl;
|
|
339
|
-
return typeof repo === 'string' ? repo : null;
|
|
340
|
-
} catch {
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
export function createInternalIntegrationAdapter(
|
|
347
|
-
options: InternalIntegrationAdapterOptions
|
|
348
|
-
): InternalIntegrationAdapter {
|
|
349
|
-
if (options.backend !== 'bifrost') {
|
|
350
|
-
const masker =
|
|
351
|
-
options.secretMasker ?? createSecretMasker([options.accessToken]);
|
|
352
|
-
throw new Error(
|
|
353
|
-
masker(`Unsupported integration backend: ${String(options.backend || '')}`)
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
return new BifrostInternalIntegrationAdapter(options);
|
|
358
|
-
}
|