@blogic-cz/agent-tools 1.2.2 → 1.4.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.
Files changed (47) hide show
  1. package/README.md +40 -0
  2. package/dist/gh-tool/api.d.ts +16 -0
  3. package/dist/gh-tool/api.d.ts.map +1 -0
  4. package/dist/gh-tool/errors.d.ts +1 -0
  5. package/dist/gh-tool/errors.d.ts.map +1 -1
  6. package/dist/gh-tool/pr/commands.d.ts +2 -1
  7. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  8. package/dist/gh-tool/pr/core.d.ts +1 -0
  9. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  10. package/dist/gh-tool/pr/index.d.ts +1 -1
  11. package/dist/gh-tool/pr/index.d.ts.map +1 -1
  12. package/dist/gh-tool/pr/stack-read.d.ts +7 -0
  13. package/dist/gh-tool/pr/stack-read.d.ts.map +1 -0
  14. package/dist/gh-tool/pr/stack.d.ts +53 -0
  15. package/dist/gh-tool/pr/stack.d.ts.map +1 -0
  16. package/dist/gh-tool/service.d.ts +2 -0
  17. package/dist/gh-tool/service.d.ts.map +1 -1
  18. package/dist/gh-tool/types.d.ts +38 -0
  19. package/dist/gh-tool/types.d.ts.map +1 -1
  20. package/dist/observability-tool/shared.d.ts.map +1 -1
  21. package/dist/observability-tool/trace.d.ts +5 -0
  22. package/dist/observability-tool/trace.d.ts.map +1 -1
  23. package/dist/observability-tool/types.d.ts +8 -0
  24. package/dist/observability-tool/types.d.ts.map +1 -1
  25. package/dist/shared/index.d.ts +2 -0
  26. package/dist/shared/index.d.ts.map +1 -1
  27. package/dist/shared/poll-until-resolved.d.ts +9 -0
  28. package/dist/shared/poll-until-resolved.d.ts.map +1 -0
  29. package/dist/shared/retry-transient.d.ts +7 -0
  30. package/dist/shared/retry-transient.d.ts.map +1 -0
  31. package/package.json +1 -1
  32. package/src/gh-tool/api.ts +166 -0
  33. package/src/gh-tool/errors.ts +2 -0
  34. package/src/gh-tool/index.ts +2 -0
  35. package/src/gh-tool/pr/commands.ts +96 -0
  36. package/src/gh-tool/pr/core.ts +79 -21
  37. package/src/gh-tool/pr/index.ts +1 -0
  38. package/src/gh-tool/pr/stack-read.ts +73 -0
  39. package/src/gh-tool/pr/stack.ts +284 -0
  40. package/src/gh-tool/service.ts +22 -16
  41. package/src/gh-tool/types.ts +38 -0
  42. package/src/observability-tool/shared.ts +5 -0
  43. package/src/observability-tool/trace.ts +158 -1
  44. package/src/observability-tool/types.ts +9 -0
  45. package/src/shared/index.ts +2 -0
  46. package/src/shared/poll-until-resolved.ts +39 -0
  47. package/src/shared/retry-transient.ts +24 -0
@@ -0,0 +1,166 @@
1
+ import { Effect } from "effect";
2
+
3
+ import { retryTransient } from "#shared/retry-transient";
4
+
5
+ import { GitHubAuthError, GitHubCommandError, GitHubNotFoundError } from "./errors";
6
+ import type { GitHubApiError } from "./errors";
7
+
8
+ // Direct HTTP, not `gh api`: the CLI collapses every failure into a non-zero exit and
9
+ // loses the status code, but merge-async answers 202 (accepted), 200 (already merged or
10
+ // queued) and 409 (a request already exists, UUID returned) as three different outcomes.
11
+ const GITHUB_API_ROOT = "https://api.github.com";
12
+ const GITHUB_ACCEPT = "application/vnd.github+json";
13
+ const GITHUB_API_VERSION = "2022-11-28";
14
+
15
+ export type GitHubApiResponse<T> = {
16
+ status: number;
17
+ body: T;
18
+ };
19
+
20
+ const authFailure = (message: string) =>
21
+ new GitHubAuthError({
22
+ message,
23
+ hint: "Set GITHUB_TOKEN or GH_TOKEN, or authenticate the GitHub CLI with 'gh auth login'.",
24
+ nextCommand: "gh auth login",
25
+ });
26
+
27
+ // Environment first, GH_TOKEN before GITHUB_TOKEN to match the gh CLI: the active gh
28
+ // account is directory-scoped global state and the shell exports the matching token.
29
+ // `gh auth token` only covers shells that export none.
30
+ export const resolveGitHubToken = Effect.fn("gh.resolveGitHubToken")(function* () {
31
+ const fromEnv = [process.env["GH_TOKEN"], process.env["GITHUB_TOKEN"]].find(
32
+ (candidate) => candidate !== undefined && candidate.length > 0,
33
+ );
34
+ if (fromEnv !== undefined && fromEnv.length > 0) {
35
+ return fromEnv;
36
+ }
37
+
38
+ // Bun.spawn rather than the ChildProcessSpawner service on purpose: routing it through
39
+ // the service would put that requirement in the error channel of every command that
40
+ // reads the API, and token resolution is not what those commands are testing.
41
+ const token = yield* Effect.tryPromise({
42
+ try: async () => {
43
+ const proc = Bun.spawn(["gh", "auth", "token", "--hostname", "github.com"], {
44
+ stdout: "pipe",
45
+ stderr: "ignore",
46
+ });
47
+ const stdout = await new Response(proc.stdout).text();
48
+ const exitCode = await proc.exited;
49
+ return exitCode === 0 ? stdout.trim() : "";
50
+ },
51
+ catch: () => authFailure("No GitHub token available."),
52
+ }).pipe(Effect.orElseSucceed(() => ""));
53
+ if (token.length === 0) {
54
+ return yield* authFailure("No GitHub token available.");
55
+ }
56
+
57
+ return token;
58
+ });
59
+
60
+ export type GitHubApiRequest = {
61
+ path: string;
62
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
63
+ body?: unknown;
64
+ alsoAcceptStatus?: number[];
65
+ };
66
+
67
+ const MAX_API_RETRIES = 2;
68
+
69
+ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(opts: GitHubApiRequest) {
70
+ const token = yield* resolveGitHubToken();
71
+ const method = opts.method ?? "GET";
72
+ const url = `${GITHUB_API_ROOT}/${opts.path.replace(/^\//, "")}`;
73
+
74
+ const response = yield* Effect.tryPromise({
75
+ try: () =>
76
+ fetch(url, {
77
+ method,
78
+ headers: {
79
+ Accept: GITHUB_ACCEPT,
80
+ Authorization: `Bearer ${token}`,
81
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
82
+ ...(opts.body === undefined ? {} : { "Content-Type": "application/json" }),
83
+ },
84
+ ...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body) }),
85
+ }),
86
+ catch: (cause) =>
87
+ new GitHubCommandError({
88
+ message: `GitHub API request failed: ${String(cause)}`,
89
+ command: `${method} ${opts.path}`,
90
+ exitCode: -1,
91
+ stderr: String(cause),
92
+ retryable: true,
93
+ hint: "Check network connectivity and VPN state, then retry.",
94
+ }),
95
+ });
96
+
97
+ const text = yield* Effect.tryPromise({
98
+ try: () => response.text(),
99
+ catch: (cause) =>
100
+ new GitHubCommandError({
101
+ message: `GitHub API response could not be read: ${String(cause)}`,
102
+ command: `${method} ${opts.path}`,
103
+ exitCode: -1,
104
+ stderr: String(cause),
105
+ hint: "The connection dropped mid-response. Re-read the resource before retrying a mutation.",
106
+ }),
107
+ });
108
+ const parsed: unknown = text.length === 0 ? null : safeJsonParse(text);
109
+
110
+ const accepted = new Set([200, ...(opts.alsoAcceptStatus ?? [])]);
111
+ if (accepted.has(response.status)) {
112
+ return { status: response.status, body: parsed as T };
113
+ }
114
+
115
+ if (response.status === 401) {
116
+ return yield* authFailure("GitHub credentials rejected (HTTP 401).");
117
+ }
118
+
119
+ if (response.status === 404) {
120
+ return yield* new GitHubNotFoundError({
121
+ message: `GitHub API returned 404 for ${opts.path}`,
122
+ identifier: opts.path,
123
+ resource: "github-api",
124
+ hint: "Verify the resource exists, that you have access, and that the feature is enabled for this repository.",
125
+ });
126
+ }
127
+
128
+ return yield* new GitHubCommandError({
129
+ message: apiErrorMessage(parsed, response.status),
130
+ command: `${method} ${opts.path}`,
131
+ exitCode: response.status,
132
+ stderr: text,
133
+ ...(response.status >= 500 ? { retryable: true } : {}),
134
+ });
135
+ });
136
+
137
+ // Mirrors GitHubService.runGh: replay a transient failure, and only for an idempotent read.
138
+ export const githubApi = <T>(
139
+ opts: GitHubApiRequest,
140
+ ): Effect.Effect<GitHubApiResponse<T>, GitHubApiError> =>
141
+ retryTransient({
142
+ attempt: () => githubApiAttempt<T>(opts),
143
+ isTransient: (error) =>
144
+ error._tag === "GitHubCommandError" &&
145
+ error.retryable === true &&
146
+ (opts.method ?? "GET") === "GET",
147
+ maxRetries: MAX_API_RETRIES,
148
+ });
149
+
150
+ const safeJsonParse = (text: string): unknown => {
151
+ try {
152
+ return JSON.parse(text);
153
+ } catch {
154
+ return null;
155
+ }
156
+ };
157
+
158
+ const apiErrorMessage = (body: unknown, status: number): string => {
159
+ if (typeof body === "object" && body !== null && "message" in body) {
160
+ const message = (body as { message?: unknown }).message;
161
+ if (typeof message === "string") {
162
+ return `GitHub API error (HTTP ${status}): ${message}`;
163
+ }
164
+ }
165
+ return `GitHub API error (HTTP ${status})`;
166
+ };
@@ -63,3 +63,5 @@ export type GitHubServiceError =
63
63
  | GitHubAuthError
64
64
  | GitHubMergeError
65
65
  | GitHubTimeoutError;
66
+
67
+ export type GitHubApiError = GitHubCommandError | GitHubAuthError | GitHubNotFoundError;
@@ -27,6 +27,7 @@ import {
27
27
  prEditCommand,
28
28
  prMergeCommand,
29
29
  prReadyCommand,
30
+ prStackCommand,
30
31
  prRequestReviewCommand,
31
32
  prThreadsCommand,
32
33
  prCommentsCommand,
@@ -93,6 +94,7 @@ const prCommand = Command.make("pr", {}).pipe(
93
94
  prEditCommand,
94
95
  prMergeCommand,
95
96
  prReadyCommand,
97
+ prStackCommand,
96
98
  prRequestReviewCommand,
97
99
  prWaitMergeableCommand,
98
100
  prThreadsCommand,
@@ -29,6 +29,7 @@ import {
29
29
  REVIEW_EVENTS,
30
30
  } from "#gh/config";
31
31
 
32
+ import { mergeStack, readStack, unstackStack } from "./stack";
32
33
  import {
33
34
  closePR,
34
35
  collectWithStableState,
@@ -413,6 +414,23 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
413
414
  if (info.reviewDecision !== "" && info.reviewDecision !== "APPROVED") {
414
415
  blocking.push(`review=${info.reviewDecision}`);
415
416
  }
417
+
418
+ // A member of a GitHub stack cannot merge on its own while open members sit below it, so a
419
+ // verdict that reads only this PR would report ready for a PR nothing can land.
420
+ const stackView = yield* readStack({ pr: info.number }).pipe(
421
+ Effect.catch(() => Effect.succeed(null)),
422
+ );
423
+ const ownPosition = stackView?.members.find((member) => member.number === info.number)?.position;
424
+ const openBelow =
425
+ stackView?.isStacked === true && ownPosition !== undefined
426
+ ? stackView.members.filter(
427
+ (member) => member.state === "open" && member.position < ownPosition,
428
+ )
429
+ : [];
430
+ if (openBelow.length > 0) {
431
+ blocking.push(`stack_members_below=${openBelow.map((member) => member.number).join(",")}`);
432
+ }
433
+
416
434
  const ready = {
417
435
  ready: blocking.length === 0,
418
436
  mergeable: info.mergeable,
@@ -1609,3 +1627,81 @@ export const prReplyAndResolveCommand = Command.make(
1609
1627
  "Composite: reply to a review comment and resolve its thread (PR/thread inferred from comment)",
1610
1628
  ),
1611
1629
  );
1630
+
1631
+ const prStackViewCommand = Command.make(
1632
+ "view",
1633
+ {
1634
+ format: formatOption,
1635
+ pr: Flag.integer("pr").pipe(Flag.withDescription("PR number to read the stack of")),
1636
+ repo: repoOption,
1637
+ },
1638
+ ({ format, pr, repo }) =>
1639
+ withRepo(
1640
+ repo,
1641
+ Effect.gen(function* () {
1642
+ const result = yield* readStack({ pr });
1643
+ yield* logFormatted(result, format);
1644
+ }),
1645
+ ),
1646
+ ).pipe(Command.withDescription("Show every PR in the stack containing this PR, bottom-up"));
1647
+
1648
+ const prStackMergeCommand = Command.make(
1649
+ "merge",
1650
+ {
1651
+ confirm: Flag.boolean("confirm").pipe(
1652
+ Flag.withDescription("Actually merge (without this flag, only shows the ordered plan)"),
1653
+ Flag.withDefault(false),
1654
+ ),
1655
+ format: formatOption,
1656
+ pr: Flag.integer("pr").pipe(Flag.withDescription("Any PR in the stack to merge")),
1657
+ repo: repoOption,
1658
+ strategy: Flag.choice("strategy", MERGE_STRATEGIES).pipe(
1659
+ Flag.withDescription("Merge strategy: squash, merge, or rebase"),
1660
+ Flag.withDefault(DEFAULT_MERGE_STRATEGY),
1661
+ ),
1662
+ },
1663
+ ({ confirm, format, pr, repo, strategy }) =>
1664
+ withRepo(
1665
+ repo,
1666
+ Effect.gen(function* () {
1667
+ const result = yield* mergeStack({ confirm, pr, strategy });
1668
+ yield* logFormatted(result, format);
1669
+ }),
1670
+ ),
1671
+ ).pipe(
1672
+ Command.withDescription(
1673
+ "Merge every open PR in the stack in one request (dry-run by default, use --confirm)",
1674
+ ),
1675
+ );
1676
+
1677
+ const prStackUnstackCommand = Command.make(
1678
+ "unstack",
1679
+ {
1680
+ confirm: Flag.boolean("confirm").pipe(
1681
+ Flag.withDescription(
1682
+ "Actually unstack (without this flag, only shows what would be removed)",
1683
+ ),
1684
+ Flag.withDefault(false),
1685
+ ),
1686
+ format: formatOption,
1687
+ pr: Flag.integer("pr").pipe(Flag.withDescription("Any PR in the stack to dissolve")),
1688
+ repo: repoOption,
1689
+ },
1690
+ ({ confirm, format, pr, repo }) =>
1691
+ withRepo(
1692
+ repo,
1693
+ Effect.gen(function* () {
1694
+ const result = yield* unstackStack({ confirm, pr });
1695
+ yield* logFormatted(result, format);
1696
+ }),
1697
+ ),
1698
+ ).pipe(
1699
+ Command.withDescription(
1700
+ "Remove every unmerged PR from the stack, dissolving it (dry-run by default)",
1701
+ ),
1702
+ );
1703
+
1704
+ export const prStackCommand = Command.make("stack", {}).pipe(
1705
+ Command.withSubcommands([prStackViewCommand, prStackMergeCommand, prStackUnstackCommand]),
1706
+ Command.withDescription("Stacked pull request operations"),
1707
+ );
@@ -22,7 +22,9 @@ import { GitHubService } from "#gh/service";
22
22
  import { logText } from "#shared";
23
23
 
24
24
  import type { ButStatusJson, PRViewJsonResult } from "./helpers";
25
+ import { pollUntilResolved } from "#shared/poll-until-resolved";
25
26
  import { runLocalCommand } from "./helpers";
27
+ import { readStack } from "./stack-read";
26
28
  import {
27
29
  diagnoseLogEntries,
28
30
  discoverDispatchedRun,
@@ -200,7 +202,7 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
200
202
  // is an ordinary state, so map it to [] and keep the zero-check paths downstream reachable.
201
203
  export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
202
204
 
203
- const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: number | null) {
205
+ export const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: number | null) {
204
206
  const gh = yield* GitHubService;
205
207
 
206
208
  const args = ["pr", "checks"];
@@ -763,26 +765,13 @@ const mergeViaAsyncApi = Effect.fn("pr.mergeViaAsyncApi")(function* (opts: {
763
765
  ]);
764
766
 
765
767
  const uuid = latest.details?.uuid;
766
- if (latest.status === "pending" && uuid !== undefined) {
767
- const start = yield* Clock.currentTimeMillis;
768
- const deadlineMs = Number(start) + MAX_ASYNC_MERGE_WAIT_SECONDS * 1000;
769
- let timedOut = false;
770
-
771
- // Effect.whileLoop (not recursion) so TestClock.adjust can advance Effect.sleep without real waits.
772
- yield* Effect.whileLoop({
773
- while: () => latest.status === "pending" && !timedOut,
774
- body: () =>
775
- Effect.gen(function* () {
776
- const now = yield* Clock.currentTimeMillis;
777
- if (Number(now) >= deadlineMs) {
778
- timedOut = true;
779
- return;
780
- }
781
- const remaining = deadlineMs - Number(now);
782
- yield* Effect.sleep(Duration.millis(Math.min(ASYNC_MERGE_POLL_INTERVAL_MS, remaining)));
783
- latest = yield* gh.runGhJson<AsyncMergeResult>(["api", `${asyncPath}/${uuid}`]);
784
- }),
785
- step: () => undefined,
768
+ if (uuid !== undefined) {
769
+ latest = yield* pollUntilResolved({
770
+ initial: latest,
771
+ isPending: (value) => value.status === "pending",
772
+ fetchLatest: () => gh.runGhJson<AsyncMergeResult>(["api", `${asyncPath}/${uuid}`]),
773
+ intervalMs: ASYNC_MERGE_POLL_INTERVAL_MS,
774
+ budgetSeconds: MAX_ASYNC_MERGE_WAIT_SECONDS,
786
775
  });
787
776
  }
788
777
 
@@ -832,6 +821,57 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
832
821
 
833
822
  const repo = opts.deleteBranch ? yield* gh.getRepoInfo() : null;
834
823
 
824
+ // merge-async lands the requested PR AND every unmerged PR below it in its stack, and
825
+ // `gh pr merge` falls back to that endpoint for a stacked PR. Merging one member can
826
+ // therefore land members this command never named. Refuse instead of merging silently.
827
+ // Fail closed: readStack already reports a repository without a stacks surface as
828
+ // unstacked, so a failure here leaves membership genuinely unknown, and proceeding
829
+ // would land whatever sits below this PR without naming it.
830
+ const stackView = yield* readStack({ pr: opts.pr }).pipe(
831
+ Effect.catch((error) =>
832
+ Effect.fail(
833
+ new GitHubMergeError({
834
+ message: `Could not determine whether PR #${opts.pr} belongs to a stack: ${error.message}`,
835
+ reason: "unknown",
836
+ hint: "Merging a stack member lands every open PR below it, so the merge is refused while membership is unknown. Retry, or read the stack with 'pr stack view'.",
837
+ nextCommand: `agent-tools-gh pr stack view --pr ${opts.pr}`,
838
+ }),
839
+ ),
840
+ ),
841
+ );
842
+ const ownPosition = stackView.members.find((entry) => entry.number === opts.pr)?.position;
843
+
844
+ // The stack was fetched by querying this PR, so it must be among its own members. If it
845
+ // is not, the PR left the stack between the list call and the detail call and membership
846
+ // is unknown again — the same reason the lookup failure above refuses.
847
+ if (stackView.isStacked && ownPosition === undefined) {
848
+ return yield* new GitHubMergeError({
849
+ message: `PR #${opts.pr} is missing from stack #${stackView.stackNumber}, which was read for that PR`,
850
+ reason: "unknown",
851
+ hint: "The stack changed while it was being read. Re-read it with 'pr stack view' before merging.",
852
+ nextCommand: `agent-tools-gh pr stack view --pr ${opts.pr}`,
853
+ });
854
+ }
855
+
856
+ const openBelow =
857
+ !stackView.isStacked || ownPosition === undefined
858
+ ? []
859
+ : stackView.members.filter(
860
+ (member) => member.state === "open" && member.position < ownPosition,
861
+ );
862
+
863
+ if (openBelow.length > 0) {
864
+ return yield* new GitHubMergeError({
865
+ message:
866
+ `PR #${opts.pr} sits above ${openBelow.length} open PR(s) in stack #${stackView.stackNumber}: ` +
867
+ openBelow.map((member) => `#${member.number}`).join(", ") +
868
+ ". Merging it would land them too.",
869
+ reason: "unknown",
870
+ hint: "Use 'pr stack merge' to merge a stack: it checks every member first and reports the whole plan.",
871
+ nextCommand: `agent-tools-gh pr stack merge --pr ${opts.pr}`,
872
+ });
873
+ }
874
+
835
875
  // A long-lived branch (default/env branch) as PR head means a promotion PR
836
876
  // (e.g. main -> staging). PRs based on it are unrelated work, not a stack —
837
877
  // retargeting them would mass-rewrite their base — and the branch itself
@@ -1141,6 +1181,24 @@ export const editPR = Effect.fn("pr.editPR")(function* (opts: {
1141
1181
 
1142
1182
  const repo = yield* gh.getRepoInfo();
1143
1183
 
1184
+ // GitHub rejects a base change on a stacked PR with a bare 422. Say what the state is and
1185
+ // which operation changes it, rather than letting the validation error through.
1186
+ if (opts.base !== null) {
1187
+ const stackView = yield* readStack({ pr: opts.pr }).pipe(
1188
+ Effect.catch(() => Effect.succeed(null)),
1189
+ );
1190
+ if (stackView?.isStacked === true) {
1191
+ return yield* new GitHubCommandError({
1192
+ command: "pr edit --base",
1193
+ exitCode: 1,
1194
+ stderr: `PR #${opts.pr} belongs to GitHub stack #${stackView.stackNumber}`,
1195
+ message: `Cannot retarget PR #${opts.pr}: it belongs to GitHub stack #${stackView.stackNumber}, whose members' bases GitHub owns`,
1196
+ hint: "Dissolve the stack first with 'pr stack unstack', which removes every unmerged member, then retarget. Merging the stack instead needs no retarget at all.",
1197
+ nextCommand: `agent-tools-gh pr stack view --pr ${opts.pr}`,
1198
+ });
1199
+ }
1200
+ }
1201
+
1144
1202
  const editArgs = [
1145
1203
  "api",
1146
1204
  "--method",
@@ -14,6 +14,7 @@ export {
14
14
  prListCommand,
15
15
  prMergeCommand,
16
16
  prReadyCommand,
17
+ prStackCommand,
17
18
  prRequestReviewCommand,
18
19
  prReplyCommand,
19
20
  prRerunChecksCommand,
@@ -0,0 +1,73 @@
1
+ import { Effect } from "effect";
2
+
3
+ import { GitHubService } from "#gh/service";
4
+
5
+ import type { StackMember, StackView } from "#gh/types";
6
+
7
+ type StacksListResponse = Array<{ number: number }>;
8
+
9
+ type StackResponse = {
10
+ number: number;
11
+ base: { ref: string };
12
+ open: boolean;
13
+ pull_requests: Array<{
14
+ number: number;
15
+ title: string;
16
+ state: "open" | "closed";
17
+ merged_at: string | null;
18
+ draft: boolean;
19
+ html_url: string;
20
+ head: { ref: string };
21
+ base: { ref: string };
22
+ }>;
23
+ };
24
+
25
+ export const readStack = Effect.fn("pr.readStack")(function* (opts: { pr: number }) {
26
+ const gh = yield* GitHubService;
27
+ const repo = yield* gh.getRepoInfo();
28
+ const base = `repos/${repo.owner}/${repo.name}`;
29
+
30
+ const unstacked: StackView = {
31
+ pr: opts.pr,
32
+ isStacked: false,
33
+ stackNumber: null,
34
+ baseRef: null,
35
+ members: [],
36
+ };
37
+
38
+ // A 404 is the repository having no stacks surface at all, which reads the same as a PR
39
+ // that belongs to no stack. Every caller gets that reading from here, not its own.
40
+ const stacks = yield* gh
41
+ .apiRequest<StacksListResponse>({ path: `${base}/stacks?pull_request=${opts.pr}` })
42
+ .pipe(Effect.catchTag("GitHubNotFoundError", () => Effect.succeed(null)));
43
+
44
+ if (stacks === null) {
45
+ return unstacked;
46
+ }
47
+
48
+ const stackNumber = stacks.body?.[0]?.number;
49
+ if (stackNumber === undefined) {
50
+ return unstacked;
51
+ }
52
+
53
+ const stack = yield* gh.apiRequest<StackResponse>({ path: `${base}/stacks/${stackNumber}` });
54
+
55
+ const members: StackMember[] = stack.body.pull_requests.map((member, index) => ({
56
+ position: index + 1,
57
+ number: member.number,
58
+ title: member.title,
59
+ headRefName: member.head.ref,
60
+ baseRefName: member.base.ref,
61
+ state: member.merged_at === null ? member.state : "merged",
62
+ isDraft: member.draft,
63
+ url: member.html_url,
64
+ }));
65
+
66
+ return {
67
+ pr: opts.pr,
68
+ isStacked: true,
69
+ stackNumber: stack.body.number,
70
+ baseRef: stack.body.base.ref,
71
+ members,
72
+ } satisfies StackView;
73
+ });