@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,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
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import { pollUntilResolved } from "#shared/poll-until-resolved";
|
|
4
|
+
import { GitHubService } from "#gh/service";
|
|
5
|
+
import { GitHubMergeError } from "#gh/errors";
|
|
6
|
+
|
|
7
|
+
import type { MergeStrategy, StackMember, StackMergeBlocker, StackMergeResult } from "#gh/types";
|
|
8
|
+
|
|
9
|
+
import { fetchCheckResults, fetchPRView } from "./core";
|
|
10
|
+
import { readStack } from "./stack-read";
|
|
11
|
+
|
|
12
|
+
export { readStack };
|
|
13
|
+
|
|
14
|
+
type AsyncMergeDetails = {
|
|
15
|
+
message?: string;
|
|
16
|
+
uuid?: string;
|
|
17
|
+
sha?: string;
|
|
18
|
+
merge_method?: MergeStrategy;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type AsyncMergeResult = {
|
|
22
|
+
status: "pending" | "merged" | "enqueued" | "failed";
|
|
23
|
+
details?: AsyncMergeDetails;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// One request merges every open member, so the server-side work scales with the stack;
|
|
27
|
+
// the single-PR budget in core.ts is deliberately shorter.
|
|
28
|
+
const POLL_INTERVAL_MS = 2000;
|
|
29
|
+
const MAX_WAIT_SECONDS = 300;
|
|
30
|
+
|
|
31
|
+
const stackMergeFailure = (opts: {
|
|
32
|
+
stackNumber: number;
|
|
33
|
+
pr: number;
|
|
34
|
+
message: string;
|
|
35
|
+
hint: string;
|
|
36
|
+
reason?: GitHubMergeError["reason"];
|
|
37
|
+
}) =>
|
|
38
|
+
new GitHubMergeError({
|
|
39
|
+
message: `Failed to merge stack #${opts.stackNumber}: ${opts.message}`,
|
|
40
|
+
reason: opts.reason ?? "unknown",
|
|
41
|
+
hint: opts.hint,
|
|
42
|
+
nextCommand: `agent-tools-gh pr stack view --pr ${opts.pr}`,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const collectBlockers = Effect.fn("pr.collectStackBlockers")(function* (members: StackMember[]) {
|
|
46
|
+
const blockers: StackMergeBlocker[] = [];
|
|
47
|
+
|
|
48
|
+
for (const member of members) {
|
|
49
|
+
if (member.isDraft) {
|
|
50
|
+
blockers.push({ number: member.number, reason: "draft", detail: "PR is a draft" });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const info = yield* fetchPRView(member.number);
|
|
55
|
+
if (info.mergeable === "CONFLICTING") {
|
|
56
|
+
blockers.push({
|
|
57
|
+
number: member.number,
|
|
58
|
+
reason: "not_mergeable",
|
|
59
|
+
detail: "PR has merge conflicts",
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (info.mergeable !== "MERGEABLE") {
|
|
65
|
+
blockers.push({
|
|
66
|
+
number: member.number,
|
|
67
|
+
reason: "mergeability_unknown",
|
|
68
|
+
detail: `GitHub has not settled mergeability yet (${info.mergeable})`,
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const checks = yield* fetchCheckResults(member.number);
|
|
74
|
+
const failing = checks.filter((check) => check.bucket === "fail");
|
|
75
|
+
if (failing.length > 0) {
|
|
76
|
+
blockers.push({
|
|
77
|
+
number: member.number,
|
|
78
|
+
reason: "checks_failing",
|
|
79
|
+
detail: `${failing.length} failing check(s): ${failing.map((c) => c.name).join(", ")}`,
|
|
80
|
+
});
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pending = checks.filter((check) => check.bucket === "pending");
|
|
85
|
+
if (pending.length > 0) {
|
|
86
|
+
blockers.push({
|
|
87
|
+
number: member.number,
|
|
88
|
+
reason: "checks_pending",
|
|
89
|
+
detail: `${pending.length} check(s) still running`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return blockers;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export const mergeStack = Effect.fn("pr.mergeStack")(function* (opts: {
|
|
98
|
+
pr: number;
|
|
99
|
+
strategy: MergeStrategy;
|
|
100
|
+
confirm: boolean;
|
|
101
|
+
}) {
|
|
102
|
+
const gh = yield* GitHubService;
|
|
103
|
+
const repo = yield* gh.getRepoInfo();
|
|
104
|
+
const view = yield* readStack({ pr: opts.pr });
|
|
105
|
+
|
|
106
|
+
if (!view.isStacked || view.stackNumber === null || view.baseRef === null) {
|
|
107
|
+
return yield* new GitHubMergeError({
|
|
108
|
+
message: `PR #${opts.pr} is not part of a GitHub stack`,
|
|
109
|
+
reason: "unknown",
|
|
110
|
+
hint: "Use 'pr merge' for an unstacked PR. A chain of PRs based on each other is only a stack when GitHub has registered it as one.",
|
|
111
|
+
nextCommand: `agent-tools-gh pr merge --pr ${opts.pr}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const unmerged = view.members.filter((member) => member.state === "open");
|
|
116
|
+
if (unmerged.length === 0) {
|
|
117
|
+
return yield* new GitHubMergeError({
|
|
118
|
+
message: `Stack #${view.stackNumber} has no open pull requests left`,
|
|
119
|
+
reason: "unknown",
|
|
120
|
+
hint: "Every member is already merged or closed.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// merge-async merges every unmerged PR up to and including the requested one, so the
|
|
125
|
+
// top open member is the request that lands the whole stack.
|
|
126
|
+
const target = unmerged[unmerged.length - 1] as StackMember;
|
|
127
|
+
|
|
128
|
+
const blockers = yield* collectBlockers(unmerged);
|
|
129
|
+
|
|
130
|
+
const plan = unmerged.map((member) => ({
|
|
131
|
+
position: member.position,
|
|
132
|
+
number: member.number,
|
|
133
|
+
headRefName: member.headRefName,
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
const base = {
|
|
137
|
+
stackNumber: view.stackNumber,
|
|
138
|
+
baseRef: view.baseRef,
|
|
139
|
+
target: target.number,
|
|
140
|
+
plan,
|
|
141
|
+
blockers,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (!opts.confirm) {
|
|
145
|
+
return {
|
|
146
|
+
...base,
|
|
147
|
+
strategy: opts.strategy,
|
|
148
|
+
merged: false,
|
|
149
|
+
dryRun: true,
|
|
150
|
+
sha: null,
|
|
151
|
+
adoptedExistingRequest: false,
|
|
152
|
+
} satisfies StackMergeResult;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (blockers.length > 0) {
|
|
156
|
+
return yield* new GitHubMergeError({
|
|
157
|
+
message:
|
|
158
|
+
`Stack #${view.stackNumber} is not ready: ` +
|
|
159
|
+
blockers.map((b) => `#${b.number} ${b.detail}`).join("; "),
|
|
160
|
+
reason: "unknown",
|
|
161
|
+
hint: "A partial stack merge leaves a parent on the trunk and a broken child, so nothing was attempted.",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const asyncPath = `repos/${repo.owner}/${repo.name}/pulls/${target.number}/merge-async`;
|
|
166
|
+
|
|
167
|
+
const requested = yield* gh.apiRequest<AsyncMergeResult>({
|
|
168
|
+
path: asyncPath,
|
|
169
|
+
method: "PUT",
|
|
170
|
+
body: { merge_method: opts.strategy, merge_action: "direct_merge" },
|
|
171
|
+
alsoAcceptStatus: [202, 409],
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// A 409 hands back an existing request whose options may differ from the ones asked
|
|
175
|
+
// for, so the result reports the strategy GitHub is actually applying.
|
|
176
|
+
const adoptedExistingRequest = requested.status === 409;
|
|
177
|
+
let latest = requested.body;
|
|
178
|
+
const effectiveStrategy = latest.details?.merge_method ?? opts.strategy;
|
|
179
|
+
|
|
180
|
+
const uuid = latest.details?.uuid;
|
|
181
|
+
if (latest.status === "pending" && (uuid === undefined || uuid.length === 0)) {
|
|
182
|
+
return yield* stackMergeFailure({
|
|
183
|
+
stackNumber: view.stackNumber,
|
|
184
|
+
pr: target.number,
|
|
185
|
+
message: "GitHub reported a pending merge without a request id",
|
|
186
|
+
hint: "The merge may or may not be running. Re-read the stack before retrying so the merge is not requested twice.",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (uuid !== undefined) {
|
|
191
|
+
latest = yield* pollUntilResolved({
|
|
192
|
+
initial: latest,
|
|
193
|
+
isPending: (value) => value.status === "pending",
|
|
194
|
+
fetchLatest: () =>
|
|
195
|
+
gh
|
|
196
|
+
.apiRequest<AsyncMergeResult>({ path: `${asyncPath}/${uuid}` })
|
|
197
|
+
.pipe(Effect.map((response) => response.body)),
|
|
198
|
+
intervalMs: POLL_INTERVAL_MS,
|
|
199
|
+
budgetSeconds: MAX_WAIT_SECONDS,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (latest.status === "merged") {
|
|
204
|
+
return {
|
|
205
|
+
...base,
|
|
206
|
+
strategy: effectiveStrategy,
|
|
207
|
+
merged: true,
|
|
208
|
+
dryRun: false,
|
|
209
|
+
sha: latest.details?.sha ?? null,
|
|
210
|
+
adoptedExistingRequest,
|
|
211
|
+
} satisfies StackMergeResult;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (latest.status === "enqueued") {
|
|
215
|
+
return yield* stackMergeFailure({
|
|
216
|
+
stackNumber: view.stackNumber,
|
|
217
|
+
pr: target.number,
|
|
218
|
+
reason: "merge_queue",
|
|
219
|
+
message: latest.details?.message ?? "the stack entered a merge queue",
|
|
220
|
+
hint: "The merge queue owns the merge from here; it is not merged yet. Watch the PRs until the queue drains.",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (latest.status === "pending") {
|
|
225
|
+
return yield* stackMergeFailure({
|
|
226
|
+
stackNumber: view.stackNumber,
|
|
227
|
+
pr: target.number,
|
|
228
|
+
message: `still pending after ${MAX_WAIT_SECONDS}s`,
|
|
229
|
+
hint: "The asynchronous merge is still running. Re-check the stack before retrying so the merge is not requested twice.",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return yield* stackMergeFailure({
|
|
234
|
+
stackNumber: view.stackNumber,
|
|
235
|
+
pr: target.number,
|
|
236
|
+
message: latest.details?.message ?? "the merge request failed",
|
|
237
|
+
hint: "Inspect the stack state and branch protections, then retry.",
|
|
238
|
+
});
|
|
239
|
+
});
|
package/src/gh-tool/service.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
2
|
-
import { Context,
|
|
2
|
+
import { Context, Effect, Layer, Stream } from "effect";
|
|
3
3
|
|
|
4
4
|
import type { GitHubRepoConfig } from "#config";
|
|
5
5
|
import type { RepoInfo } from "./types";
|
|
6
6
|
|
|
7
7
|
import { GH_BINARY } from "./config";
|
|
8
8
|
import { GitHubAuthError, GitHubCommandError, GitHubNotFoundError } from "./errors";
|
|
9
|
+
import { retryTransient } from "#shared/retry-transient";
|
|
10
|
+
import { githubApi } from "./api";
|
|
11
|
+
import type { GitHubApiRequest, GitHubApiResponse } from "./api";
|
|
9
12
|
import { ConfigService, getGitHubConfig, resolveGitHubRepoTarget } from "#config";
|
|
10
13
|
|
|
11
14
|
// Transient GitHub-side failures worth a silent retry (vs. a hard error the agent must act on).
|
|
@@ -34,6 +37,11 @@ const KNOWN_STDERR_HINTS: ReadonlyArray<{
|
|
|
34
37
|
re: /already exists/i,
|
|
35
38
|
hint: "The resource already exists. Fetch the existing one and update it instead of creating another.",
|
|
36
39
|
},
|
|
40
|
+
{
|
|
41
|
+
re: /Can not (?:approve|request changes on) your own pull request/i,
|
|
42
|
+
hint: "GitHub refuses a verdict review on your own PR. Post the findings with --event comment, or have the reviewing account submit the verdict.",
|
|
43
|
+
nextCommand: "agent-tools-gh pr review --pr <number> --event comment --body <text>",
|
|
44
|
+
},
|
|
37
45
|
{
|
|
38
46
|
re: /pending review/i,
|
|
39
47
|
hint: "A pending (unsubmitted) review blocks this mutation. Inspect its contents and submit or discard it before retrying.",
|
|
@@ -77,6 +85,9 @@ export class GitHubService extends Context.Service<
|
|
|
77
85
|
query: string,
|
|
78
86
|
variables: Record<string, string | number | null>,
|
|
79
87
|
) => Effect.Effect<unknown, GhError>;
|
|
88
|
+
readonly apiRequest: <T>(
|
|
89
|
+
opts: GitHubApiRequest,
|
|
90
|
+
) => Effect.Effect<GitHubApiResponse<T>, GhError>;
|
|
80
91
|
readonly getRepoConfig: () => Effect.Effect<GitHubRepoConfig | undefined, never>;
|
|
81
92
|
readonly getRepoInfo: () => Effect.Effect<RepoInfo, GhError>;
|
|
82
93
|
readonly withRepoTarget: <A, E, R>(
|
|
@@ -256,20 +267,12 @@ export class GitHubService extends Context.Service<
|
|
|
256
267
|
// Auto-retry transient failures, but only for idempotent reads (never replay a mutation).
|
|
257
268
|
const runGh = (args: string[]): Effect.Effect<GhResult, GhError> => {
|
|
258
269
|
const canRetry = isSafeRetryRead(args);
|
|
259
|
-
|
|
260
|
-
runGhAttempt(args)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return Effect.sleep(Duration.millis(500 * 2 ** attempt)).pipe(
|
|
266
|
-
Effect.flatMap(() => loop(attempt + 1)),
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
return Effect.fail(err);
|
|
270
|
-
}),
|
|
271
|
-
);
|
|
272
|
-
return loop(0);
|
|
270
|
+
return retryTransient({
|
|
271
|
+
attempt: () => runGhAttempt(args),
|
|
272
|
+
isTransient: (err) =>
|
|
273
|
+
err instanceof GitHubCommandError && err.retryable === true && canRetry,
|
|
274
|
+
maxRetries: MAX_GH_RETRIES,
|
|
275
|
+
});
|
|
273
276
|
};
|
|
274
277
|
|
|
275
278
|
const runGhJson = <T>(args: string[]) =>
|
|
@@ -360,7 +363,15 @@ export class GitHubService extends Context.Service<
|
|
|
360
363
|
return repoInfo;
|
|
361
364
|
});
|
|
362
365
|
|
|
363
|
-
return {
|
|
366
|
+
return {
|
|
367
|
+
runGh,
|
|
368
|
+
runGhJson,
|
|
369
|
+
runGraphQL,
|
|
370
|
+
apiRequest: githubApi,
|
|
371
|
+
getRepoConfig,
|
|
372
|
+
getRepoInfo,
|
|
373
|
+
withRepoTarget,
|
|
374
|
+
};
|
|
364
375
|
}),
|
|
365
376
|
),
|
|
366
377
|
);
|
package/src/gh-tool/types.ts
CHANGED
|
@@ -314,3 +314,41 @@ export type JobAnnotations = {
|
|
|
314
314
|
jobName: string;
|
|
315
315
|
annotations: CheckRunAnnotation[];
|
|
316
316
|
};
|
|
317
|
+
|
|
318
|
+
export type StackMember = {
|
|
319
|
+
position: number;
|
|
320
|
+
number: number;
|
|
321
|
+
title: string;
|
|
322
|
+
headRefName: string;
|
|
323
|
+
baseRefName: string;
|
|
324
|
+
state: "open" | "closed" | "merged";
|
|
325
|
+
isDraft: boolean;
|
|
326
|
+
url: string;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
export type StackView = {
|
|
330
|
+
pr: number;
|
|
331
|
+
isStacked: boolean;
|
|
332
|
+
stackNumber: number | null;
|
|
333
|
+
baseRef: string | null;
|
|
334
|
+
members: StackMember[];
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
export type StackMergeBlocker = {
|
|
338
|
+
number: number;
|
|
339
|
+
reason: "draft" | "not_mergeable" | "mergeability_unknown" | "checks_failing" | "checks_pending";
|
|
340
|
+
detail: string;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export type StackMergeResult = {
|
|
344
|
+
stackNumber: number;
|
|
345
|
+
baseRef: string;
|
|
346
|
+
target: number;
|
|
347
|
+
strategy: MergeStrategy;
|
|
348
|
+
plan: Array<{ position: number; number: number; headRefName: string }>;
|
|
349
|
+
merged: boolean;
|
|
350
|
+
dryRun: boolean;
|
|
351
|
+
blockers: StackMergeBlocker[];
|
|
352
|
+
sha: string | null;
|
|
353
|
+
adoptedExistingRequest: boolean;
|
|
354
|
+
};
|
package/src/shared/index.ts
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Clock, Duration, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
export const pollUntilResolved = <A, E, R>(opts: {
|
|
4
|
+
initial: A;
|
|
5
|
+
isPending: (value: A) => boolean;
|
|
6
|
+
fetchLatest: () => Effect.Effect<A, E, R>;
|
|
7
|
+
intervalMs: number;
|
|
8
|
+
budgetSeconds: number;
|
|
9
|
+
}): Effect.Effect<A, E, R> =>
|
|
10
|
+
Effect.gen(function* () {
|
|
11
|
+
let latest = opts.initial;
|
|
12
|
+
|
|
13
|
+
if (!opts.isPending(latest)) {
|
|
14
|
+
return latest;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const start = yield* Clock.currentTimeMillis;
|
|
18
|
+
const deadlineMs = Number(start) + opts.budgetSeconds * 1000;
|
|
19
|
+
let timedOut = false;
|
|
20
|
+
|
|
21
|
+
// Effect.whileLoop (not recursion) so TestClock.adjust can advance Effect.sleep without real waits.
|
|
22
|
+
yield* Effect.whileLoop({
|
|
23
|
+
while: () => opts.isPending(latest) && !timedOut,
|
|
24
|
+
body: () =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const now = yield* Clock.currentTimeMillis;
|
|
27
|
+
if (Number(now) >= deadlineMs) {
|
|
28
|
+
timedOut = true;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const remaining = deadlineMs - Number(now);
|
|
32
|
+
yield* Effect.sleep(Duration.millis(Math.min(opts.intervalMs, remaining)));
|
|
33
|
+
latest = yield* opts.fetchLatest();
|
|
34
|
+
}),
|
|
35
|
+
step: () => undefined,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return latest;
|
|
39
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Duration, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
const BASE_DELAY_MS = 500;
|
|
4
|
+
|
|
5
|
+
export const retryTransient = <A, E, R>(opts: {
|
|
6
|
+
attempt: () => Effect.Effect<A, E, R>;
|
|
7
|
+
isTransient: (error: E) => boolean;
|
|
8
|
+
maxRetries: number;
|
|
9
|
+
}): Effect.Effect<A, E, R> => {
|
|
10
|
+
const loop = (attempt: number): Effect.Effect<A, E, R> =>
|
|
11
|
+
opts
|
|
12
|
+
.attempt()
|
|
13
|
+
.pipe(
|
|
14
|
+
Effect.catch((error: E) =>
|
|
15
|
+
opts.isTransient(error) && attempt < opts.maxRetries
|
|
16
|
+
? Effect.sleep(Duration.millis(BASE_DELAY_MS * 2 ** attempt)).pipe(
|
|
17
|
+
Effect.flatMap(() => loop(attempt + 1)),
|
|
18
|
+
)
|
|
19
|
+
: Effect.fail(error),
|
|
20
|
+
),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return loop(0);
|
|
24
|
+
};
|