1import type { Octokit as GitHubClient } from "@octokit/rest";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../Diff.ts";
4import * as Provider from "../Provider.ts";
5import { Resource } from "../Resource.ts";
6import { dedent } from "../Util/dedent.ts";
7import { gitHubBaseUrlChanged, Octokit, octokitFor } from "./Octokit.ts";
8import type * as GitHub from "./Providers.ts";
9
10export interface PullRequestProps {
11 /**
12 * Repository owner (user or organization).
13 */
14 owner: string;
15
16 /**
17 * Repository name.
18 */
19 repository: string;
20
21 /**
22 * Pull request title.
23 */
24 title: string;
25
26 /**
27 * Pull request body (supports GitHub Markdown).
28 *
29 * The body is automatically dedented, so you can use indented template
30 * literals without worrying about leading whitespace. Accepts
31 * `Output<string>` at the call site via `Output.interpolate` to embed
32 * resource attributes that are not yet resolved.
33 */
34 body?: string;
35
36 /**
37 * The name of the branch where your changes are implemented (the source).
38 */
39 head: string;
40
41 /**
42 * The name of the branch you want the changes pulled into (the target).
43 */
44 base: string;
45
46 /**
47 * State of the pull request. Use "open" to reopen a closed PR or "closed"
48 * to close an open PR. Note: only the PR owner, repo owner, or user with
49 * push access can close PRs.
50 * @default "open"
51 */
52 state?: "open" | "closed";
53
54 /**
55 * Whether the pull request is a draft.
56 * @default false
57 */
58 draft?: boolean;
59
60 /**
61 * Labels to attach to the pull request. The provided list fully replaces
62 * any existing labels.
63 */
64 labels?: string[];
65
66 /**
67 * Assignees (user logins) to assign to the pull request. The provided list
68 * fully replaces existing assignees.
69 */
70 assignees?: string[];
71
72 /**
73 * Reviewers (user logins) to request reviews from. The provided list fully
74 * replaces existing review requests.
75 */
76 reviewers?: string[];
77
78 /**
79 * Team slugs (for organization repos) to request reviews from.
80 */
81 teamReviewers?: string[];
82
83 /**
84 * Milestone number to assign to the pull request. Use `null` to remove
85 * milestone.
86 */
87 milestone?: number | null;
88
89 /**
90 * Whether maintainers of the base repository can modify the pull
91 * request's head branch. This does not change the pull request author.
92 */
93 maintainerCanModify?: boolean;
94
95 /**
96 * Override the GitHub host or API base URL for this resource only (e.g.
97 * `github.example.com` for GitHub Enterprise). Falls back to
98 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
99 * provider. Changing it replaces the resource — the same name on a
100 * different GitHub instance is a different physical resource.
101 */
102 baseUrl?: string;
103}
104
105export interface PullRequest extends Resource<
106 "GitHub.PullRequest",
107 PullRequestProps,
108 {
109 /**
110 * The numeric ID of the pull request in GitHub.
111 */
112 prNumber: number;
113
114 /**
115 * GraphQL node ID of the pull request.
116 */
117 nodeId: string;
118
119 /**
120 * URL to view the pull request in a browser.
121 */
122 htmlUrl: string;
123
124 /**
125 * State of the pull request (open or closed).
126 */
127 state: "open" | "closed";
128
129 /**
130 * Whether the pull request is merged.
131 */
132 merged: boolean;
133
134 /**
135 * Whether the pull request is a draft.
136 */
137 draft: boolean;
138
139 /**
140 * ISO-8601 timestamp of when the pull request was created.
141 */
142 createdAt: string;
143
144 /**
145 * ISO-8601 timestamp of the last update.
146 */
147 updatedAt: string;
148 },
149 never,
150 GitHub.Providers
151> {}
152
153/**
154 * A GitHub pull request.
155 *
156 * `PullRequest` manages the lifecycle of a pull request in a repository. PRs
157 * are created on the first deploy and updated in place on subsequent deploys
158 * when properties change. By default, pull requests are retained on
159 * destruction to preserve history — set `destroy()` to opt in to closure.
160 *
161 * Authentication is resolved via the `GitHubCredentials` service supplied by
162 * `GitHub.providers()` (env, stored PAT, `gh` CLI, or OAuth). The token needs
163 * `repo` scope for private repositories or `public_repo` for public ones.
164 *
165 * ### Creating Pull Requests
166 * **Example:** Create a Basic Pull Request
167 * ```typescript
168 * const pr = yield* GitHub.PullRequest("feature-pr", {
169 * owner: "my-org",
170 * repository: "my-repo",
171 * title: "Add new feature",
172 * body: "## Changes\n\nThis PR adds...",
173 * head: "feature-branch",
174 * base: "main",
175 * })
176 * ```
177 *
178 * **Example:** Draft Pull Request with Reviewers
179 * ```typescript
180 * const pr = yield* GitHub.PullRequest("draft-pr", {
181 * owner: "my-org",
182 * repository: "my-repo",
183 * title: "WIP: New feature",
184 * body: "Work in progress...",
185 * head: "feature-branch",
186 * base: "main",
187 * draft: true,
188 * reviewers: ["reviewer1", "reviewer2"],
189 * teamReviewers: ["platform-team"],
190 * })
191 * ```
192 *
193 * ### Updating Pull Requests
194 * Deploy with the same logical ID and different properties to update the
195 * existing PR in place rather than creating a new one.
196 *
197 * **Example:** Update PR State
198 * ```typescript
199 * const pr = yield* GitHub.PullRequest("completed-pr", {
200 * owner: "my-org",
201 * repository: "my-repo",
202 * title: "Add new feature",
203 * body: "Completed and ready to merge.",
204 * head: "feature-branch",
205 * base: "main",
206 * state: "closed",
207 * })
208 * ```
209 *
210 * ### Pull Request with Labels and Milestone
211 * **Example:** Organized Pull Request
212 * ```typescript
213 * const pr = yield* GitHub.PullRequest("release-pr", {
214 * owner: "my-org",
215 * repository: "my-repo",
216 * title: "Release v1.0",
217 * body: "Release notes...",
218 * head: "release-1.0",
219 * base: "main",
220 * labels: ["release", "v1.0"],
221 * milestone: 1,
222 * })
223 * ```
224 *
225 * ### Infrastructure Deployment PRs
226 * A common pattern is creating PRs for infrastructure changes that require
227 * review before merging.
228 *
229 * **Example:** Infrastructure Change PR
230 * ```typescript
231 * yield* GitHub.PullRequest("infra-update", {
232 * owner: "my-org",
233 * repository: "my-repo",
234 * title: "Update infrastructure configuration",
235 * body: Output.interpolate`
236 * ## Infrastructure Changes
237 *
238 * **Database:** ${database.endpoint}
239 * **Cache:** ${cache.endpoint}
240 *
241 * Requires approval before deployment.
242 * `,
243 * head: "infra-updates",
244 * base: "main",
245 * labels: ["infrastructure"],
246 * reviewers: ["infrastructure-lead"],
247 * })
248 * ```
249 *
250 * @resource
251 * @product Pull Request
252 */
253export const PullRequest = Resource<PullRequest>("GitHub.PullRequest", {
254 defaultRemovalPolicy: "retain",
255});
256
257export const PullRequestProvider = () =>
258 Provider.succeed(PullRequest, {
259 stables: ["prNumber", "nodeId"],
260
261 // A PR belongs to (host, owner, repository, head, base) — changing any
262 // of these replaces the resource: a fresh PR is created with the new
263 // configuration, and the old one is retained by default.
264 diff: Effect.fn(function* ({ news, olds }) {
265 if (!isResolved(news)) return;
266 if (olds === undefined) return;
267 if (
268 news.owner !== olds.owner ||
269 news.repository !== olds.repository ||
270 news.head !== olds.head ||
271 news.base !== olds.base ||
272 (yield* gitHubBaseUrlChanged(olds, news))
273 ) {
274 return { action: "replace" };
275 }
276 }),
277
278 reconcile: Effect.fn(function* ({ news, output }) {
279 const octokit = yield* octokitFor(news.baseUrl);
280 const scope = { owner: news.owner, repo: news.repository };
281 const body = news.body === undefined ? undefined : dedent(news.body);
282 const findOpen = () =>
283 request(() =>
284 octokit.rest.pulls.list({
285 ...scope,
286 head: news.head.includes(":")
287 ? news.head
288 : `${news.owner}:${news.head}`,
289 base: news.base,
290 state: "open",
291 per_page: 100,
292 }),
293 ).pipe(Effect.map(({ data }) => data[0]?.number));
294 let number = output?.prNumber;
295 let observed =
296 number === undefined
297 ? undefined
298 : yield* getPull(octokit, news, number);
299 if (observed === undefined) {
300 number = yield* findOpen();
301 if (number === undefined) {
302 number = yield* request(() =>
303 octokit.rest.pulls.create({
304 ...scope,
305 title: news.title,
306 body,
307 head: news.head,
308 base: news.base,
309 draft: news.draft,
310 maintainer_can_modify: news.maintainerCanModify,
311 }),
312 ).pipe(
313 Effect.map(({ data }) => data.number),
314 Effect.catchIf(
315 (error) => error.status === 422,
316 (error) =>
317 findOpen().pipe(
318 Effect.flatMap((existing) =>
319 existing === undefined
320 ? Effect.fail(error)
321 : Effect.succeed(existing),
322 ),
323 ),
324 ),
325 );
326 }
327 observed = yield* getPull(octokit, news, number);
328 }
329 if (observed === undefined) {
330 return yield* Effect.fail(
331 new Error("Pull request disappeared during reconciliation"),
332 );
333 }
334 const state = news.state ?? "open";
335 const draft = news.draft ?? false;
336 const draftChanged = draft !== (observed.draft ?? false);
337 const syncState = draftChanged ? "open" : state;
338 if (
339 observed.title !== news.title ||
340 (body !== undefined && (observed.body ?? "") !== body) ||
341 observed.state !== syncState ||
342 (news.maintainerCanModify !== undefined &&
343 observed.maintainer_can_modify !== news.maintainerCanModify)
344 ) {
345 yield* request(() =>
346 octokit.rest.pulls.update({
347 ...scope,
348 pull_number: observed.number,
349 title: news.title,
350 body,
351 state: syncState,
352 maintainer_can_modify: news.maintainerCanModify,
353 }),
354 );
355 }
356 // GitHub only permits draft transitions while a pull request is open.
357 if (draftChanged) {
358 const mutation = draft
359 ? "convertPullRequestToDraft"
360 : "markPullRequestReadyForReview";
361 yield* request(() =>
362 octokit.graphql(
363 `mutation($id: ID!) { ${mutation}(input: {pullRequestId: $id}) { pullRequest { id } } }`,
364 { id: observed.node_id },
365 ),
366 );
367 }
368 yield* syncPullRequestMeta(octokit, news, observed.number);
369 if (draftChanged && state === "closed") {
370 yield* request(() =>
371 octokit.rest.pulls.update({
372 ...scope,
373 pull_number: observed.number,
374 state: "closed",
375 }),
376 );
377 }
378 const final = yield* getPull(octokit, news, observed.number);
379 if (final === undefined) {
380 return yield* Effect.fail(
381 new Error("Pull request disappeared after reconciliation"),
382 );
383 }
384 return attributes(final);
385 }),
386
387 // Enumerate every pull request across the repositories the token can see.
388 list: Effect.fn(function* () {
389 const octokit = yield* Octokit;
390
391 const repos = yield* Effect.tryPromise({
392 try: () =>
393 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
394 per_page: 100,
395 }),
396 catch: (e) => e as Error,
397 });
398
399 const perRepo = yield* Effect.forEach(
400 repos,
401 (repo) =>
402 Effect.tryPromise({
403 try: async () => {
404 try {
405 const pulls = await octokit.paginate(octokit.rest.pulls.list, {
406 owner: repo.owner.login,
407 repo: repo.name,
408 state: "all",
409 per_page: 100,
410 });
411 return pulls.map((pr) => ({
412 prNumber: pr.number,
413 nodeId: pr.node_id,
414 htmlUrl: pr.html_url,
415 state: pr.state as "open" | "closed",
416 merged: pr.merged_at !== null,
417 draft: pr.draft ?? false,
418 createdAt: pr.created_at,
419 updatedAt: pr.updated_at,
420 }));
421 } catch (error: any) {
422 if (error.status === 403 || error.status === 404) {
423 return [];
424 }
425 throw error;
426 }
427 },
428 catch: (e) => e as Error,
429 }),
430 { concurrency: 10 },
431 );
432
433 return perRepo.flat();
434 }),
435
436 delete: Effect.fn(function* ({ olds, output }) {
437 const octokit = yield* octokitFor(olds.baseUrl);
438
439 // GitHub preserves pull request history; destruction closes it.
440 const observed = yield* getPull(octokit, olds, output.prNumber);
441 if (observed?.state === "open") {
442 yield* request(() =>
443 octokit.rest.pulls.update({
444 owner: olds.owner,
445 repo: olds.repository,
446 pull_number: output.prNumber,
447 state: "closed",
448 }),
449 ).pipe(
450 Effect.catchIf(
451 (error) => error.status === 404,
452 () => Effect.void,
453 ),
454 );
455 }
456 }),
457 });
458
459const request = <A>(run: () => Promise<A>) =>
460 Effect.tryPromise({
461 try: run,
462 catch: (error) => error as Error & { status?: number },
463 });
464
465type Pull = Awaited<ReturnType<GitHubClient["rest"]["pulls"]["get"]>>["data"];
466
467const getPull = (
468 octokit: GitHubClient,
469 props: PullRequestProps,
470 number: number,
471) =>
472 request(() =>
473 octokit.rest.pulls.get({
474 owner: props.owner,
475 repo: props.repository,
476 pull_number: number,
477 }),
478 ).pipe(
479 Effect.map(({ data }) => data),
480 Effect.catchIf(
481 (error) => error.status === 404,
482 () => Effect.succeed(undefined),
483 ),
484 );
485
486const attributes = (data: Pull) => ({
487 prNumber: data.number,
488 nodeId: data.node_id,
489 htmlUrl: data.html_url,
490 state: data.state as "open" | "closed",
491 merged: data.merged,
492 draft: data.draft ?? false,
493 createdAt: data.created_at,
494 updatedAt: data.updated_at,
495});
496
497const sameNames = (left: string[], right: string[]) =>
498 JSON.stringify([...new Set(left)].sort()) ===
499 JSON.stringify([...new Set(right)].sort());
500
501const syncPullRequestMeta = Effect.fn(function* (
502 octokit: GitHubClient,
503 props: PullRequestProps,
504 prNumber: number,
505) {
506 const scope = { owner: props.owner, repo: props.repository };
507 const issue = { ...scope, issue_number: prNumber };
508 const { data } = yield* request(() => octokit.rest.issues.get(issue));
509 const labels = data.labels.map((label) =>
510 typeof label === "string" ? label : (label.name ?? ""),
511 );
512 if (props.labels !== undefined && !sameNames(labels, props.labels)) {
513 yield* request(() =>
514 octokit.rest.issues.setLabels({ ...issue, labels: props.labels }),
515 );
516 }
517 const assignees = data.assignees?.map((user) => user.login) ?? [];
518 if (props.assignees !== undefined && !sameNames(assignees, props.assignees)) {
519 yield* request(() =>
520 octokit.rest.issues.update({ ...issue, assignees: props.assignees }),
521 );
522 }
523 if (
524 props.milestone !== undefined &&
525 (data.milestone?.number ?? null) !== props.milestone
526 ) {
527 yield* request(() =>
528 octokit.rest.issues.update({ ...issue, milestone: props.milestone }),
529 );
530 }
531 if (props.reviewers !== undefined || props.teamReviewers !== undefined) {
532 const pull = { ...scope, pull_number: prNumber };
533 const { data: current } = yield* request(() =>
534 octokit.rest.pulls.listRequestedReviewers(pull),
535 );
536 const users = current.users.map((user) => user.login);
537 const teams = current.teams.map((team) => team.slug);
538 const removeUsers =
539 props.reviewers === undefined
540 ? []
541 : users.filter((user) => !props.reviewers!.includes(user));
542 const removeTeams =
543 props.teamReviewers === undefined
544 ? []
545 : teams.filter((team) => !props.teamReviewers!.includes(team));
546 if (removeUsers.length || removeTeams.length) {
547 yield* request(() =>
548 octokit.rest.pulls.removeRequestedReviewers({
549 ...pull,
550 reviewers: removeUsers,
551 team_reviewers: removeTeams,
552 }),
553 );
554 }
555 const addUsers = (props.reviewers ?? []).filter(
556 (user) => !users.includes(user),
557 );
558 const addTeams = (props.teamReviewers ?? []).filter(
559 (team) => !teams.includes(team),
560 );
561 if (addUsers.length || addTeams.length) {
562 yield* request(() =>
563 octokit.rest.pulls.requestReviewers({
564 ...pull,
565 reviewers: addUsers,
566 team_reviewers: addTeams,
567 }),
568 );
569 }
570 }
571});