1import * as codeconnections from "@distilled.cloud/aws/codeconnections";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import { fetchObservedTags, syncResourceTags, toTagList } from "./internal.ts";
12
13export interface RepositoryLinkProps {
14 /**
15 * ARN of the connection to the external provider. The connection must be
16 * in the `AVAILABLE` state (its OAuth handshake completed in the console).
17 */
18 connectionArn: string;
19 /**
20 * Owner ID of the repository — the GitHub organization/user or GitLab
21 * group/user that owns the repository. Changing the owner replaces the
22 * link.
23 */
24 ownerId: string;
25 /**
26 * Name of the repository to link. Changing the repository replaces the
27 * link.
28 */
29 repositoryName: string;
30 /**
31 * ARN of the KMS key used to encrypt the repository link's sync content.
32 */
33 encryptionKeyArn?: string;
34 /**
35 * User-defined tags.
36 */
37 tags?: Record<string, string>;
38}
39
40export interface RepositoryLink extends Resource<
41 "AWS.CodeConnections.RepositoryLink",
42 RepositoryLinkProps,
43 {
44 /** Unique ID of the repository link (used by sync operations). */
45 repositoryLinkId: string;
46 /** ARN of the repository link. */
47 repositoryLinkArn: string;
48 /** ARN of the connection the link authenticates through. */
49 connectionArn: string;
50 /** Owner ID of the linked repository. */
51 ownerId: string;
52 /** Name of the linked repository. */
53 repositoryName: string;
54 /** The source provider (`GitHub`, `GitLab`, ...). */
55 providerType: string;
56 /** ARN of the KMS key encrypting the link's sync content, if any. */
57 encryptionKeyArn: string | undefined;
58 },
59 never,
60 Providers
61> {}
62
63/**
64 * An AWS CodeConnections repository link — associates a connection with a
65 * specific external Git repository so Git sync can monitor and sync changes
66 * (e.g. CloudFormation git sync).
67 *
68 * Requires a connection in the `AVAILABLE` state; the connection's OAuth
69 * handshake is a one-time **manual** console step.
70 * ### Linking a Repository
71 * **Example:** Link a GitHub Repository
72 * ```typescript
73 * const link = yield* CodeConnections.RepositoryLink("Repo", {
74 * connectionArn: connection.connectionArn,
75 * ownerId: "my-github-org",
76 * repositoryName: "my-repo",
77 * });
78 * ```
79 *
80 * **Example:** Encrypted Repository Link
81 * ```typescript
82 * const link = yield* CodeConnections.RepositoryLink("Repo", {
83 * connectionArn: connection.connectionArn,
84 * ownerId: "my-github-org",
85 * repositoryName: "my-repo",
86 * encryptionKeyArn: key.keyArn,
87 * });
88 * ```
89 *
90 * @resource
91 */
92export const RepositoryLink = Resource<RepositoryLink>(
93 "AWS.CodeConnections.RepositoryLink",
94);
95
96export const RepositoryLinkProvider = () =>
97 Provider.effect(
98 RepositoryLink,
99 Effect.gen(function* () {
100 /** Read a repository link by ID; a missing link reads as absent. */
101 const getById = Effect.fn(function* (id: string) {
102 const response = yield* codeconnections
103 .getRepositoryLink({ RepositoryLinkId: id })
104 .pipe(
105 Effect.catchTag("ResourceNotFoundException", () =>
106 Effect.succeed(undefined),
107 ),
108 );
109 return response?.RepositoryLinkInfo;
110 });
111
112 /**
113 * Find a repository link by its identity — owner + repository name
114 * (getRepositoryLink only accepts an ID).
115 */
116 const findByIdentity = Effect.fn(function* (
117 ownerId: string,
118 repositoryName: string,
119 ) {
120 const links = yield* codeconnections.listRepositoryLinks.pages({}).pipe(
121 Stream.runCollect,
122 Effect.map((chunk) =>
123 Array.from(chunk).flatMap((page) => page.RepositoryLinks ?? []),
124 ),
125 );
126 return links.find(
127 (link) =>
128 link.OwnerId === ownerId && link.RepositoryName === repositoryName,
129 );
130 });
131
132 const toAttrs = (link: codeconnections.RepositoryLinkInfo) => ({
133 repositoryLinkId: link.RepositoryLinkId,
134 repositoryLinkArn: link.RepositoryLinkArn,
135 connectionArn: link.ConnectionArn,
136 ownerId: link.OwnerId,
137 repositoryName: link.RepositoryName,
138 providerType: link.ProviderType ?? "",
139 encryptionKeyArn: link.EncryptionKeyArn,
140 });
141
142 return {
143 stables: [
144 "repositoryLinkId",
145 "repositoryLinkArn",
146 "ownerId",
147 "repositoryName",
148 "providerType",
149 ],
150
151 diff: Effect.fn(function* ({ olds, news }) {
152 if (!isResolved(news)) return undefined;
153 // The linked repository is the link's identity — replace on change.
154 // Connection and encryption key are mutable via UpdateRepositoryLink.
155 if (
156 (news?.ownerId ?? undefined) !== (olds?.ownerId ?? undefined) ||
157 (news?.repositoryName ?? undefined) !==
158 (olds?.repositoryName ?? undefined)
159 ) {
160 return { action: "replace" } as const;
161 }
162 }),
163
164 read: Effect.fn(function* ({ id, olds, output }) {
165 const link = output?.repositoryLinkId
166 ? yield* getById(output.repositoryLinkId)
167 : olds?.ownerId && olds?.repositoryName
168 ? yield* findByIdentity(olds.ownerId, olds.repositoryName)
169 : undefined;
170 if (link === undefined) return undefined;
171 const attrs = toAttrs(link);
172 const tags = yield* fetchObservedTags(attrs.repositoryLinkArn);
173 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
174 }),
175
176 reconcile: Effect.fn(function* ({ id, news, output, session }) {
177 const internalTags = yield* createInternalTags(id);
178 const desiredTags = { ...internalTags, ...news.tags };
179
180 // 1. Observe — cloud state is authoritative.
181 let observed = output?.repositoryLinkId
182 ? yield* getById(output.repositoryLinkId)
183 : yield* findByIdentity(news.ownerId, news.repositoryName);
184
185 // 2. Ensure — create if missing; tolerate the AlreadyExists race.
186 if (observed === undefined) {
187 observed = yield* codeconnections
188 .createRepositoryLink({
189 ConnectionArn: news.connectionArn,
190 OwnerId: news.ownerId,
191 RepositoryName: news.repositoryName,
192 EncryptionKeyArn: news.encryptionKeyArn,
193 Tags: toTagList(desiredTags),
194 })
195 .pipe(
196 Effect.map((res) => res.RepositoryLinkInfo),
197 Effect.catchTag("ResourceAlreadyExistsException", (error) =>
198 findByIdentity(news.ownerId, news.repositoryName).pipe(
199 Effect.flatMap((link) =>
200 link === undefined
201 ? // The race lost and the winner is not visible yet —
202 // surface the typed conflict; the engine retries.
203 Effect.fail(error)
204 : Effect.succeed(link),
205 ),
206 ),
207 ),
208 );
209 }
210
211 // 3. Sync — connection + encryption key, diffed against OBSERVED
212 // cloud state; skip the API entirely on no-op.
213 if (
214 observed.ConnectionArn !== news.connectionArn ||
215 (observed.EncryptionKeyArn ?? undefined) !==
216 (news.encryptionKeyArn ?? undefined)
217 ) {
218 const updated = yield* codeconnections.updateRepositoryLink({
219 RepositoryLinkId: observed.RepositoryLinkId,
220 ConnectionArn: news.connectionArn,
221 EncryptionKeyArn: news.encryptionKeyArn,
222 });
223 observed = updated.RepositoryLinkInfo;
224 }
225
226 // 4. Sync tags — diff against OBSERVED cloud tags.
227 yield* syncResourceTags(observed.RepositoryLinkArn, desiredTags);
228
229 yield* session.note(`${news.ownerId}/${news.repositoryName}`);
230 return toAttrs(observed);
231 }),
232
233 delete: Effect.fn(function* ({ output }) {
234 // Sync configurations on the link must be deleted first — retry
235 // the dependency violation while downstream deletions land.
236 yield* codeconnections
237 .deleteRepositoryLink({
238 RepositoryLinkId: output.repositoryLinkId,
239 })
240 .pipe(
241 Effect.retry({
242 while: (e): boolean =>
243 e._tag === "SyncConfigurationStillExistsException",
244 schedule: Schedule.exponential("2 seconds"),
245 times: 8,
246 }),
247 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
248 );
249 }),
250
251 list: () =>
252 codeconnections.listRepositoryLinks.pages({}).pipe(
253 Stream.runCollect,
254 Effect.map((chunk) =>
255 Array.from(chunk)
256 .flatMap((page) => page.RepositoryLinks ?? [])
257 .map((link) => ({
258 repositoryLinkId: link.RepositoryLinkId,
259 repositoryLinkArn: link.RepositoryLinkArn,
260 connectionArn: link.ConnectionArn,
261 ownerId: link.OwnerId,
262 repositoryName: link.RepositoryName,
263 providerType: link.ProviderType ?? "",
264 encryptionKeyArn: link.EncryptionKeyArn,
265 })),
266 ),
267 ),
268 };
269 }),
270 );