Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import { AuthOrchestrationReadScope, EnvironmentAuthorizationError } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

Expand Down Expand Up @@ -418,3 +419,31 @@ describe("what one diff slice spends", () => {
}),
);
});

it.effect.each(["changed files", "threads"] as const)(
"preserves typed denial from %s instead of returning an unavailable read",
(method) =>
Effect.gen(function* () {
const denial = new EnvironmentAuthorizationError({
requiredScope: AuthOrchestrationReadScope,
message: "fixture denial",
});
const provider = yield* make.pipe(
Effect.provide(
Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
getPullRequest: () => Effect.succeed(PULL_REQUEST),
listIterations: () => Effect.fail(denial),
listThreads: () => Effect.fail(denial),
}),
),
);
const input = { cwd: "/w", host: "dev.azure.com", repository: "acme/web", number: 7 };
expect(
yield* (
method === "changed files"
? provider.getChangeRequest(input).pipe(Effect.asVoid)
: provider.getChangeRequestActivity(input).pipe(Effect.asVoid)
).pipe(Effect.flip),
).toBe(denial);
}),
);
21 changes: 15 additions & 6 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
beforeCurrentWorkspaceEffect,
recoverWorkspaceError,
} from "../auth/workspaceReadAccess.ts";
import * as Effect from "effect/Effect";
import * as Semaphore from "effect/Semaphore";
import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
Expand Down Expand Up @@ -189,15 +193,20 @@ export const make = Effect.gen(function* () {
*/
const locations = new Map<string, AzureDevOpsRepositoryLocation>();

const locationOf = (input: { readonly cwd: string; readonly number: number }) => {
const locationOf = Effect.fn("AzureDevOpsPullRequestProvider.locationOf")(function* (input: {
readonly cwd: string;
readonly number: number;
}) {
const key = `${input.cwd} ${input.number}`;
yield* beforeCurrentWorkspaceEffect;
const held = locations.get(key);
if (held !== undefined) {
locations.delete(key);
locations.set(key, held);
return Effect.succeed(held);
return held;
}
return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
return yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
Effect.tap(() => beforeCurrentWorkspaceEffect),
Effect.map((pullRequest) => {
const location = pullRequest.location;
if (location === null) return null;
Expand All @@ -209,7 +218,7 @@ export const make = Effect.gen(function* () {
return location;
}),
);
};
});

/**
* Everything a diff read needs before it can ask for a file: where the repository lives, and
Expand Down Expand Up @@ -370,7 +379,7 @@ export const make = Effect.gen(function* () {
}),
),
Effect.map((listed) => listed.changes.length),
Effect.orElseSucceed(() => 0),
recoverWorkspaceError(() => 0),
);
const detail: ProviderChangeRequestDetail = {
...toChangeRequest(pullRequest),
Expand Down Expand Up @@ -404,7 +413,7 @@ export const make = Effect.gen(function* () {
})
.pipe(
Effect.map((comments) => ({ comments, truncated: false })),
Effect.orElseSucceed(() => ({ comments: [], truncated: true })),
recoverWorkspaceError(() => ({ comments: [], truncated: true })),
)
).pipe(
Effect.map((conversation): ProviderChangeRequestActivity => ({
Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/pullRequest/BitbucketPullRequestApi.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { beforeCurrentWorkspaceEffect } from "../auth/workspaceReadAccess.ts";
import * as Cache from "effect/Cache";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
Expand Down Expand Up @@ -591,7 +592,9 @@ export const make = Effect.gen(function* () {
const revisionPatches = yield* Cache.makeWith(
(key: string) => {
const [repository, number] = JSON.parse(key) as [string, number];
return pullRequestDiff({ repository, number }).pipe(
return beforeCurrentWorkspaceEffect.pipe(
Effect.andThen(pullRequestDiff({ repository, number })),
Effect.tap(() => beforeCurrentWorkspaceEffect),
Effect.map((diff) => ({
revisions: parseDiffFileRevisions(diff.patch),
truncated: diff.truncated,
Expand Down Expand Up @@ -682,7 +685,11 @@ export const make = Effect.gen(function* () {
getFileRevisions: (input) =>
input.paths.length === 0
? Effect.succeed({ revisions: new Map(), complete: false })
: Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe(
: beforeCurrentWorkspaceEffect.pipe(
Effect.andThen(
Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])),
),
Effect.tap(() => beforeCurrentWorkspaceEffect),
Effect.map((diff) => {
const revisions = new Map(diff.revisions);
// A patch cut short at the byte ceiling says nothing about the files past the cut,
Expand Down
44 changes: 27 additions & 17 deletions apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import { AuthOrchestrationReadScope, EnvironmentAuthorizationError } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Result from "effect/Result";
Expand All @@ -19,8 +20,8 @@ for (const operation of [
"listComments",
"listCommits",
] as const) {
it.effect.each(["response", "body read"])(
`preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`,
it.effect.each(["response", "body read", "authorization"])(
`preserves rate limits and authorization from ${operation} on %s errors while recovering ordinary optional-read failures`,
(variant) =>
Effect.gen(function* () {
const pullRequest = Result.getOrThrow(
Expand All @@ -33,7 +34,11 @@ for (const operation of [
"links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } }
}`),
);
for (const status of [429, 403]) {
const denial = new EnvironmentAuthorizationError({
requiredScope: AuthOrchestrationReadScope,
message: "fixture denial",
});
for (const status of variant === "authorization" ? [403] : [429, 403]) {
const provider = yield* make.pipe(
Effect.provide(
Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
Expand All @@ -46,19 +51,21 @@ for (const operation of [
listCommits: () => Effect.succeed([]),
[operation]: () =>
Effect.fail(
variant === "response"
? new BitbucketApi.BitbucketResponseError({
operation: "request",
status,
responseBodyLength: 0,
retryAt: 120_000,
})
: new BitbucketApi.BitbucketResponseBodyReadError({
operation: "request",
status,
cause: new Error("response stream failed"),
retryAt: 120_000,
}),
variant === "authorization"
? denial
: variant === "response"
? new BitbucketApi.BitbucketResponseError({
operation: "request",
status,
responseBodyLength: 0,
retryAt: 120_000,
})
: new BitbucketApi.BitbucketResponseBodyReadError({
operation: "request",
status,
cause: new Error("response stream failed"),
retryAt: 120_000,
}),
),
}),
),
Expand All @@ -72,7 +79,10 @@ for (const operation of [
const result = yield* operation === "listComments" || operation === "listCommits"
? Effect.result(provider.getChangeRequestActivity(reference))
: Effect.result(provider.getChangeRequest(reference));
if (status === 429) {
if (variant === "authorization") {
expect(result._tag).toBe("Failure");
if (result._tag === "Failure") expect(result.failure).toBe(denial);
} else if (status === 429) {
expect(result).toMatchObject({
_tag: "Failure",
failure: { reason: "rate-limited", retryAt: 120_000 },
Expand Down
141 changes: 141 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { afterEach, assert, expect, it, vi } from "@effect/vitest";
import { AuthOrchestrationReadScope, EnvironmentAuthorizationError } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Clock from "effect/Clock";
import * as Deferred from "effect/Deferred";
Expand All @@ -19,6 +20,7 @@ import * as GitHubQuota from "../sourceControl/githubQuota.ts";
import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";

const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
Expand Down Expand Up @@ -4586,3 +4588,142 @@ layer("GitHubPullRequestApi.layer", (it) => {
}),
);
});

layer("workflow REST recoveries", (it) => {
const input = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 };
const ordinaryFailure = new GitHubApi.GitHubApiResponseError({
host: "github.com",
operation: "listWorkflowRunsRequiringApproval",
status: 403,
});
const denial = new EnvironmentAuthorizationError({
requiredScope: AuthOrchestrationReadScope,
message: "workflow admission denied",
});
const routeFailure = (error: GitHubApi.GitHubApiError) =>
mockedExecute.mockImplementation((call) =>
call.kind === "rest"
? Effect.fail(error)
: Effect.succeed(
output(
encodeJson(
call.query.includes("headRefName: $head") ? heads([7]) : crossRepositoryDetail(),
),
),
),
);

it.effect.each(["denial", "ordinary"] as const)(
"workflow REST %s retains the actual PR API error channel",
(failure) =>
Effect.gen(function* () {
routeFailure(failure === "denial" ? denial : ordinaryFailure);
const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
const error = yield* cli
.listWorkflowRunsRequiringApproval({
...input,
headSha: "abc123",
headBranch: "feat/page",
headRepositoryOwner: "octocat",
isCrossRepository: true,
})
.pipe(Effect.flip);
if (failure === "denial") {
expect(error).toBe(denial);
} else {
expect(error._tag).toBe("GitHubPullRequestReadError");
if (error._tag === "GitHubPullRequestReadError") {
expect(error.operation).toBe("listWorkflowRunsRequiringApproval");
expect(error.cause).toBe(ordinaryFailure);
}
}
expect(restCallsTo("actions/runs?")).toHaveLength(1);
}),
);

it.effect.each([
{ read: "detail", failure: "denial" },
{ read: "checks", failure: "denial" },
{ read: "detail", failure: "ordinary" },
{ read: "checks", failure: "ordinary" },
] as const)(
"workflow REST $read read preserves $failure through the production provider",
({ read, failure }) =>
Effect.gen(function* () {
routeFailure(failure === "denial" ? denial : ordinaryFailure);
const provider = yield* GitHubPullRequestProvider.make;
const operation =
read === "detail" ? provider.getChangeRequest : provider.getChangeRequestChecks;
if (operation === undefined) return yield* Effect.die("checks read missing");
const result = yield* Effect.result(operation(input));
if (failure === "denial") {
expect(result._tag).toBe("Failure");
if (result._tag === "Failure") expect(result.failure).toBe(denial);
} else {
expect(result._tag).toBe("Success");
if (result._tag === "Success") {
expect(result.success.checks).toContainEqual({
name: "Workflow approval status",
status: "action-required",
description: "GitHub could not determine whether workflows are awaiting approval.",
url: null,
});
}
}
expect(restCallsTo("actions/runs?")).toHaveLength(1);
}),
);
});

layer("authorization recoveries", (it) => {
it.effect("optional stack badges preserve typed denial rather than returning the listing", () =>
Effect.gen(function* () {
const denial = new EnvironmentAuthorizationError({
requiredScope: AuthOrchestrationReadScope,
message: "fixture denial",
});
mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 1))));
mockedStackMemberships.mockReturnValueOnce(Effect.fail(denial));
const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
expect(
yield* cli
.listPullRequests({
cwd: "/w",
host: "github.com",
repository: "acme/web",
state: "open",
involvement: "all",
viewer: "viewer",
limit: 10,
})
.pipe(Effect.flip),
).toBe(denial);
expect(mockedExecute).toHaveBeenCalledTimes(1);
expect(mockedStackMemberships).toHaveBeenCalledTimes(1);
}),
);
it.effect(
"files fallback preserves its typed denial instead of relabeling it as the original HTTP refusal",
() =>
Effect.gen(function* () {
const denial = new EnvironmentAuthorizationError({
requiredScope: AuthOrchestrationReadScope,
message: "fixture denial",
});
mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));
mockedExecute.mockReturnValueOnce(Effect.fail(denial));
const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
expect(
yield* cli
.getPullRequestDiff({
cwd: "/w",
host: "github.com",
repository: "acme/web",
number: 7,
})
.pipe(Effect.flip),
).toBe(denial);
expect(mockedExecute).toHaveBeenCalledTimes(2);
}),
);
});
Loading
Loading