@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.
- package/README.md +40 -0
- package/dist/gh-tool/api.d.ts +16 -0
- package/dist/gh-tool/api.d.ts.map +1 -0
- 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 +2 -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/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 +53 -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/observability-tool/shared.d.ts.map +1 -1
- package/dist/observability-tool/trace.d.ts +5 -0
- package/dist/observability-tool/trace.d.ts.map +1 -1
- package/dist/observability-tool/types.d.ts +8 -0
- package/dist/observability-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/errors.ts +2 -0
- package/src/gh-tool/index.ts +2 -0
- package/src/gh-tool/pr/commands.ts +96 -0
- package/src/gh-tool/pr/core.ts +79 -21
- package/src/gh-tool/pr/index.ts +1 -0
- package/src/gh-tool/pr/stack-read.ts +73 -0
- package/src/gh-tool/pr/stack.ts +284 -0
- package/src/gh-tool/service.ts +22 -16
- package/src/gh-tool/types.ts +38 -0
- package/src/observability-tool/shared.ts +5 -0
- package/src/observability-tool/trace.ts +158 -1
- package/src/observability-tool/types.ts +9 -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/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,
|
|
@@ -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
|
+
);
|
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
|
|
@@ -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",
|
package/src/gh-tool/pr/index.ts
CHANGED
|
@@ -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
|
+
});
|