1import * as Effect from "effect/Effect";
2import { isResolved } from "../Diff.ts";
3import * as Provider from "../Provider.ts";
4import { Resource } from "../Resource.ts";
5import { gitHubBaseUrlChanged, Octokit, octokitFor } from "./Octokit.ts";
6import type * as GitHub from "./Providers.ts";
7
8export interface LabelProps {
9 /**
10 * Repository owner (user or organization).
11 */
12 owner: string;
13
14 /**
15 * Repository name.
16 */
17 repository: string;
18
19 /**
20 * Label name. The name is the label's identity — changing it replaces the
21 * label.
22 */
23 name: string;
24
25 /**
26 * Label color (6-character hex code without the leading `#`).
27 * @default "ededed"
28 */
29 color?: string;
30
31 /**
32 * Short description of the label.
33 */
34 description?: string;
35
36 /**
37 * Override the GitHub host or API base URL for this resource only (e.g.
38 * `github.example.com` for GitHub Enterprise). Falls back to
39 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
40 * provider. Changing it replaces the resource — the same name on a
41 * different GitHub instance is a different physical resource.
42 */
43 baseUrl?: string;
44}
45
46export interface Label extends Resource<
47 "GitHub.Label",
48 LabelProps,
49 {
50 /**
51 * Numeric label ID.
52 */
53 labelId: number;
54
55 /**
56 * GraphQL node ID of the label.
57 */
58 nodeId: string;
59
60 /**
61 * The label name.
62 */
63 name: string;
64
65 /**
66 * The label color (6-character hex code without the leading `#`).
67 */
68 color: string;
69
70 /**
71 * Label description.
72 */
73 description: string | null;
74
75 /**
76 * URL to view the label in a browser.
77 */
78 url: string;
79
80 /**
81 * Whether this is a default label (created by GitHub).
82 */
83 default: boolean;
84 },
85 never,
86 GitHub.Providers
87> {}
88
89/**
90 * A GitHub repository label.
91 *
92 * `Label` manages repository labels for categorizing issues and pull requests.
93 * Labels are created on first deploy and updated in place on subsequent
94 * deploys when properties change.
95 *
96 * Authentication is resolved via the `GitHubCredentials` service supplied by
97 * `GitHub.providers()` (env, stored PAT, `gh` CLI, or OAuth). The token needs
98 * `repo` scope for private repositories or `public_repo` for public ones.
99 * ### Creating Labels
100 * **Example:** Basic Label
101 * ```typescript
102 * const bug = yield* GitHub.Label("bug", {
103 * owner: "my-org",
104 * repository: "my-repo",
105 * name: "bug",
106 * color: "d73a4a",
107 * description: "Something isn't working",
108 * });
109 * ```
110 *
111 * **Example:** Multiple Labels
112 * ```typescript
113 * yield* GitHub.Label("feature", {
114 * owner: "my-org",
115 * repository: "my-repo",
116 * name: "feature",
117 * color: "a2eeef",
118 * description: "New feature or request",
119 * });
120 *
121 * yield* GitHub.Label("documentation", {
122 * owner: "my-org",
123 * repository: "my-repo",
124 * name: "documentation",
125 * color: "0075ca",
126 * description: "Improvements or additions to documentation",
127 * });
128 * ```
129 *
130 * ### Updating Labels
131 * Deploy with the same logical ID and different properties to update the
132 * existing label in place.
133 *
134 * **Example:** Update Color and Description
135 * ```typescript
136 * yield* GitHub.Label("priority-high", {
137 * owner: "my-org",
138 * repository: "my-repo",
139 * name: "priority: high",
140 * color: "ff0000",
141 * description: "Updated: Critical issues requiring immediate attention",
142 * });
143 * ```
144 *
145 * ### Label Color Codes
146 * GitHub uses 6-character hex codes without the `#` prefix. Common colors:
147 * - `d73a4a` - Red (bugs)
148 * - `0075ca` - Blue (documentation)
149 * - `a2eeef` - Light blue (features)
150 * - `7057ff` - Purple (good first issue)
151 * - `008672` - Green (improvement)
152 * - `e4e669` - Yellow (question)
153 *
154 * **Example:** Standard Label Set
155 * ```typescript
156 * const labels = [
157 * { name: "bug", color: "d73a4a", description: "Something isn't working" },
158 * { name: "enhancement", color: "a2eeef", description: "New feature" },
159 * { name: "documentation", color: "0075ca", description: "Documentation" },
160 * ];
161 *
162 * for (const { name, color, description } of labels) {
163 * yield* GitHub.Label(name, {
164 * owner: "my-org",
165 * repository: "my-repo",
166 * name,
167 * color,
168 * description,
169 * });
170 * }
171 * ```
172 *
173 * ### Replacing on Name Change
174 * Changing the name creates a new label and deletes the old one.
175 *
176 * **Example:** Replace by Changing Name
177 * ```typescript
178 * // First deploy creates "wip"
179 * const label = yield* GitHub.Label("work", {
180 * owner: "my-org",
181 * repository: "my-repo",
182 * name: "wip",
183 * color: "fbca04",
184 * });
185 *
186 * // Later deploy with same logical ID but different name replaces it
187 * const label = yield* GitHub.Label("work", {
188 * owner: "my-org",
189 * repository: "my-repo",
190 * name: "in-progress",
191 * color: "fbca04",
192 * });
193 * ```
194 *
195 * ### Wiring with Other Resources
196 * **Example:** Create Repository with Labels
197 * ```typescript
198 * const repo = yield* GitHub.Repository("api", {
199 * owner: "my-org",
200 * name: "api",
201 * autoInit: true,
202 * });
203 *
204 * yield* GitHub.Label("bug", {
205 * owner: repo.owner!,
206 * repository: repo.name!,
207 * name: "bug",
208 * color: "d73a4a",
209 * });
210 * ```
211 *
212 * @resource
213 * @product Issue
214 */
215export const Label = Resource<Label>("GitHub.Label");
216
217export const LabelProvider = () =>
218 Provider.succeed(Label, {
219 stables: ["labelId", "nodeId"],
220
221 // A label belongs to (host, owner, repository, name) — changing any of
222 // these replaces the resource.
223 diff: Effect.fn(function* ({ news, olds }) {
224 if (!isResolved(news)) return;
225 if (olds === undefined) return;
226 if (
227 news.owner !== olds.owner ||
228 news.repository !== olds.repository ||
229 news.name !== olds.name ||
230 (yield* gitHubBaseUrlChanged(olds, news))
231 ) {
232 return { action: "replace" };
233 }
234 }),
235
236 reconcile: Effect.fn(function* ({ news }) {
237 const octokit = yield* octokitFor(news.baseUrl);
238
239 // Observe — probe for an existing label by name
240 const observed = yield* Effect.tryPromise({
241 try: async () => {
242 try {
243 const { data } = await octokit.rest.issues.getLabel({
244 owner: news.owner,
245 repo: news.repository,
246 name: news.name,
247 });
248 return data;
249 } catch (error: any) {
250 if (error.status === 404) return undefined;
251 throw error;
252 }
253 },
254 catch: (e) => e as Error,
255 });
256
257 // Ensure — when no label exists, create one
258 if (observed === undefined) {
259 const { data } = yield* Effect.tryPromise({
260 try: () =>
261 octokit.rest.issues.createLabel({
262 owner: news.owner,
263 repo: news.repository,
264 name: news.name,
265 color: news.color,
266 description: news.description,
267 }),
268 catch: (e) => e as Error,
269 });
270
271 return attrsOf(data);
272 }
273
274 // Sync — update the existing label if any properties differ
275 const { data } = yield* Effect.tryPromise({
276 try: () =>
277 octokit.rest.issues.updateLabel({
278 owner: news.owner,
279 repo: news.repository,
280 name: news.name,
281 color: news.color,
282 description: news.description,
283 }),
284 catch: (e) => e as Error,
285 });
286
287 return attrsOf(data);
288 }),
289
290 // Enumerate every label across the repositories the token can see —
291 // labels are keyed by {owner, repository, name} with no account-wide
292 // list endpoint, so walk the repos like the Variable provider does.
293 list: Effect.fn(function* () {
294 const octokit = yield* Octokit;
295
296 const repos = yield* Effect.tryPromise({
297 try: () =>
298 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
299 per_page: 100,
300 }),
301 catch: (e) => e as Error,
302 });
303
304 const perRepo = yield* Effect.forEach(
305 repos,
306 (repo) =>
307 Effect.tryPromise({
308 try: async () => {
309 try {
310 const labels = await octokit.paginate(
311 octokit.rest.issues.listLabelsForRepo,
312 {
313 owner: repo.owner.login,
314 repo: repo.name,
315 per_page: 100,
316 },
317 );
318 return labels.map(attrsOf);
319 } catch (error: any) {
320 // Repos where the token lacks label access reject with
321 // 403/404 — skip them rather than failing the whole enumeration.
322 if (error.status === 403 || error.status === 404) {
323 return [];
324 }
325 throw error;
326 }
327 },
328 catch: (e) => e as Error,
329 }),
330 { concurrency: 10 },
331 );
332
333 return perRepo.flat();
334 }),
335
336 delete: Effect.fn(function* ({ olds }) {
337 const octokit = yield* octokitFor(olds.baseUrl);
338
339 yield* Effect.tryPromise({
340 try: async () => {
341 try {
342 await octokit.rest.issues.deleteLabel({
343 owner: olds.owner,
344 repo: olds.repository,
345 name: olds.name,
346 });
347 } catch (error: any) {
348 if (error.status !== 404) {
349 throw error;
350 }
351 }
352 },
353 catch: (e) => e as Error,
354 });
355 }),
356 });
357
358const attrsOf = (data: {
359 id: number;
360 node_id: string;
361 name: string;
362 color: string;
363 description: string | null;
364 url: string;
365 default: boolean;
366}) => ({
367 labelId: data.id,
368 nodeId: data.node_id,
369 name: data.name,
370 color: data.color,
371 description: data.description,
372 url: data.url,
373 default: data.default,
374});