@blogic-cz/agent-tools 1.2.1 → 1.3.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 (45) hide show
  1. package/README.md +41 -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/config.d.ts +2 -0
  5. package/dist/gh-tool/config.d.ts.map +1 -1
  6. package/dist/gh-tool/errors.d.ts +1 -0
  7. package/dist/gh-tool/errors.d.ts.map +1 -1
  8. package/dist/gh-tool/pr/commands.d.ts +14 -1
  9. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  10. package/dist/gh-tool/pr/core.d.ts +1 -0
  11. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  12. package/dist/gh-tool/pr/index.d.ts +1 -1
  13. package/dist/gh-tool/pr/index.d.ts.map +1 -1
  14. package/dist/gh-tool/pr/review.d.ts +19 -1
  15. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  16. package/dist/gh-tool/pr/stack-read.d.ts +7 -0
  17. package/dist/gh-tool/pr/stack-read.d.ts.map +1 -0
  18. package/dist/gh-tool/pr/stack.d.ts +42 -0
  19. package/dist/gh-tool/pr/stack.d.ts.map +1 -0
  20. package/dist/gh-tool/service.d.ts +2 -0
  21. package/dist/gh-tool/service.d.ts.map +1 -1
  22. package/dist/gh-tool/types.d.ts +38 -0
  23. package/dist/gh-tool/types.d.ts.map +1 -1
  24. package/dist/shared/index.d.ts +2 -0
  25. package/dist/shared/index.d.ts.map +1 -1
  26. package/dist/shared/poll-until-resolved.d.ts +9 -0
  27. package/dist/shared/poll-until-resolved.d.ts.map +1 -0
  28. package/dist/shared/retry-transient.d.ts +7 -0
  29. package/dist/shared/retry-transient.d.ts.map +1 -0
  30. package/package.json +1 -1
  31. package/src/gh-tool/api.ts +166 -0
  32. package/src/gh-tool/config.ts +3 -0
  33. package/src/gh-tool/errors.ts +2 -0
  34. package/src/gh-tool/index.ts +6 -1
  35. package/src/gh-tool/pr/commands.ts +128 -3
  36. package/src/gh-tool/pr/core.ts +61 -21
  37. package/src/gh-tool/pr/index.ts +2 -0
  38. package/src/gh-tool/pr/review.ts +101 -1
  39. package/src/gh-tool/pr/stack-read.ts +73 -0
  40. package/src/gh-tool/pr/stack.ts +239 -0
  41. package/src/gh-tool/service.ts +27 -16
  42. package/src/gh-tool/types.ts +38 -0
  43. package/src/shared/index.ts +2 -0
  44. package/src/shared/poll-until-resolved.ts +39 -0
  45. 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
+ };
@@ -5,3 +5,6 @@ export const GRAPHQL_PAGE_SIZE = 100 as const;
5
5
  export const GH_BINARY = "gh" as const;
6
6
 
7
7
  export const MERGE_STRATEGIES = ["squash", "merge", "rebase"] as const;
8
+
9
+ export const REVIEW_EVENTS = ["comment", "approve", "request-changes"] as const;
10
+ export const DEFAULT_REVIEW_EVENT = "comment" as const;
@@ -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,
@@ -37,6 +38,7 @@ import {
37
38
  prFeedbackCommand,
38
39
  prReplyCommand,
39
40
  prResolveCommand,
41
+ prReviewCommand,
40
42
  prReviewsCommand,
41
43
  prLastHumanReviewerCommand,
42
44
  prSubmitReviewCommand,
@@ -92,6 +94,7 @@ const prCommand = Command.make("pr", {}).pipe(
92
94
  prEditCommand,
93
95
  prMergeCommand,
94
96
  prReadyCommand,
97
+ prStackCommand,
95
98
  prRequestReviewCommand,
96
99
  prWaitMergeableCommand,
97
100
  prThreadsCommand,
@@ -105,6 +108,7 @@ const prCommand = Command.make("pr", {}).pipe(
105
108
  prDiscussionSummaryCommand,
106
109
  prReplyCommand,
107
110
  prResolveCommand,
111
+ prReviewCommand,
108
112
  prSubmitReviewCommand,
109
113
  prChecksCommand,
110
114
  prChecksFailedCommand,
@@ -199,7 +203,8 @@ WORKFLOW FOR AI AGENTS:
199
203
  1. Use 'pr view' to inspect current PR
200
204
  2. Use 'pr discussion-summary' for overview (counts + latest discussion comment)
201
205
  3. Use 'pr threads' and 'pr issue-comments-latest --author <username> --body-contains "Review"' for review context
202
- 4. Use 'pr submit-review', 'pr reply', 'pr comment' and 'pr resolve' to handle feedback
206
+ 4. Use 'pr reply', 'pr comment' and 'pr resolve' to handle feedback
207
+ 4b. Use 'pr review --event request-changes|approve|comment --confirm' to post a verdict, or 'pr submit-review' for a pending one
203
208
  5. Use 'pr checks' to monitor CI status; 'pr trigger-checks --workflow <file.yml>' when zero checks were reported
204
209
  6. Use 'pr merge' to merge (dry-run by default)
205
210
  7. Use 'issue list' to list open/closed issues
@@ -24,9 +24,12 @@ import {
24
24
  CI_CHECK_WATCH_TIMEOUT_MS,
25
25
  DEFAULT_DELETE_BRANCH,
26
26
  DEFAULT_MERGE_STRATEGY,
27
+ DEFAULT_REVIEW_EVENT,
27
28
  MERGE_STRATEGIES,
29
+ REVIEW_EVENTS,
28
30
  } from "#gh/config";
29
31
 
32
+ import { mergeStack, readStack } from "./stack";
30
33
  import {
31
34
  closePR,
32
35
  collectWithStableState,
@@ -47,6 +50,7 @@ import {
47
50
  waitForMergeable,
48
51
  } from "./core";
49
52
  import {
53
+ createReview,
50
54
  fetchComments,
51
55
  fetchDiscussionSummary,
52
56
  fetchFeedback,
@@ -1336,6 +1340,68 @@ export const prResolveCommand = Command.make(
1336
1340
  ),
1337
1341
  ).pipe(Command.withDescription("Resolve a review thread via GraphQL"));
1338
1342
 
1343
+ const reviewEventOption = Flag.choice("event", REVIEW_EVENTS).pipe(
1344
+ Flag.withDescription("Review verdict: comment, approve, or request-changes"),
1345
+ Flag.withDefault(DEFAULT_REVIEW_EVENT),
1346
+ );
1347
+
1348
+ const reviewConfirmOption = Flag.boolean("confirm").pipe(
1349
+ Flag.withDescription("Required for --event approve and --event request-changes"),
1350
+ Flag.withDefault(false),
1351
+ );
1352
+
1353
+ export const prReviewCommand = Command.make(
1354
+ "review",
1355
+ {
1356
+ body: Flag.string("body").pipe(Flag.withDescription("Review body text"), Flag.optional),
1357
+ bodyFile: Flag.string("body-file").pipe(
1358
+ Flag.withDescription("Read review body from a file path or '-' for stdin"),
1359
+ Flag.optional,
1360
+ ),
1361
+ bodyStdin: Flag.boolean("body-stdin").pipe(
1362
+ Flag.withDescription("Read review body from stdin"),
1363
+ Flag.withDefault(false),
1364
+ ),
1365
+ confirm: reviewConfirmOption,
1366
+ event: reviewEventOption,
1367
+ format: formatOption,
1368
+ pr: Flag.integer("pr").pipe(
1369
+ Flag.withDescription("PR number (default: current branch PR)"),
1370
+ Flag.optional,
1371
+ ),
1372
+ repo: repoOption,
1373
+ },
1374
+ ({ body, bodyFile, bodyStdin, confirm, event, format, pr, repo }) =>
1375
+ withRepo(
1376
+ repo,
1377
+ Effect.gen(function* () {
1378
+ const resolvedBody = yield* resolveDefaultTextInput({
1379
+ command: "gh-tool pr review",
1380
+ value: Option.getOrNull(body),
1381
+ fileValue: Option.getOrNull(bodyFile),
1382
+ stdin: bodyStdin,
1383
+ valueFlag: "--body",
1384
+ fileFlag: "--body-file",
1385
+ stdinFlag: "--body-stdin",
1386
+ label: "body",
1387
+ defaultValue: "",
1388
+ });
1389
+
1390
+ const result = yield* createReview({
1391
+ pr: Option.getOrNull(pr),
1392
+ event,
1393
+ body: resolvedBody,
1394
+ confirm,
1395
+ });
1396
+ yield* logFormatted(result, format);
1397
+ }),
1398
+ ),
1399
+ ).pipe(
1400
+ Command.withDescription(
1401
+ "Create and submit a review (--event comment, approve, or request-changes; verdicts need --confirm)",
1402
+ ),
1403
+ );
1404
+
1339
1405
  export const prSubmitReviewCommand = Command.make(
1340
1406
  "submit-review",
1341
1407
  {
@@ -1347,6 +1413,8 @@ export const prSubmitReviewCommand = Command.make(
1347
1413
  Flag.withDescription("Read review body from a file path or '-' for stdin"),
1348
1414
  Flag.optional,
1349
1415
  ),
1416
+ confirm: reviewConfirmOption,
1417
+ event: reviewEventOption,
1350
1418
  format: formatOption,
1351
1419
  pr: Flag.integer("pr").pipe(
1352
1420
  Flag.withDescription("PR number (default: current branch PR)"),
@@ -1360,7 +1428,7 @@ export const prSubmitReviewCommand = Command.make(
1360
1428
  Flag.optional,
1361
1429
  ),
1362
1430
  },
1363
- ({ body, bodyFile, format, pr, repo, reviewId }) =>
1431
+ ({ body, bodyFile, confirm, event, format, pr, repo, reviewId }) =>
1364
1432
  withRepo(
1365
1433
  repo,
1366
1434
  Effect.gen(function* () {
@@ -1374,13 +1442,19 @@ export const prSubmitReviewCommand = Command.make(
1374
1442
  fileFlag: "--body-file",
1375
1443
  label: "body",
1376
1444
  });
1377
- const result = yield* submitPendingReview(prNumber, reviewIdValue, bodyValue);
1445
+ const result = yield* submitPendingReview(
1446
+ prNumber,
1447
+ reviewIdValue,
1448
+ bodyValue,
1449
+ event,
1450
+ confirm,
1451
+ );
1378
1452
  yield* logFormatted(result, format);
1379
1453
  }),
1380
1454
  ),
1381
1455
  ).pipe(
1382
1456
  Command.withDescription(
1383
- "Submit a pending review as COMMENT (auto-detects your pending review if --review-id is omitted)",
1457
+ "Submit a pending review (--event comment by default; verdicts need --confirm; auto-detects your pending review if --review-id is omitted)",
1384
1458
  ),
1385
1459
  );
1386
1460
 
@@ -1536,3 +1610,54 @@ export const prReplyAndResolveCommand = Command.make(
1536
1610
  "Composite: reply to a review comment and resolve its thread (PR/thread inferred from comment)",
1537
1611
  ),
1538
1612
  );
1613
+
1614
+ const prStackViewCommand = Command.make(
1615
+ "view",
1616
+ {
1617
+ format: formatOption,
1618
+ pr: Flag.integer("pr").pipe(Flag.withDescription("PR number to read the stack of")),
1619
+ repo: repoOption,
1620
+ },
1621
+ ({ format, pr, repo }) =>
1622
+ withRepo(
1623
+ repo,
1624
+ Effect.gen(function* () {
1625
+ const result = yield* readStack({ pr });
1626
+ yield* logFormatted(result, format);
1627
+ }),
1628
+ ),
1629
+ ).pipe(Command.withDescription("Show every PR in the stack containing this PR, bottom-up"));
1630
+
1631
+ const prStackMergeCommand = Command.make(
1632
+ "merge",
1633
+ {
1634
+ confirm: Flag.boolean("confirm").pipe(
1635
+ Flag.withDescription("Actually merge (without this flag, only shows the ordered plan)"),
1636
+ Flag.withDefault(false),
1637
+ ),
1638
+ format: formatOption,
1639
+ pr: Flag.integer("pr").pipe(Flag.withDescription("Any PR in the stack to merge")),
1640
+ repo: repoOption,
1641
+ strategy: Flag.choice("strategy", MERGE_STRATEGIES).pipe(
1642
+ Flag.withDescription("Merge strategy: squash, merge, or rebase"),
1643
+ Flag.withDefault(DEFAULT_MERGE_STRATEGY),
1644
+ ),
1645
+ },
1646
+ ({ confirm, format, pr, repo, strategy }) =>
1647
+ withRepo(
1648
+ repo,
1649
+ Effect.gen(function* () {
1650
+ const result = yield* mergeStack({ confirm, pr, strategy });
1651
+ yield* logFormatted(result, format);
1652
+ }),
1653
+ ),
1654
+ ).pipe(
1655
+ Command.withDescription(
1656
+ "Merge every open PR in the stack in one request (dry-run by default, use --confirm)",
1657
+ ),
1658
+ );
1659
+
1660
+ export const prStackCommand = Command.make("stack", {}).pipe(
1661
+ Command.withSubcommands([prStackViewCommand, prStackMergeCommand]),
1662
+ Command.withDescription("Stacked pull request operations"),
1663
+ );
@@ -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
@@ -14,11 +14,13 @@ export {
14
14
  prListCommand,
15
15
  prMergeCommand,
16
16
  prReadyCommand,
17
+ prStackCommand,
17
18
  prRequestReviewCommand,
18
19
  prReplyCommand,
19
20
  prRerunChecksCommand,
20
21
  prReplyAndResolveCommand,
21
22
  prResolveCommand,
23
+ prReviewCommand,
22
24
  prReviewsCommand,
23
25
  prStatusCommand,
24
26
  prSubmitReviewCommand,
@@ -11,6 +11,8 @@ import type {
11
11
  FeedbackOrigin,
12
12
  } from "#gh/types";
13
13
 
14
+ import type { REVIEW_EVENTS } from "#gh/config";
15
+
14
16
  import { GitHubCommandError } from "#gh/errors";
15
17
  import { GitHubService } from "#gh/service";
16
18
 
@@ -967,12 +969,110 @@ export const resolveThread = Effect.fn("pr.resolveThread")(function* (threadId:
967
969
  };
968
970
  });
969
971
 
972
+ export type ReviewEvent = (typeof REVIEW_EVENTS)[number];
973
+
974
+ const REVIEW_EVENT_API = {
975
+ approve: "APPROVE",
976
+ comment: "COMMENT",
977
+ "request-changes": "REQUEST_CHANGES",
978
+ } as const satisfies Record<ReviewEvent, string>;
979
+
980
+ // APPROVE and REQUEST_CHANGES change whether the PR can merge, so they need the same explicit
981
+ // opt-in as `pr merge`. COMMENT carries no verdict and stays free.
982
+ const requireVerdictConfirm = Effect.fn("pr.requireVerdictConfirm")(function* (opts: {
983
+ command: string;
984
+ event: ReviewEvent;
985
+ confirm: boolean;
986
+ }) {
987
+ if (opts.event === "comment" || opts.confirm) {
988
+ return;
989
+ }
990
+
991
+ const detail = `--event ${opts.event} posts a verdict review; pass --confirm to submit it`;
992
+
993
+ return yield* Effect.fail(
994
+ new GitHubCommandError({
995
+ command: opts.command,
996
+ exitCode: 1,
997
+ stderr: detail,
998
+ message: detail,
999
+ hint: "APPROVE and REQUEST_CHANGES change the merge state of the PR. Re-run with --confirm once the verdict is intended.",
1000
+ }),
1001
+ );
1002
+ });
1003
+
1004
+ /**
1005
+ * Create and submit a review in one call — the path for a verdict an agent reached from a diff
1006
+ * it just read, with no pending review to submit.
1007
+ */
1008
+ export const createReview = Effect.fn("pr.createReview")(function* (opts: {
1009
+ pr: number | null;
1010
+ event: ReviewEvent;
1011
+ body: string;
1012
+ confirm: boolean;
1013
+ }) {
1014
+ const service = yield* GitHubService;
1015
+ const command = "gh-tool pr review";
1016
+
1017
+ yield* requireVerdictConfirm({ command, event: opts.event, confirm: opts.confirm });
1018
+
1019
+ // GitHub rejects a bodyless COMMENT or REQUEST_CHANGES review; only APPROVE may be silent.
1020
+ if (opts.event !== "approve" && opts.body.trim() === "") {
1021
+ const detail = `--event ${opts.event} requires a non-empty body`;
1022
+
1023
+ return yield* Effect.fail(
1024
+ new GitHubCommandError({
1025
+ command,
1026
+ exitCode: 1,
1027
+ stderr: detail,
1028
+ message: detail,
1029
+ hint: "Pass --body, --body-file, or --body-stdin with what has to change.",
1030
+ }),
1031
+ );
1032
+ }
1033
+
1034
+ const repoInfo = yield* service.getRepoInfo();
1035
+ const resolvedPr = opts.pr ?? (yield* viewPR(null)).number;
1036
+
1037
+ const args = [
1038
+ "api",
1039
+ "--method",
1040
+ "POST",
1041
+ `repos/${repoInfo.owner}/${repoInfo.name}/pulls/${resolvedPr}/reviews`,
1042
+ "-f",
1043
+ `event=${REVIEW_EVENT_API[opts.event]}`,
1044
+ ];
1045
+
1046
+ if (opts.body !== "") {
1047
+ args.push("-f", `body=${opts.body}`);
1048
+ }
1049
+
1050
+ const result = yield* service.runGhJson<{
1051
+ id: number;
1052
+ state: string;
1053
+ html_url: string;
1054
+ }>(args);
1055
+
1056
+ return {
1057
+ submitted: true as const,
1058
+ pr: resolvedPr,
1059
+ reviewId: result.id,
1060
+ state: result.state,
1061
+ url: result.html_url,
1062
+ };
1063
+ });
1064
+
970
1065
  export const submitPendingReview = Effect.fn("pr.submitPendingReview")(function* (
971
1066
  pr: number | null,
972
1067
  reviewId: string | null,
973
1068
  body: string | null,
1069
+ event: ReviewEvent = "comment",
1070
+ confirm = false,
974
1071
  ) {
975
1072
  const service = yield* GitHubService;
1073
+
1074
+ yield* requireVerdictConfirm({ command: "gh-tool pr submit-review", event, confirm });
1075
+
976
1076
  const repoInfo = yield* service.getRepoInfo();
977
1077
 
978
1078
  const resolvedPr = pr ?? (yield* viewPR(null)).number;
@@ -1007,7 +1107,7 @@ export const submitPendingReview = Effect.fn("pr.submitPendingReview")(function*
1007
1107
 
1008
1108
  const result = (yield* service.runGraphQL(SUBMIT_REVIEW_MUTATION, {
1009
1109
  reviewId: targetReviewId,
1010
- event: "COMMENT",
1110
+ event: REVIEW_EVENT_API[event],
1011
1111
  body: body ?? "",
1012
1112
  })) as SubmitReviewResult;
1013
1113