feat: prepare issue workspaces from connected repositories
This commit is contained in:
436
packages/primitives/src/project-workspace.ts
Normal file
436
packages/primitives/src/project-workspace.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/* eslint-disable max-classes-per-file -- workspace errors stay next to their contracts. */
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
import { CONTEXT_KINDS } from "./project.js";
|
||||
|
||||
export type { ContextKind } from "./project.js";
|
||||
|
||||
const MeaningfulString = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
const PositiveInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
|
||||
|
||||
const WORKSPACE_PATH = "/workspace";
|
||||
const CHECKOUT_PATH = "/workspace/repository";
|
||||
const CONTROL_PATH = "/workspace/control";
|
||||
|
||||
const isSafeRelativePath = (value: string): boolean => {
|
||||
if (value.length === 0 || value.startsWith("/") || value.includes("\\")) {
|
||||
return false;
|
||||
}
|
||||
return !value
|
||||
.split("/")
|
||||
.some((segment) => segment === "" || segment === "." || segment === "..");
|
||||
};
|
||||
|
||||
const isValidGitRef = (value: string): boolean =>
|
||||
value.length > 0 &&
|
||||
!value.startsWith("/") &&
|
||||
!value.endsWith("/") &&
|
||||
!value.startsWith(".") &&
|
||||
!value.endsWith(".") &&
|
||||
!value.includes("..") &&
|
||||
!value.includes("//") &&
|
||||
!value.includes("@{") &&
|
||||
![...value].some((character) => character < " ") &&
|
||||
!/[ ~^:?*[\\]/u.test(value);
|
||||
|
||||
const isPublicGitUrl = (value: string): boolean => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
(url.protocol === "http:" || url.protocol === "https:") &&
|
||||
url.hostname.length > 0 &&
|
||||
url.pathname !== "/" &&
|
||||
url.username.length === 0 &&
|
||||
url.password.length === 0 &&
|
||||
url.search.length === 0 &&
|
||||
url.hash.length === 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const shellQuote = (value: string): string =>
|
||||
`'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
|
||||
const slugIdentity = (value: string): string => {
|
||||
const parts = value
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/gu)
|
||||
.filter(Boolean);
|
||||
let result = "";
|
||||
for (const part of parts) {
|
||||
result = part;
|
||||
}
|
||||
return result.replaceAll(/^-+|-+$/gu, "").slice(-24) || "issue";
|
||||
};
|
||||
|
||||
export const WorkspaceContextFile = Schema.Struct({
|
||||
content: Schema.String,
|
||||
kind: Schema.Literals([...CONTEXT_KINDS]),
|
||||
path: MeaningfulString,
|
||||
});
|
||||
export type WorkspaceContextFile = typeof WorkspaceContextFile.Type;
|
||||
|
||||
export const WorkspaceArtifactFile = Schema.Struct({
|
||||
content: Schema.String,
|
||||
path: MeaningfulString,
|
||||
});
|
||||
export type WorkspaceArtifactFile = typeof WorkspaceArtifactFile.Type;
|
||||
|
||||
export const IssueWorkspaceInput = Schema.Struct({
|
||||
artifacts: Schema.Array(WorkspaceArtifactFile),
|
||||
branchName: Schema.UndefinedOr(MeaningfulString),
|
||||
checkoutPath: Schema.UndefinedOr(MeaningfulString),
|
||||
contextFiles: Schema.Array(WorkspaceContextFile),
|
||||
defaultBranch: Schema.UndefinedOr(MeaningfulString),
|
||||
issueBody: MeaningfulString,
|
||||
issueId: MeaningfulString,
|
||||
issueNumber: PositiveInteger,
|
||||
issueTitle: MeaningfulString,
|
||||
sourceUrl: Schema.UndefinedOr(MeaningfulString),
|
||||
});
|
||||
export type IssueWorkspaceInput = typeof IssueWorkspaceInput.Type;
|
||||
|
||||
export const WorkspaceExecOperation = Schema.Struct({
|
||||
_tag: Schema.Literal("Exec"),
|
||||
command: MeaningfulString,
|
||||
cwd: MeaningfulString,
|
||||
timeoutMs: PositiveInteger,
|
||||
});
|
||||
|
||||
export const WorkspaceMkdirOperation = Schema.Struct({
|
||||
_tag: Schema.Literal("Mkdir"),
|
||||
path: MeaningfulString,
|
||||
});
|
||||
|
||||
export const WorkspaceWriteFileOperation = Schema.Struct({
|
||||
_tag: Schema.Literal("WriteFile"),
|
||||
content: Schema.String,
|
||||
path: MeaningfulString,
|
||||
});
|
||||
|
||||
export const IssueWorkspaceOperation = Schema.Union([
|
||||
WorkspaceExecOperation,
|
||||
WorkspaceMkdirOperation,
|
||||
WorkspaceWriteFileOperation,
|
||||
]);
|
||||
export type IssueWorkspaceOperation = typeof IssueWorkspaceOperation.Type;
|
||||
|
||||
export const IssueWorkspacePlan = Schema.Struct({
|
||||
artifactsRoot: MeaningfulString,
|
||||
baseBranch: MeaningfulString,
|
||||
branchName: MeaningfulString,
|
||||
checkoutPath: MeaningfulString,
|
||||
contextRoot: MeaningfulString,
|
||||
issuePath: MeaningfulString,
|
||||
operations: Schema.Array(IssueWorkspaceOperation),
|
||||
sourceUrl: MeaningfulString,
|
||||
});
|
||||
export type IssueWorkspacePlan = typeof IssueWorkspacePlan.Type;
|
||||
|
||||
export const IssueWorkspaceCommandResult = Schema.Struct({
|
||||
exitCode: Schema.Int,
|
||||
stderr: Schema.String,
|
||||
stdout: Schema.String,
|
||||
});
|
||||
export type IssueWorkspaceCommandResult =
|
||||
typeof IssueWorkspaceCommandResult.Type;
|
||||
|
||||
export const IssueWorkspaceResult = Schema.Struct({
|
||||
baseBranch: MeaningfulString,
|
||||
branchName: MeaningfulString,
|
||||
checkoutPath: MeaningfulString,
|
||||
headSha: MeaningfulString,
|
||||
sourceUrl: MeaningfulString,
|
||||
});
|
||||
export type IssueWorkspaceResult = typeof IssueWorkspaceResult.Type;
|
||||
|
||||
export const WorkspaceValidationReason = Schema.Literals([
|
||||
"MissingSource",
|
||||
"InvalidInput",
|
||||
"InvalidSourceUrl",
|
||||
"InvalidBranchName",
|
||||
"InvalidPath",
|
||||
"DuplicatePath",
|
||||
]);
|
||||
export type WorkspaceValidationReason = typeof WorkspaceValidationReason.Type;
|
||||
|
||||
export class WorkspaceValidationError extends Schema.TaggedErrorClass<WorkspaceValidationError>()(
|
||||
"WorkspaceValidationError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: WorkspaceValidationReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const WorkspaceExecutionReason = Schema.Literals([
|
||||
"CommandFailed",
|
||||
"InvalidResult",
|
||||
]);
|
||||
export type WorkspaceExecutionReason = typeof WorkspaceExecutionReason.Type;
|
||||
|
||||
export class WorkspaceExecutionError extends Schema.TaggedErrorClass<WorkspaceExecutionError>()(
|
||||
"WorkspaceExecutionError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: WorkspaceExecutionReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const WorkspaceRunStatus = Schema.Literals([
|
||||
"ready",
|
||||
"queued",
|
||||
"working",
|
||||
"needs-input",
|
||||
"completed",
|
||||
"failed",
|
||||
]);
|
||||
export type WorkspaceRunStatus = typeof WorkspaceRunStatus.Type;
|
||||
|
||||
export class WorkspaceStateError extends Schema.TaggedErrorClass<WorkspaceStateError>()(
|
||||
"WorkspaceStateError",
|
||||
{
|
||||
from: WorkspaceRunStatus,
|
||||
message: Schema.String,
|
||||
to: WorkspaceRunStatus,
|
||||
}
|
||||
) {}
|
||||
|
||||
const validationError = (message: string, reason: WorkspaceValidationReason) =>
|
||||
new WorkspaceValidationError({ message, reason });
|
||||
|
||||
const requireWorkspaceSource = (sourceUrl: string | undefined) => {
|
||||
if (!sourceUrl || sourceUrl.trim().length === 0) {
|
||||
return Effect.fail(
|
||||
validationError("Project source is not connected", "MissingSource")
|
||||
);
|
||||
}
|
||||
if (!isPublicGitUrl(sourceUrl)) {
|
||||
return Effect.fail(
|
||||
validationError(
|
||||
"Issue workspace source must be a public http(s) Git URL",
|
||||
"InvalidSourceUrl"
|
||||
)
|
||||
);
|
||||
}
|
||||
return Effect.succeed(sourceUrl);
|
||||
};
|
||||
|
||||
const validateFiles = (
|
||||
files: readonly { readonly path: string }[],
|
||||
root: string
|
||||
): Effect.Effect<void, WorkspaceValidationError> =>
|
||||
Effect.gen(function* validateWorkspaceFiles() {
|
||||
const paths = new Set<string>();
|
||||
for (const file of files) {
|
||||
if (!isSafeRelativePath(file.path)) {
|
||||
return yield* Effect.fail(
|
||||
validationError(
|
||||
`Invalid workspace file path: ${file.path}`,
|
||||
"InvalidPath"
|
||||
)
|
||||
);
|
||||
}
|
||||
const path = `${root}/${file.path}`;
|
||||
if (paths.has(path)) {
|
||||
return yield* Effect.fail(
|
||||
validationError(
|
||||
`Duplicate workspace file path: ${path}`,
|
||||
"DuplicatePath"
|
||||
)
|
||||
);
|
||||
}
|
||||
paths.add(path);
|
||||
}
|
||||
});
|
||||
|
||||
const makeBranchName = (issueNumber: number, issueId: string): string =>
|
||||
`work/issue-${issueNumber}-${slugIdentity(issueId)}`;
|
||||
|
||||
const makeCheckoutCommand = (input: {
|
||||
readonly baseBranch: string;
|
||||
readonly branchName: string;
|
||||
readonly checkoutPath: string;
|
||||
readonly sourceUrl: string;
|
||||
}): string => {
|
||||
const checkout = shellQuote(input.checkoutPath);
|
||||
const baseBranch = shellQuote(input.baseBranch);
|
||||
const branch = shellQuote(input.branchName);
|
||||
const originBaseBranch = shellQuote(`origin/${input.baseBranch}`);
|
||||
const source = shellQuote(input.sourceUrl);
|
||||
|
||||
return [
|
||||
"set -eu",
|
||||
`mkdir -p ${shellQuote(WORKSPACE_PATH)}`,
|
||||
`if [ ! -d ${checkout}/.git ]; then git clone --branch ${baseBranch} --single-branch ${source} ${checkout}; fi`,
|
||||
`if [ "$(git -C ${checkout} branch --show-current)" != ${branch} ]; then git -C ${checkout} show-ref --verify --quiet refs/heads/${branch} && git -C ${checkout} checkout ${branch} || git -C ${checkout} checkout -b ${branch} ${originBaseBranch}; fi`,
|
||||
`printf '%s\\n' "$(git -C ${checkout} branch --show-current)" "$(git -C ${checkout} rev-parse HEAD)"`,
|
||||
].join(" && ");
|
||||
};
|
||||
|
||||
export const makeIssueWorkspacePlan = Effect.fn("ProjectWorkspace.makePlan")(
|
||||
function* makeIssueWorkspacePlan(rawInput: unknown) {
|
||||
const input: IssueWorkspaceInput = yield* Schema.decodeUnknownEffect(
|
||||
IssueWorkspaceInput
|
||||
)(rawInput).pipe(
|
||||
Effect.mapError(() =>
|
||||
validationError("Invalid issue workspace input", "InvalidInput")
|
||||
)
|
||||
);
|
||||
|
||||
const sourceUrl = yield* requireWorkspaceSource(input.sourceUrl);
|
||||
|
||||
const baseBranch = input.defaultBranch?.trim() || "main";
|
||||
const branchName =
|
||||
input.branchName?.trim() ||
|
||||
makeBranchName(input.issueNumber, input.issueId);
|
||||
const checkoutPath = input.checkoutPath?.trim() || CHECKOUT_PATH;
|
||||
|
||||
if (!isValidGitRef(baseBranch) || !isValidGitRef(branchName)) {
|
||||
return yield* Effect.fail(
|
||||
validationError(
|
||||
"Issue workspace branch name is not a valid Git ref",
|
||||
"InvalidBranchName"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (
|
||||
checkoutPath !== CHECKOUT_PATH ||
|
||||
!checkoutPath.startsWith(`${WORKSPACE_PATH}/`) ||
|
||||
checkoutPath.includes("..")
|
||||
) {
|
||||
return yield* Effect.fail(
|
||||
validationError(
|
||||
"Issue workspace checkout path must be /workspace/repository",
|
||||
"InvalidPath"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const contextRoot = `${CONTROL_PATH}/context`;
|
||||
const artifactsRoot = `${CONTROL_PATH}/artifacts`;
|
||||
yield* validateFiles(input.contextFiles, contextRoot);
|
||||
yield* validateFiles(input.artifacts, artifactsRoot);
|
||||
|
||||
const operations: IssueWorkspaceOperation[] = [
|
||||
{ _tag: "Mkdir", path: CONTROL_PATH },
|
||||
{
|
||||
_tag: "Exec",
|
||||
command: makeCheckoutCommand({
|
||||
baseBranch,
|
||||
branchName,
|
||||
checkoutPath,
|
||||
sourceUrl,
|
||||
}),
|
||||
cwd: WORKSPACE_PATH,
|
||||
timeoutMs: 300_000,
|
||||
},
|
||||
{ _tag: "Mkdir", path: contextRoot },
|
||||
{ _tag: "Mkdir", path: artifactsRoot },
|
||||
{
|
||||
_tag: "WriteFile",
|
||||
content: `# Issue ${input.issueNumber}: ${input.issueTitle}\n\n${input.issueBody}\n`,
|
||||
path: `${CONTROL_PATH}/issue.md`,
|
||||
},
|
||||
...input.contextFiles.map((file) => ({
|
||||
_tag: "WriteFile" as const,
|
||||
content: file.content,
|
||||
path: `${contextRoot}/${file.path}`,
|
||||
})),
|
||||
...input.artifacts.map((file) => ({
|
||||
_tag: "WriteFile" as const,
|
||||
content: file.content,
|
||||
path: `${artifactsRoot}/${file.path}`,
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
artifactsRoot,
|
||||
baseBranch,
|
||||
branchName,
|
||||
checkoutPath,
|
||||
contextRoot,
|
||||
issuePath: `${CONTROL_PATH}/issue.md`,
|
||||
operations,
|
||||
sourceUrl,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const decodeIssueWorkspaceResult = Effect.fn(
|
||||
"ProjectWorkspace.decodeResult"
|
||||
)(function* decodeIssueWorkspaceResult(input: {
|
||||
readonly command: IssueWorkspaceCommandResult;
|
||||
readonly plan: IssueWorkspacePlan;
|
||||
}) {
|
||||
if (input.command.exitCode !== 0) {
|
||||
return yield* Effect.fail(
|
||||
new WorkspaceExecutionError({
|
||||
message:
|
||||
input.command.stderr || "Issue workspace checkout command failed",
|
||||
reason: "CommandFailed",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const [branchName, headSha] = input.command.stdout.trim().split(/\s+/u);
|
||||
if (
|
||||
branchName !== input.plan.branchName ||
|
||||
!headSha ||
|
||||
!/^[0-9a-f]{7,64}$/u.test(headSha)
|
||||
) {
|
||||
return yield* Effect.fail(
|
||||
new WorkspaceExecutionError({
|
||||
message:
|
||||
"Issue workspace checkout command returned invalid branch metadata",
|
||||
reason: "InvalidResult",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
baseBranch: input.plan.baseBranch,
|
||||
branchName,
|
||||
checkoutPath: input.plan.checkoutPath,
|
||||
headSha,
|
||||
sourceUrl: input.plan.sourceUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const allowedTransitions: Readonly<
|
||||
Record<WorkspaceRunStatus, readonly WorkspaceRunStatus[]>
|
||||
> = {
|
||||
completed: [],
|
||||
failed: [],
|
||||
"needs-input": ["queued", "working", "failed"],
|
||||
queued: ["working", "completed", "failed"],
|
||||
ready: ["queued", "working", "failed"],
|
||||
working: ["needs-input", "completed", "failed"],
|
||||
};
|
||||
|
||||
export const transitionWorkspaceRun = Effect.fn(
|
||||
"ProjectWorkspace.transitionRun"
|
||||
)(function* transitionWorkspaceRun(input: {
|
||||
readonly from: WorkspaceRunStatus;
|
||||
readonly to: WorkspaceRunStatus;
|
||||
}) {
|
||||
if (
|
||||
input.from === input.to ||
|
||||
allowedTransitions[input.from].includes(input.to)
|
||||
) {
|
||||
return input.to;
|
||||
}
|
||||
return yield* Effect.fail(
|
||||
new WorkspaceStateError({
|
||||
from: input.from,
|
||||
message: `Cannot transition issue workspace run from ${input.from} to ${input.to}`,
|
||||
to: input.to,
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user