@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.
- package/README.md +41 -0
- package/dist/gh-tool/api.d.ts +16 -0
- package/dist/gh-tool/api.d.ts.map +1 -0
- package/dist/gh-tool/config.d.ts +2 -0
- package/dist/gh-tool/config.d.ts.map +1 -1
- package/dist/gh-tool/errors.d.ts +1 -0
- package/dist/gh-tool/errors.d.ts.map +1 -1
- package/dist/gh-tool/pr/commands.d.ts +14 -1
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/pr/core.d.ts +1 -0
- package/dist/gh-tool/pr/core.d.ts.map +1 -1
- package/dist/gh-tool/pr/index.d.ts +1 -1
- package/dist/gh-tool/pr/index.d.ts.map +1 -1
- package/dist/gh-tool/pr/review.d.ts +19 -1
- package/dist/gh-tool/pr/review.d.ts.map +1 -1
- package/dist/gh-tool/pr/stack-read.d.ts +7 -0
- package/dist/gh-tool/pr/stack-read.d.ts.map +1 -0
- package/dist/gh-tool/pr/stack.d.ts +42 -0
- package/dist/gh-tool/pr/stack.d.ts.map +1 -0
- package/dist/gh-tool/service.d.ts +2 -0
- package/dist/gh-tool/service.d.ts.map +1 -1
- package/dist/gh-tool/types.d.ts +38 -0
- package/dist/gh-tool/types.d.ts.map +1 -1
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/poll-until-resolved.d.ts +9 -0
- package/dist/shared/poll-until-resolved.d.ts.map +1 -0
- package/dist/shared/retry-transient.d.ts +7 -0
- package/dist/shared/retry-transient.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/gh-tool/api.ts +166 -0
- package/src/gh-tool/config.ts +3 -0
- package/src/gh-tool/errors.ts +2 -0
- package/src/gh-tool/index.ts +6 -1
- package/src/gh-tool/pr/commands.ts +128 -3
- package/src/gh-tool/pr/core.ts +61 -21
- package/src/gh-tool/pr/index.ts +2 -0
- package/src/gh-tool/pr/review.ts +101 -1
- package/src/gh-tool/pr/stack-read.ts +73 -0
- package/src/gh-tool/pr/stack.ts +239 -0
- package/src/gh-tool/service.ts +27 -16
- package/src/gh-tool/types.ts +38 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/poll-until-resolved.ts +39 -0
- 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
|
+
};
|
package/src/gh-tool/config.ts
CHANGED
|
@@ -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;
|
package/src/gh-tool/errors.ts
CHANGED
package/src/gh-tool/index.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
+
);
|
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -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 (
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
package/src/gh-tool/pr/index.ts
CHANGED
|
@@ -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,
|
package/src/gh-tool/pr/review.ts
CHANGED
|
@@ -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:
|
|
1110
|
+
event: REVIEW_EVENT_API[event],
|
|
1011
1111
|
body: body ?? "",
|
|
1012
1112
|
})) as SubmitReviewResult;
|
|
1013
1113
|
|