import type { PullRequestAction, PullRequestMergeMethod, PullRequestStackHead,} from "@t3tools/contracts";import * as Effect from "effect/Effect";import * as Clock from "effect/Clock";import * as Result from "effect/Result";import * as Schema from "effect/Schema"; import * as GitHubCli from "../sourceControl/GitHubCli.ts";import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; const stackErrorIdentity = { repository: Schema.String, number: Schema.Int, stackNumber: Schema.Int,}; export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChangedError>()( "GitHubStackChangedError", { ...stackErrorIdentity, completed: Schema.Int },) { get detail(): string { return this.message; } override get message(): string { return this.completed > 0 ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` : "The stack changed. Refresh it before trying again."; }} export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackUnsupportedError>()( "GitHubStackUnsupportedError", stackErrorIdentity,) { get detail(): string { return this.message; } override get message(): string { return "This operation is not supported for this stack."; }} export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubStackResponseInvalidError>()( "GitHubStackResponseInvalidError", { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) },) { get detail(): string { return this.message; } override get message(): string { return "GitHub returned an unreadable stack operation response."; }} export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStackMergeRejectedError>()( "GitHubStackMergeRejectedError", { ...stackErrorIdentity, cause: Schema.Defect() },) { get detail(): string { return this.message; } override get message(): string { return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; }} export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStackMergePendingError>()( "GitHubStackMergePendingError", stackErrorIdentity,) { get detail(): string { return this.message; } override get message(): string { return "The merge is still running on GitHub. Check its status there before submitting another request."; }} export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPermissionError>()( "GitHubStackPermissionError", stackErrorIdentity,) { get detail(): string { return this.message; } override get message(): string { return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; }} export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStackRebaseFailedError>()( "GitHubStackRebaseFailedError", { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() },) { get detail(): string { return this.message; } override get message(): string { return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; }} export type GitHubStackActionError = | GitHubStackChangedError | GitHubStackUnsupportedError | GitHubStackResponseInvalidError | GitHubStackMergeRejectedError | GitHubStackMergePendingError | GitHubStackPermissionError | GitHubStackRebaseFailedError; const MergeResponse = Schema.Struct({ status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), details: Schema.Struct({ uuid: Schema.optional(Schema.String), message: Schema.optional(Schema.String), }),}); const decodeBranchAccess = Schema.decodeEffect( Schema.fromJsonString( Schema.Struct({ data: Schema.Struct({ repository: Schema.NullOr( Schema.Record( Schema.String, Schema.NullOr( Schema.Struct({ headRepository: Schema.NullOr( Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), ), maintainerCanModify: Schema.Boolean, }), ), ), ), }), }), ),); const decodeRebaseBranch = Schema.decodeEffect( Schema.fromJsonString( Schema.Struct({ data: Schema.Struct({ processed: Schema.optional( Schema.Array(Schema.NullOr(Schema.Struct({ headRefOid: Schema.String }))), ), repository: Schema.Struct({ pullRequest: Schema.Struct({ id: Schema.String, headRefOid: Schema.String, baseRef: Schema.Struct({ compare: Schema.Struct({ behindBy: Schema.Int }) }), }), }), }), }), ),);const decodeRebaseResponse = Schema.decodeEffect( Schema.fromJsonString( Schema.Struct({ data: Schema.Struct({ updatePullRequestBranch: Schema.Struct({ pullRequest: Schema.Struct({ headRefOid: Schema.String }), }), }), }), ),); const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); /** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { cwd: string; repository: string; host: string; number: number; stackNumber: number; expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; action: PullRequestAction; mergeMethod?: PullRequestMergeMethod;}) { const github = yield* GitHubCli.GitHubCli; const identity = { repository: input.repository, number: input.number, stackNumber: input.stackNumber, }; if (input.action !== "merge" && input.action !== "update-branch") return yield* new GitHubStackUnsupportedError({ ...identity }); const endpoint = `repos/${input.repository}`; const read = yield* github.execute({ cwd: input.cwd, args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`], }); const decoded = decodePullRequestStacksJson(read.stdout); if (Result.isFailure(decoded)) return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); const stack = decoded.success; const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; const target = stack?.layers[targetIndex]; if ( stack?.number !== input.stackNumber || target === undefined || (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) ) { return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); } const affectedLayers = input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; const open = affectedLayers.filter((layer) => layer.state !== "merged"); if (input.action === "merge" && target.state !== "open") return yield* new GitHubStackUnsupportedError({ ...identity }); if ( !input.expectedStackHeads || input.expectedStackHeads.length !== open.length || new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || open.some( (layer) => !layer.headSha || !input.expectedStackHeads?.some( (expected) => expected.number === layer.number && expected.headSha === layer.headSha, ), ) ) { return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); } if (open.length === 0 || open.some((layer) => layer.state !== "open")) return yield* new GitHubStackUnsupportedError({ ...identity }); if (input.action === "update-branch") { const [owner, name] = input.repository.split("/"); const permissions = yield* github.execute({ cwd: input.cwd, args: [ "api", "--hostname", input.host, "graphql", "-f", `owner=${owner}`, "-f", `name=${name}`, "-f", `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open .map( (layer) => `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, ) .join(" ")}}}`, ], }); const access = yield* decodeBranchAccess(permissions.stdout).pipe( Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), ); // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent // will make it stale. Check branch write access separately before touching any layer. if ( open.some((layer) => { const pr = access.data.repository?.[`pr${layer.number}`]; return ( !pr?.headRepository || (!pr.maintainerCanModify && !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) ); }) ) return yield* new GitHubStackPermissionError({ ...identity }); const processed: Array<{ id: string; number: number; headSha: string }> = []; for (const [index, layer] of open.entries()) { yield* Effect.gen(function* () { const read = yield* github.execute({ cwd: input.cwd, args: [ "api", "--hostname", input.host, "graphql", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${layer.number}`, "-f", `sha=${layer.headSha}`, "-f", `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${ processed.length === 0 ? "" : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}` } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, ], }); const { data: { processed: observed, repository: { pullRequest: pr }, }, } = yield* decodeRebaseBranch(read.stdout); // A push to an earlier layer must not silently become the next layer's new base. const changed = processed.find( (head, index) => observed?.[index]?.headRefOid !== head.headSha, ); if (changed !== undefined) return yield* new GitHubStackChangedError({ ...identity, number: changed.number, completed: index, }); if (pr.headRefOid !== layer.headSha) return yield* new GitHubStackChangedError({ ...identity, number: layer.number, completed: index, }); if (pr.baseRef.compare.behindBy === 0) { processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid }); return; } // Pass the reviewed revision to GitHub, including when a push races this read. const updated = yield* github.execute({ cwd: input.cwd, args: [ "api", "--hostname", input.host, "graphql", "-f", `id=${pr.id}`, "-f", `sha=${layer.headSha}`, "-f", "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", ], }); const response = yield* decodeRebaseResponse(updated.stdout); processed.push({ id: pr.id, number: layer.number, headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid, }); }).pipe( Effect.mapError((cause) => cause._tag === "GitHubStackChangedError" ? cause : new GitHubStackRebaseFailedError({ ...identity, number: layer.number, completed: index, cause, }), ), ); } return; } if (open.some((layer) => layer.isDraft)) return yield* new GitHubStackUnsupportedError({ ...identity }); const decode = (raw: string) => decodeMergeResponse(raw).pipe( Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), ); const request = yield* github.execute({ cwd: input.cwd, args: [ "api", "--hostname", input.host, "--method", "PUT", `${endpoint}/pulls/${input.number}/merge-async`, "-f", `merge_method=${input.mergeMethod ?? "merge"}`, "-f", "merge_action=default", "-f", `sha=${target.headSha}`, ], }); let result = yield* decode(request.stdout); const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; for ( let attempt = 0; result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; attempt++ ) { const uuid = result.details.uuid; if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); const poll = yield* github.execute({ cwd: input.cwd, args: [ "api", "--hostname", input.host, `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, ], }); result = yield* decode(poll.stdout); } if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); if (result.status === "failed") return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result });});
import type {
PullRequestAction,
PullRequestMergeMethod,
PullRequestStackHead,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Clock from "effect/Clock";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import * as GitHubCli from "../sourceControl/GitHubCli.ts";
import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";
const stackErrorIdentity = {
repository: Schema.String,
number: Schema.Int,
stackNumber: Schema.Int,
};
export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChangedError>()(
"GitHubStackChangedError",
{ ...stackErrorIdentity, completed: Schema.Int },
) {
get detail(): string {
return this.message;
}
override get message(): string {
return this.completed > 0
? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.`
: "The stack changed. Refresh it before trying again.";
}
}
export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackUnsupportedError>()(
"GitHubStackUnsupportedError",
stackErrorIdentity,
) {
get detail(): string {
return this.message;
}
override get message(): string {
return "This operation is not supported for this stack.";
}
}
export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubStackResponseInvalidError>()(
"GitHubStackResponseInvalidError",
{ ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) },
) {
get detail(): string {
return this.message;
}
override get message(): string {
return "GitHub returned an unreadable stack operation response.";
}
}
export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStackMergeRejectedError>()(
"GitHubStackMergeRejectedError",
{ ...stackErrorIdentity, cause: Schema.Defect() },
) {
get detail(): string {
return this.message;
}
override get message(): string {
return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements.";
}
}
export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStackMergePendingError>()(
"GitHubStackMergePendingError",
stackErrorIdentity,
) {
get detail(): string {
return this.message;
}
override get message(): string {
return "The merge is still running on GitHub. Check its status there before submitting another request.";
}
}
export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPermissionError>()(
"GitHubStackPermissionError",
stackErrorIdentity,
) {
get detail(): string {
return this.message;
}
override get message(): string {
return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying.";
}
}
export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStackRebaseFailedError>()(
"GitHubStackRebaseFailedError",
{ ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() },
) {
get detail(): string {
return this.message;
}
override get message(): string {
return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`;
}
}
export type GitHubStackActionError =
| GitHubStackChangedError
| GitHubStackUnsupportedError
| GitHubStackResponseInvalidError
| GitHubStackMergeRejectedError
| GitHubStackMergePendingError
| GitHubStackPermissionError
| GitHubStackRebaseFailedError;
const MergeResponse = Schema.Struct({
status: Schema.Literals(["pending", "merged", "enqueued", "failed"]),
details: Schema.Struct({
uuid: Schema.optional(Schema.String),
message: Schema.optional(Schema.String),
}),
});
const decodeBranchAccess = Schema.decodeEffect(
Schema.fromJsonString(
Schema.Struct({
data: Schema.Struct({
repository: Schema.NullOr(
Schema.Record(
Schema.String,
Schema.NullOr(
Schema.Struct({
headRepository: Schema.NullOr(
Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }),
),
maintainerCanModify: Schema.Boolean,
}),
),
),
),
}),
}),
),
);
const decodeRebaseBranch = Schema.decodeEffect(
Schema.fromJsonString(
Schema.Struct({
data: Schema.Struct({
processed: Schema.optional(
Schema.Array(Schema.NullOr(Schema.Struct({ headRefOid: Schema.String }))),
),
repository: Schema.Struct({
pullRequest: Schema.Struct({
id: Schema.String,
headRefOid: Schema.String,
baseRef: Schema.Struct({ compare: Schema.Struct({ behindBy: Schema.Int }) }),
}),
}),
}),
}),
),
);
const decodeRebaseResponse = Schema.decodeEffect(
Schema.fromJsonString(
Schema.Struct({
data: Schema.Struct({
updatePullRequestBranch: Schema.Struct({
pullRequest: Schema.Struct({ headRefOid: Schema.String }),
}),
}),
}),
),
);
const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse));
/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */
export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: {
cwd: string;
repository: string;
host: string;
number: number;
stackNumber: number;
expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
action: PullRequestAction;
mergeMethod?: PullRequestMergeMethod;
}) {
const github = yield* GitHubCli.GitHubCli;
const identity = {
repository: input.repository,
number: input.number,
stackNumber: input.stackNumber,
};
if (input.action !== "merge" && input.action !== "update-branch")
return yield* new GitHubStackUnsupportedError({ ...identity });
const endpoint = `repos/${input.repository}`;
const read = yield* github.execute({
cwd: input.cwd,
args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`],
});
const decoded = decodePullRequestStacksJson(read.stdout);
if (Result.isFailure(decoded))
return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure });
const stack = decoded.success;
const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1;
const target = stack?.layers[targetIndex];
if (
stack?.number !== input.stackNumber ||
target === undefined ||
(input.action === "update-branch" && targetIndex !== stack.layers.length - 1)
) {
return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 });
}
const affectedLayers =
input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers;
const open = affectedLayers.filter((layer) => layer.state !== "merged");
if (input.action === "merge" && target.state !== "open")
return yield* new GitHubStackUnsupportedError({ ...identity });
if (
!input.expectedStackHeads ||
input.expectedStackHeads.length !== open.length ||
new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length ||
open.some(
(layer) =>
!layer.headSha ||
!input.expectedStackHeads?.some(
(expected) => expected.number === layer.number && expected.headSha === layer.headSha,
),
)
) {
return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 });
}
if (open.length === 0 || open.some((layer) => layer.state !== "open"))
return yield* new GitHubStackUnsupportedError({ ...identity });
if (input.action === "update-branch") {
const [owner, name] = input.repository.split("/");
const permissions = yield* github.execute({
cwd: input.cwd,
args: [
"api",
"--hostname",
input.host,
"graphql",
"-f",
`owner=${owner}`,
"-f",
`name=${name}`,
"-f",
`query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open
.map(
(layer) =>
`pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`,
)
.join(" ")}}}`,
],
});
const access = yield* decodeBranchAccess(permissions.stdout).pipe(
Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
);
// viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent
// will make it stale. Check branch write access separately before touching any layer.
if (
open.some((layer) => {
const pr = access.data.repository?.[`pr${layer.number}`];
return (
!pr?.headRepository ||
(!pr.maintainerCanModify &&
!["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? ""))
);
})
)
return yield* new GitHubStackPermissionError({ ...identity });
const processed: Array<{ id: string; number: number; headSha: string }> = [];
for (const [index, layer] of open.entries()) {
yield* Effect.gen(function* () {
const read = yield* github.execute({
cwd: input.cwd,
args: [
"api",
"--hostname",
input.host,
"graphql",
"-f",
`owner=${owner}`,
"-f",
`name=${name}`,
"-F",
`number=${layer.number}`,
"-f",
`sha=${layer.headSha}`,
"-f",
`query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${
processed.length === 0
? ""
: `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}`
} repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`,
],
});
const {
data: {
processed: observed,
repository: { pullRequest: pr },
},
} = yield* decodeRebaseBranch(read.stdout);
// A push to an earlier layer must not silently become the next layer's new base.
const changed = processed.find(
(head, index) => observed?.[index]?.headRefOid !== head.headSha,
);
if (changed !== undefined)
return yield* new GitHubStackChangedError({
...identity,
number: changed.number,
completed: index,
});
if (pr.headRefOid !== layer.headSha)
return yield* new GitHubStackChangedError({
...identity,
number: layer.number,
completed: index,
});
if (pr.baseRef.compare.behindBy === 0) {
processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid });
return;
}
// Pass the reviewed revision to GitHub, including when a push races this read.
const updated = yield* github.execute({
cwd: input.cwd,
args: [
"api",
"--hostname",
input.host,
"graphql",
"-f",
`id=${pr.id}`,
"-f",
`sha=${layer.headSha}`,
"-f",
"query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}",
],
});
const response = yield* decodeRebaseResponse(updated.stdout);
processed.push({
id: pr.id,
number: layer.number,
headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid,
});
}).pipe(
Effect.mapError((cause) =>
cause._tag === "GitHubStackChangedError"
? cause
: new GitHubStackRebaseFailedError({
...identity,
number: layer.number,
completed: index,
cause,
}),
),
);
}
return;
}
if (open.some((layer) => layer.isDraft))
return yield* new GitHubStackUnsupportedError({ ...identity });
const decode = (raw: string) =>
decodeMergeResponse(raw).pipe(
Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
);
const request = yield* github.execute({
cwd: input.cwd,
args: [
"api",
"--hostname",
input.host,
"--method",
"PUT",
`${endpoint}/pulls/${input.number}/merge-async`,
"-f",
`merge_method=${input.mergeMethod ?? "merge"}`,
"-f",
"merge_action=default",
"-f",
`sha=${target.headSha}`,
],
});
let result = yield* decode(request.stdout);
const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000;
for (
let attempt = 0;
result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline;
attempt++
) {
const uuid = result.details.uuid;
if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity });
yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000));
const poll = yield* github.execute({
cwd: input.cwd,
args: [
"api",
"--hostname",
input.host,
`${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`,
],
});
result = yield* decode(poll.stdout);
}
if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity });
if (result.status === "failed")
return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result });
});