Skip to content
9 changes: 9 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ export class GitWorkflowService extends Context.Service<
readonly cwd: string;
readonly remoteName: string;
}) => Effect.Effect<boolean, GitCommandError>;
readonly remoteBranchExists: (input: {
readonly cwd: string;
readonly remoteName: string;
readonly refName: string;
}) => Effect.Effect<boolean, GitCommandError>;
readonly resolveRemoteTrackingCommit: (input: {
readonly cwd: string;
readonly refName: string;
Expand Down Expand Up @@ -396,6 +401,10 @@ export const make = Effect.gen(function* () {
ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe(
Effect.andThen(git.remoteExists(input)),
),
remoteBranchExists: (input) =>
ensureGitCommand("GitWorkflowService.remoteBranchExists", input.cwd).pipe(
Effect.andThen(git.remoteBranchExists(input)),
),
resolveRemoteTrackingCommit: (input) =>
ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd, {
allowBare: true,
Expand Down
42 changes: 33 additions & 9 deletions apps/server/src/provider/Layers/OpenCodeProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import * as NodeAssert from "node:assert/strict";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import { beforeEach } from "vite-plus/test";

import { OpenCodeSettings } from "@t3tools/contracts";
Expand Down Expand Up @@ -34,6 +36,7 @@ const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
const runtimeMock = {
state: {
runVersionError: null as Error | null,
runVersionPending: false,
versionStdout: DEFAULT_VERSION_STDOUT,
inventoryError: null as Error | null,
connectionError: null as Error | null,
Expand All @@ -52,6 +55,7 @@ const runtimeMock = {
},
reset() {
this.state.runVersionError = null;
this.state.runVersionPending = false;
this.state.versionStdout = DEFAULT_VERSION_STDOUT;
this.state.inventoryError = null;
this.state.connectionError = null;
Expand Down Expand Up @@ -114,15 +118,17 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
};
}),
runOpenCodeCommand: () =>
runtimeMock.state.runVersionError
? Effect.fail(
new OpenCodeRuntimeError({
operation: "runOpenCodeCommand",
detail: runtimeMock.state.runVersionError.message,
cause: runtimeMock.state.runVersionError,
}),
)
: Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
runtimeMock.state.runVersionPending
? Effect.never
: runtimeMock.state.runVersionError
? Effect.fail(
new OpenCodeRuntimeError({
operation: "runOpenCodeCommand",
detail: runtimeMock.state.runVersionError.message,
cause: runtimeMock.state.runVersionError,
}),
)
: Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
createOpenCodeSdkClient: (input) => {
runtimeMock.state.sdkClientInputs.push(input);
return {} as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>;
Expand Down Expand Up @@ -218,6 +224,24 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
}),
);

it.effect("times out a hanging local CLI version probe", () =>
Effect.gen(function* () {
runtimeMock.state.runVersionPending = true;
const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild);

yield* Effect.yieldNow;
yield* TestClock.adjust("4 seconds");
const snapshot = yield* Fiber.join(probeFiber);

NodeAssert.equal(snapshot.status, "error");
NodeAssert.equal(snapshot.installed, true);
NodeAssert.equal(
snapshot.message,
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
);
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
Effect.gen(function* () {
runtimeMock.state.inventory = {
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/provider/Layers/OpenCodeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ const OPENCODE_PRESENTATION = {
displayName: "OpenCode",
showInteractionModeToggle: false,
} as const;
const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds";

class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
readonly cause: unknown;
readonly cause?: unknown;
readonly detail: string;
}> {}

Expand Down Expand Up @@ -401,6 +402,15 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
Effect.mapError(
(cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }),
),
Effect.timeoutOrElse({
duration: OPENCODE_VERSION_PROBE_TIMEOUT,
orElse: () =>
Effect.fail(
new OpenCodeProbeError({
detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`,
}),
),
}),
),
);
if (versionExit._tag === "Failure") {
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/provider/opencodeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,11 +565,23 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment);
const child = yield* spawner.spawn(
ChildProcess.make(spawnCommand.command, spawnCommand.args, {
detached: hostPlatform !== "win32",
shell: spawnCommand.shell,
...(input.cwd ? { cwd: input.cwd } : {}),
...(input.environment ? { env: input.environment } : { extendEnv: true }),
}),
);
const terminateCommandGroup =
hostPlatform === "win32"
? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid)
: Effect.sync(() => {
try {
process.kill(-Number(child.pid), "SIGKILL");
} catch {
// The command and its process group may already have exited.
}
});
yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore));
const collectOptions =
input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes };
const [stdout, stderr, code] = yield* Effect.all(
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,46 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
}),
);

it.effect("reads an Azure pull request page larger than the VCS default output limit", () =>
Effect.gen(function* () {
const rows = pullRequestRows(100, 1).map((row) => ({
...row,
description: "x".repeat(10_000),
}));
// @effect-diagnostics-next-line preferSchemaOverJson:off
const response = JSON.stringify(rows);
expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000);

mockedExecute.mockImplementationOnce((input) => {
const maxOutputBytes =
"maxOutputBytes" in input && typeof input.maxOutputBytes === "number"
? input.maxOutputBytes
: 1_000_000;
return Effect.succeed(
maxOutputBytes >= Buffer.byteLength(response)
? output(response)
: {
...output(response.slice(0, maxOutputBytes)),
stdoutTruncated: true,
},
);
});
const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;

const batch = yield* cli.listPullRequests({
cwd: "/w",
repository: "web",
state: "merged",
involvement: "all",
viewer: "bilal@acme.dev",
limit: 99,
});

assert.strictEqual(batch.items.length, 99);
assert.isTrue(batch.truncated);
}),
);

it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export type AzureDevOpsPullRequestCliError =

/** The version every REST call below is pinned to, so a new default cannot reshape a response. */
const REST_API_VERSION = "7.1";
const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;

export class AzureDevOpsPullRequestCli extends Context.Service<
AzureDevOpsPullRequestCli,
Expand Down Expand Up @@ -258,10 +259,15 @@ export const make = Effect.gen(function* () {
// how to read all of them.
const detectArgs = ["--detect", "true"] as const;

const executeJson = (input: { readonly cwd: string; readonly args: ReadonlyArray<string> }) =>
const executeJson = (input: {
readonly cwd: string;
readonly args: ReadonlyArray<string>;
readonly maxOutputBytes?: number;
}) =>
azure.execute({
cwd: input.cwd,
args: [...input.args, "--only-show-errors", "--output", "json"],
...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
});

/**
Expand Down Expand Up @@ -290,6 +296,7 @@ export const make = Effect.gen(function* () {
const top = remaining + 1;
return executeJson({
cwd: input.cwd,
maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES,
args: [
"repos",
"pr",
Expand Down
Loading
Loading