alchemy 0.9.1 → 0.10.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.
@@ -0,0 +1,469 @@
1
+ import type { Context } from "../context";
2
+ import { Resource } from "../resource";
3
+ import { createGitHubClient, verifyGitHubAuth } from "./client";
4
+
5
+ /**
6
+ * Properties for creating or updating a GitHub Repository Environment
7
+ */
8
+ export interface RepositoryEnvironmentProps {
9
+ /**
10
+ * Repository owner (user or organization)
11
+ */
12
+ owner: string;
13
+
14
+ /**
15
+ * Repository name
16
+ */
17
+ repository: string;
18
+
19
+ /**
20
+ * Environment name
21
+ */
22
+ name: string;
23
+
24
+ /**
25
+ * Wait timer before allowing deployments to proceed (in minutes)
26
+ * Must be between 0 and 43200 (30 days)
27
+ * @default 0
28
+ */
29
+ waitTimer?: number;
30
+
31
+ /**
32
+ * Determine whether to prevent self-reviews on pull requests
33
+ * Note: Requires at least one reviewer when enabled
34
+ * @default false
35
+ */
36
+ preventSelfReview?: boolean;
37
+
38
+ /**
39
+ * Determine whether administrators can bypass deployment protection rules
40
+ * @default true
41
+ */
42
+ adminBypass?: boolean;
43
+
44
+ /**
45
+ * Required reviewers for deployments to this environment
46
+ */
47
+ reviewers?: {
48
+ /**
49
+ * GitHub usernames or user IDs that can approve deployments
50
+ * Can be numeric IDs or string usernames (which will be resolved to IDs)
51
+ */
52
+ users?: Array<number | string>;
53
+
54
+ /**
55
+ * GitHub team names or team IDs that can approve deployments
56
+ * Can be numeric IDs or string team names (which will be resolved to IDs)
57
+ * Note: For team names, should be in the format "org/team-name"
58
+ */
59
+ teams?: Array<number | string>;
60
+ };
61
+
62
+ /**
63
+ * Deployment branch policy for the environment
64
+ */
65
+ deploymentBranchPolicy?: {
66
+ /**
67
+ * Whether to restrict deployments to protected branches
68
+ * @default false
69
+ */
70
+ protectedBranches?: boolean;
71
+
72
+ /**
73
+ * Whether to allow custom branch policies
74
+ * When true, specific branch patterns can be specified using createDeploymentBranchPolicy
75
+ * @default false
76
+ */
77
+ customBranchPolicies?: boolean;
78
+ };
79
+
80
+ /**
81
+ * Branch patterns for deployment when customBranchPolicies is true
82
+ * For example: ["main", "releases/*"]
83
+ * @default []
84
+ */
85
+ branchPatterns?: string[];
86
+
87
+ /**
88
+ * Optional GitHub API token (overrides environment variable)
89
+ * If not provided, will use GITHUB_TOKEN environment variable
90
+ * @default process.env.GITHUB_TOKEN
91
+ */
92
+ token?: string;
93
+ }
94
+
95
+ /**
96
+ * Output returned after Repository Environment creation/update
97
+ */
98
+ export interface RepositoryEnvironment
99
+ extends Resource<"github::RepositoryEnvironment">,
100
+ RepositoryEnvironmentProps {
101
+ /**
102
+ * The ID of the resource
103
+ */
104
+ id: string;
105
+
106
+ /**
107
+ * The numeric ID of the environment in GitHub
108
+ */
109
+ environmentId: number;
110
+
111
+ /**
112
+ * Time at which the object was created/updated
113
+ */
114
+ updatedAt: string;
115
+ }
116
+
117
+ /**
118
+ * Resource for managing GitHub repository environments
119
+ *
120
+ * Note: If preventSelfReview is true, at least one reviewer must be specified.
121
+ *
122
+ * Branch policies are efficiently managed with proper diffing:
123
+ * - Only manages branch patterns configured through this resource
124
+ * - Preserves any manually added branch patterns outside this resource
125
+ * - When updating, only modifies patterns that have changed from previous state
126
+ * - Compares against previous resource state, not current environment state
127
+ * - Safely handles policy type changes
128
+ *
129
+ * @example
130
+ * // Create a basic environment with no protection rules
131
+ * const devEnv = await RepositoryEnvironment("dev-environment", {
132
+ * owner: "my-org",
133
+ * repository: "my-repo",
134
+ * name: "development"
135
+ * });
136
+ *
137
+ * @example
138
+ * // Create a production environment with approval requirements
139
+ * const prodEnv = await RepositoryEnvironment("prod-environment", {
140
+ * owner: "my-org",
141
+ * repository: "my-repo",
142
+ * name: "production",
143
+ * waitTimer: 10, // 10 minute delay
144
+ * preventSelfReview: true,
145
+ * reviewers: {
146
+ * teams: ["platform-team"], // team name
147
+ * users: ["security-admin"] // username
148
+ * },
149
+ * deploymentBranchPolicy: {
150
+ * protectedBranches: true,
151
+ * customBranchPolicies: false
152
+ * }
153
+ * });
154
+ *
155
+ * @example
156
+ * // Create an environment with reviewer IDs
157
+ * const stagingEnv = await RepositoryEnvironment("staging-environment", {
158
+ * owner: "my-org",
159
+ * repository: "my-repo",
160
+ * name: "staging",
161
+ * reviewers: {
162
+ * teams: [1234567], // team ID
163
+ * users: [7654321] // user ID
164
+ * },
165
+ * deploymentBranchPolicy: {
166
+ * protectedBranches: false,
167
+ * customBranchPolicies: true
168
+ * },
169
+ * branchPatterns: ["main", "release/*"]
170
+ * });
171
+ */
172
+ export const RepositoryEnvironment = Resource(
173
+ "github::RepositoryEnvironment",
174
+ async function (
175
+ this: Context<RepositoryEnvironment>,
176
+ id: string,
177
+ props: RepositoryEnvironmentProps
178
+ ): Promise<RepositoryEnvironment> {
179
+ // Create authenticated Octokit client
180
+ const octokit = await createGitHubClient({
181
+ token: props.token,
182
+ });
183
+
184
+ // Verify authentication and permissions
185
+ if (!this.quiet) {
186
+ await verifyGitHubAuth(octokit, props.owner, props.repository);
187
+ }
188
+
189
+ if (this.phase === "delete") {
190
+ if (this.output?.id) {
191
+ try {
192
+ // Delete the environment
193
+ await octokit.rest.repos.deleteAnEnvironment({
194
+ owner: props.owner,
195
+ repo: props.repository,
196
+ environment_name: props.name,
197
+ });
198
+ } catch (error: any) {
199
+ // Ignore 404 errors (environment already deleted)
200
+ if (error.status === 404) {
201
+ console.log("Environment doesn't exist, ignoring");
202
+ } else {
203
+ throw error;
204
+ }
205
+ }
206
+ }
207
+
208
+ // Return void (a deleted resource has no content)
209
+ return this.destroy();
210
+ } else {
211
+ try {
212
+ // Check if the environment already exists
213
+ let environmentId: number | undefined = undefined; // Use undefined instead of 0
214
+ try {
215
+ const { data: environments } =
216
+ await octokit.rest.repos.getAllEnvironments({
217
+ owner: props.owner,
218
+ repo: props.repository,
219
+ });
220
+
221
+ const existingEnv = environments.environments?.find(
222
+ (env) => env.name.toLowerCase() === props.name.toLowerCase()
223
+ );
224
+
225
+ if (existingEnv?.id) {
226
+ environmentId = existingEnv.id;
227
+ }
228
+ } catch (error: any) {
229
+ // If it's a 404, the environment doesn't exist, which is fine
230
+ if (error.status !== 404) {
231
+ throw error;
232
+ }
233
+ }
234
+
235
+ // Convert reviewers to API format
236
+ const reviewers: { type: "User" | "Team"; id: number }[] = [];
237
+
238
+ // Process user reviewers
239
+ if (props.reviewers?.users && props.reviewers.users.length > 0) {
240
+ for (const user of props.reviewers.users) {
241
+ if (typeof user === "number") {
242
+ // If a numeric ID is provided, use it directly
243
+ reviewers.push({
244
+ type: "User" as const,
245
+ id: user,
246
+ });
247
+ } else {
248
+ // If a username string is provided, look up the ID
249
+ const { data: userData } = await octokit.rest.users.getByUsername(
250
+ {
251
+ username: user,
252
+ }
253
+ );
254
+
255
+ reviewers.push({
256
+ type: "User" as const,
257
+ id: userData.id,
258
+ });
259
+ }
260
+ }
261
+ }
262
+
263
+ // Process team reviewers
264
+ if (props.reviewers?.teams && props.reviewers.teams.length > 0) {
265
+ for (const team of props.reviewers.teams) {
266
+ if (typeof team === "number") {
267
+ // If a numeric ID is provided, use it directly
268
+ reviewers.push({
269
+ type: "Team" as const,
270
+ id: team,
271
+ });
272
+ } else {
273
+ // If a team name string is provided, look up the ID
274
+ // Check if team name is in the format 'org/team-name'
275
+ const teamParts = team.includes("/")
276
+ ? team.split("/")
277
+ : [props.owner, team];
278
+
279
+ if (teamParts.length !== 2) {
280
+ throw new Error(
281
+ `Invalid team format: ${team}. Expected format: "org/team-slug" or just "team-slug"`
282
+ );
283
+ }
284
+
285
+ const [org, teamSlug] = teamParts;
286
+
287
+ const { data: teamData } = await octokit.rest.teams.getByName({
288
+ org,
289
+ team_slug: teamSlug,
290
+ });
291
+
292
+ reviewers.push({
293
+ type: "Team" as const,
294
+ id: teamData.id,
295
+ });
296
+ }
297
+ }
298
+ }
299
+
300
+ // Create or update the environment
301
+ if (environmentId === undefined) {
302
+ // Create the environment
303
+ const { data: createdEnv } =
304
+ await octokit.rest.repos.createOrUpdateEnvironment({
305
+ owner: props.owner,
306
+ repo: props.repository,
307
+ environment_name: props.name,
308
+ wait_timer: props.waitTimer,
309
+ prevent_self_review: props.preventSelfReview,
310
+ reviewers: reviewers.length > 0 ? reviewers : undefined,
311
+ deployment_branch_policy: props.deploymentBranchPolicy
312
+ ? {
313
+ protected_branches:
314
+ props.deploymentBranchPolicy.protectedBranches ?? false,
315
+ custom_branch_policies:
316
+ props.deploymentBranchPolicy.customBranchPolicies ??
317
+ false,
318
+ }
319
+ : undefined,
320
+ });
321
+
322
+ environmentId = createdEnv.id;
323
+
324
+ // If there are specific branch patterns, set them
325
+ if (
326
+ props.deploymentBranchPolicy?.customBranchPolicies === true &&
327
+ props.branchPatterns &&
328
+ props.branchPatterns.length > 0
329
+ ) {
330
+ // Add branch patterns one by one
331
+ for (const pattern of props.branchPatterns) {
332
+ await octokit.rest.repos.createDeploymentBranchPolicy({
333
+ owner: props.owner,
334
+ repo: props.repository,
335
+ environment_name: props.name,
336
+ name: pattern,
337
+ });
338
+ }
339
+ }
340
+ } else {
341
+ // Update the environment
342
+ await octokit.rest.repos.createOrUpdateEnvironment({
343
+ owner: props.owner,
344
+ repo: props.repository,
345
+ environment_name: props.name,
346
+ wait_timer: props.waitTimer,
347
+ prevent_self_review: props.preventSelfReview,
348
+ reviewers: reviewers.length > 0 ? reviewers : undefined,
349
+ deployment_branch_policy: props.deploymentBranchPolicy
350
+ ? {
351
+ protected_branches:
352
+ props.deploymentBranchPolicy.protectedBranches ?? false,
353
+ custom_branch_policies:
354
+ props.deploymentBranchPolicy.customBranchPolicies ?? false,
355
+ }
356
+ : undefined,
357
+ });
358
+
359
+ // If there are specific branch patterns, update them
360
+ if (props.deploymentBranchPolicy?.customBranchPolicies === true) {
361
+ // Get existing branch policies to understand what's currently configured
362
+ const { data: existingPolicies } =
363
+ await octokit.rest.repos.listDeploymentBranchPolicies({
364
+ owner: props.owner,
365
+ repo: props.repository,
366
+ environment_name: props.name,
367
+ });
368
+
369
+ const existingPatterns = existingPolicies.branch_policies.map(
370
+ (policy) => policy.name!
371
+ );
372
+
373
+ const newPatterns: string[] = props.branchPatterns || [];
374
+
375
+ // For updates, keep track of which branch policies we should manage
376
+ // This set identifies patterns that were previously managed by this resource
377
+ // If we don't have previous props, use an empty set
378
+ const previouslyManagedPatterns: Set<string> = new Set(
379
+ this.phase === "update" &&
380
+ this.props?.deploymentBranchPolicy?.customBranchPolicies === true
381
+ ? this.props.branchPatterns || []
382
+ : []
383
+ );
384
+
385
+ // Patterns to add (in new config but not in existing)
386
+ const patternsToAdd = newPatterns.filter(
387
+ (pattern) => !existingPatterns.includes(pattern)
388
+ );
389
+
390
+ // Patterns to delete (were managed by us previously but not in new config)
391
+ const patternsToDelete = existingPatterns.filter(
392
+ (pattern) =>
393
+ previouslyManagedPatterns.has(pattern) &&
394
+ !newPatterns.includes(pattern)
395
+ );
396
+
397
+ // Delete policies that are no longer needed
398
+ for (const patternToDelete of patternsToDelete) {
399
+ const policy = existingPolicies.branch_policies.find(
400
+ (p) => p.name === patternToDelete
401
+ );
402
+
403
+ if (policy?.id) {
404
+ await octokit.rest.repos.deleteDeploymentBranchPolicy({
405
+ owner: props.owner,
406
+ repo: props.repository,
407
+ environment_name: props.name,
408
+ branch_policy_id: policy.id,
409
+ });
410
+ }
411
+ }
412
+
413
+ // Add new branch patterns
414
+ for (const pattern of patternsToAdd) {
415
+ await octokit.rest.repos.createDeploymentBranchPolicy({
416
+ owner: props.owner,
417
+ repo: props.repository,
418
+ environment_name: props.name,
419
+ name: pattern,
420
+ });
421
+ }
422
+ }
423
+ }
424
+
425
+ // Get the updated environment details
426
+ const { data: env } = await octokit.rest.repos.getEnvironment({
427
+ owner: props.owner,
428
+ repo: props.repository,
429
+ environment_name: props.name,
430
+ });
431
+
432
+ // Return environment details
433
+ return this({
434
+ id: `${props.owner}/${props.repository}/${props.name}`,
435
+ environmentId: environmentId || env.id,
436
+ owner: props.owner,
437
+ repository: props.repository,
438
+ name: props.name,
439
+ waitTimer: props.waitTimer,
440
+ preventSelfReview: props.preventSelfReview,
441
+ adminBypass: props.adminBypass,
442
+ reviewers: props.reviewers,
443
+ deploymentBranchPolicy: props.deploymentBranchPolicy,
444
+ branchPatterns: props.branchPatterns,
445
+ token: props.token,
446
+ updatedAt: new Date().toISOString(),
447
+ });
448
+ } catch (error: any) {
449
+ if (
450
+ error.status === 403 &&
451
+ error.message?.includes("Must have admin rights")
452
+ ) {
453
+ console.error(
454
+ "\n⚠️ Error creating/updating GitHub environment: You must have admin rights to the repository."
455
+ );
456
+ console.error(
457
+ "Make sure your GitHub token has the required permissions (repo scope for private repos).\n"
458
+ );
459
+ } else {
460
+ console.error(
461
+ "Error creating/updating GitHub environment:",
462
+ error.message
463
+ );
464
+ }
465
+ throw error;
466
+ }
467
+ }
468
+ }
469
+ );