Skip to content
Merged
53 changes: 53 additions & 0 deletions apps/server/src/issue/AzureDevOpsIssueProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
import * as AzureDevOpsIssueCli from "./AzureDevOpsIssueCli.ts";
import * as AzureDevOpsIssueProvider from "./AzureDevOpsIssueProvider.ts";

it.effect("maps CLI rate limits without changing other Azure DevOps failures", () =>
Effect.gen(function* () {
const context = {
operation: "execute" as const,
command: "az" as const,
cwd: "/repo",
argumentCount: 1,
cause: new Error("provider failure"),
};
for (const [source, reason] of [
[new AzureDevOpsCli.AzureDevOpsCliRateLimitError(context), "rate-limited"],
[new AzureDevOpsCli.AzureDevOpsCliUnavailableError(context), "missing-tool"],
[new AzureDevOpsCli.AzureDevOpsCliAuthenticationError(context), "unauthenticated"],
[new AzureDevOpsCli.AzureDevOpsCommandFailedError(context), "failed"],
] as const) {
const provider = yield* AzureDevOpsIssueProvider.make.pipe(
Effect.provide(
Layer.mock(AzureDevOpsIssueCli.AzureDevOpsIssueCli)({
listWorkItems: () => Effect.fail(source),
runWorkItemAction: () => Effect.fail(source),
}),
),
);
const reference = { cwd: "/repo", repository: "acme/web", host: "dev.azure.com" };
for (const request of [
provider.listIssues({
...reference,
state: "open",
involvement: "all",
viewer: "ada",
limit: 20,
}),
provider.runAction({ ...reference, number: 7, action: "close" }),
]) {
const error = yield* Effect.flip(request);
assert.strictEqual(error._tag, "IssueProviderError");
assert.strictEqual(error.provider, "azure-devops");
assert.strictEqual(error.reason, reason);
assert.strictEqual(error.retryAt, undefined);
assert.strictEqual(error.detail, source.detail);
assert.strictEqual(error.cause, source);
}
}
}),
);
1 change: 1 addition & 0 deletions apps/server/src/issue/AzureDevOpsIssueProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function reasonFor(
): IssueProviderError["reason"] {
if (error._tag === "AzureDevOpsCliUnavailableError") return "missing-tool";
if (error._tag === "AzureDevOpsCliAuthenticationError") return "unauthenticated";
if (error._tag === "AzureDevOpsCliRateLimitError") return "rate-limited";
return "failed";
}

Expand Down
188 changes: 151 additions & 37 deletions apps/server/src/issue/BitbucketIssueProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,160 @@ import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
import * as BitbucketIssueApi from "./BitbucketIssueApi.ts";
import * as BitbucketIssueProvider from "./BitbucketIssueProvider.ts";
import type { BitbucketIssueDetail } from "./bitbucketIssueJson.ts";

it.effect("shows readable issues when permissions fail while refusing writes", () =>
Effect.gen(function* () {
const provider = yield* BitbucketIssueProvider.make.pipe(
Effect.provide(
Layer.mock(BitbucketIssueApi.BitbucketIssueApi)({
getIssue: () =>
Effect.succeed({
number: 7,
title: "Readable issue",
url: "https://bitbucket.org/acme/web/issues/7",
body: "Issue body",
author: null,
assignee: null,
state: "open",
stateReason: null,
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-01T00:00:00Z",
closedAt: null,
milestone: null,
commentCount: 0,
} satisfies BitbucketIssueDetail),
getRepositoryPermission: () =>
Effect.fail(
new BitbucketIssueApi.BitbucketIssueReadError({
operation: "getRepositoryPermission",
cause: new Error("unavailable"),
}),
),
it.effect.each([
[false, undefined],
[false, 120_000],
[true, undefined],
[true, 120_000],
] as const)(
"preserves HTTP 429 retry time with unreadable body=%s, retryAt=%s",
([unreadable, retryAt]) =>
Effect.gen(function* () {
const fields = {
operation: "request",
status: 429,
...(retryAt === undefined ? {} : { retryAt }),
} as const;
const source = unreadable
? new BitbucketApi.BitbucketResponseBodyReadError({ ...fields, cause: new Error("body") })
: new BitbucketApi.BitbucketResponseError({ ...fields, responseBodyLength: 0 });
const provider = yield* BitbucketIssueProvider.make.pipe(
Effect.provide(
Layer.mock(BitbucketIssueApi.BitbucketIssueApi)({
listIssues: () => Effect.fail(source),
runAction: () => Effect.fail(source),
}),
),
);
const reference = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org" };
for (const request of [
provider.listIssues({
...reference,
state: "open",
involvement: "all",
viewer: "ada",
limit: 20,
}),
),
);
const reference = { cwd: "/w", repository: "acme/web", number: 7, host: "bitbucket.org" };
const issue = yield* provider.getIssue(reference);
assert.strictEqual(issue.body, "Issue body");
assert.isFalse(issue.viewerPermissions!.edit);
const error = yield* Effect.flip(provider.getViewerPermissions(reference));
assert.strictEqual(error.operation, "getViewerPermissions");
}),
provider.runAction({ ...reference, number: 7, action: "close" }),
]) {
const error = yield* Effect.flip(request);
assert.strictEqual(error._tag, "IssueProviderError");
assert.strictEqual(error.provider, "bitbucket");
assert.strictEqual(error.reason, "rate-limited");
assert.strictEqual(error.retryAt, retryAt);
assert.strictEqual(error.detail, source.detail);
assert.strictEqual(error.cause, source);
}
}),
);

it.effect.each([
[401, "unauthenticated", "unauthenticated"],
[403, "failed", "failed"],
[404, "tracker-disabled", "failed"],
[500, "failed", "failed"],
] as const)(
"keeps authentication and tracker error mapping for HTTP %s",
([status, collectionReason, itemReason]) =>
Effect.gen(function* () {
const source = new BitbucketApi.BitbucketResponseError({
operation: "request",
status,
responseBodyLength: 0,
});
const provider = yield* BitbucketIssueProvider.make.pipe(
Effect.provide(
Layer.mock(BitbucketIssueApi.BitbucketIssueApi)({
listIssues: () => Effect.fail(source),
createIssue: () => Effect.fail(source),
getIssue: () => Effect.fail(source),
getRepositoryPermission: () => Effect.succeed(false),
}),
),
);
const reference = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org" };
const listError = yield* Effect.flip(
provider.listIssues({
...reference,
state: "open",
involvement: "all",
viewer: "ada",
limit: 20,
}),
);
const createError = yield* Effect.flip(
provider.create({ ...reference, title: "Issue", body: "", labels: [], assignees: [] }),
);
const detailError = yield* Effect.flip(provider.getIssue({ ...reference, number: 7 }));
assert.strictEqual(listError.reason, collectionReason);
assert.strictEqual(createError.reason, collectionReason);
assert.strictEqual(detailError.reason, itemReason);
}),
);

it.effect.each(["failed", "limited", "unreadable"] as const)(
"keeps permission failures safe for %s",
(failure) =>
Effect.gen(function* () {
const provider = yield* BitbucketIssueProvider.make.pipe(
Effect.provide(
Layer.mock(BitbucketIssueApi.BitbucketIssueApi)({
getIssue: () =>
Effect.succeed({
number: 7,
title: "Readable issue",
url: "https://bitbucket.org/acme/web/issues/7",
body: "Issue body",
author: null,
assignee: null,
state: "open",
stateReason: null,
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-01T00:00:00Z",
closedAt: null,
milestone: null,
commentCount: 0,
} satisfies BitbucketIssueDetail),
getRepositoryPermission: () =>
Effect.fail(
failure === "limited"
? new BitbucketApi.BitbucketResponseError({
operation: "request",
status: 429,
responseBodyLength: 0,
retryAt: 120_000,
})
: failure === "unreadable"
? new BitbucketApi.BitbucketResponseBodyReadError({
operation: "request",
status: 429,
retryAt: 120_000,
cause: new Error("body"),
})
: new BitbucketIssueApi.BitbucketIssueReadError({
operation: "getRepositoryPermission",
cause: new Error("unavailable"),
}),
),
}),
),
);
const reference = { cwd: "/w", repository: "acme/web", number: 7, host: "bitbucket.org" };
if (failure !== "failed") {
const error = yield* Effect.flip(provider.getIssue(reference));
assert.strictEqual(error.reason, "rate-limited");
assert.strictEqual(error.retryAt, 120_000);
return;
}
const issue = yield* provider.getIssue(reference);
assert.strictEqual(issue.body, "Issue body");
assert.isFalse(issue.viewerPermissions!.edit);
const error = yield* Effect.flip(provider.getViewerPermissions(reference));
assert.strictEqual(error.operation, "getViewerPermissions");
}),
);
18 changes: 16 additions & 2 deletions apps/server/src/issue/BitbucketIssueProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ function bitbucketIssueErrorReason(
error: BitbucketIssueApi.BitbucketIssueApiError,
operation: string,
): IssueProviderError["reason"] {
if (error._tag !== "BitbucketResponseError") return "failed";
if (error._tag !== "BitbucketResponseError" && error._tag !== "BitbucketResponseBodyReadError")
return "failed";
if (error.status === 401) return "unauthenticated";
if (error.status === 429) return "rate-limited";
// A switched-off tracker answers 404 on the issues collection itself — `listIssues` and
// `create` are the only operations that ask it directly. A 404 on one issue by number means
// only that issue is gone, which is an ordinary failure the reader can act on.
Expand Down Expand Up @@ -102,6 +104,12 @@ export const make = Effect.gen(function* () {
provider: "bitbucket",
operation,
reason: bitbucketIssueErrorReason(error, operation),
...((error._tag === "BitbucketResponseError" ||
error._tag === "BitbucketResponseBodyReadError") &&
error.status === 429 &&
error.retryAt !== undefined
? { retryAt: error.retryAt }
: {}),
// Every Bitbucket failure states its own fact; this names the operation around it, so the
// two do not stack into "failed in x: failed in y: ...".
detail: error.detail,
Expand Down Expand Up @@ -152,7 +160,13 @@ export const make = Effect.gen(function* () {
api.getIssue(target),
api
.getRepositoryPermission({ repository: input.repository })
.pipe(Effect.orElseSucceed(() => false)),
.pipe(
Effect.catch((error) =>
bitbucketIssueErrorReason(error, "getRepositoryPermission") === "rate-limited"
? Effect.fail(error)
: Effect.succeed(false),
),
),
],
{ concurrency: 2 },
).pipe(
Expand Down
Loading
Loading