Skip to content
Merged
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
59 changes: 59 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6214,6 +6214,65 @@ it.effect("names the signed-in account in the detail, and says nothing where the
}),
);

it.effect("partial host viewed writes invalidate marks but keep the diff cached", () =>
Effect.gen(function* () {
let diffReads = 0;
const states = new Map<string, "viewed" | "unviewed">([
["a.ts", "unviewed"],
["b.ts", "unviewed"],
]);
const service = yield* makeService({
projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
providers: [
fakeProvider(SourceControlProviderKind.make("github"), {
capabilities: {
...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
viewedFiles: "host",
},
getDiff: () =>
Effect.sync(() => {
diffReads += 1;
return { patch: "@@", truncated: false, nextCursor: null };
}),
getFilesViewed: () =>
Effect.sync(() => ({
files: [...states].map(([path, state]) => ({ path, state })),
truncated: false,
})),
setFilesViewed: () =>
Effect.gen(function* () {
states.set("a.ts", "viewed");
return yield* new PullRequestProviderError({
provider: SourceControlProviderKind.make("github"),
operation: "setFilesViewed",
reason: "failed",
detail: "Second mutation refused",
});
}),
}),
],
});
const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 10 };
yield* service.diff(ref);
assert.deepStrictEqual((yield* service.filesViewed(ref)).files, [
{ path: "a.ts", state: "unviewed" },
{ path: "b.ts", state: "unviewed" },
]);
yield* Effect.flip(
service.setFilesViewed({
...ref,
files: [...states.keys()].map((path) => ({ path, viewed: true })),
}),
);
yield* service.diff(ref);
assert.strictEqual(diffReads, 1);
assert.deepStrictEqual((yield* service.filesViewed(ref)).files, [
{ path: "a.ts", state: "viewed" },
{ path: "b.ts", state: "unviewed" },
]);
}),
);

it.effect("keeps the diff cached across a file being ticked off", () =>
Effect.gen(function* () {
let diffReads = 0;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1771,7 +1771,7 @@ export const make = Effect.gen(function* () {
// Deliberately not `invalidatedByMutation`: ticking a file off says nothing about the
// change request, and dropping a 300-file diff on every checkbox is the whole cost of
// the feature.
Effect.tap(() => Effect.sync(() => bumpFilesViewedEpoch(ref))),
Effect.ensuring(Effect.sync(() => bumpFilesViewedEpoch(ref))),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type PullRequestFilesViewedResult,
type PullRequestRef,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import { act, StrictMode, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
Expand Down Expand Up @@ -47,7 +48,13 @@ const reference: PullRequestRef = {
};

function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesViewedResult {
return { files: [{ path: "a.ts", state }], truncated: false };
return {
files: [
{ path: "a.ts", state },
{ path: "b.ts", state: "unviewed" },
],
truncated: false,
};
}

let root: Root;
Expand Down Expand Up @@ -148,3 +155,26 @@ describe("a mark the host has not answered for yet", () => {
expect(view().isStale("a.ts")).toBe(false);
});
});

describe("a failed batch that the host partly applied", () => {
it("refreshes the host and accepts the applied marks", async () => {
setFilesViewed.mockResolvedValueOnce(
AsyncResult.failure(Cause.fail(new Error("partial batch"))),
);
host.refresh.mockImplementation(() => {
host.data = answer("viewed");
root.render(
<StrictMode>
<Surface />
</StrictMode>,
);
});
act(() => {
view().setViewed("a.ts", true);
view().setViewed("b.ts", true);
});
await act(async () => vi.advanceTimersByTimeAsync(500));
expect(view().isViewed("a.ts")).toBe(true);
expect(view().isViewed("b.ts")).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export function usePullRequestFilesViewed(options: {
setOverlay((current) => revertFileViewedOverlay(current, batch, owned));
if (owned.size > 0 && !isAtomCommandInterrupted(result)) {
toastManager.add({ type: "error", title: "Could not update viewed files" });
refresh();
}
return;
}
Expand Down
69 changes: 33 additions & 36 deletions packages/client-runtime/src/state/pullRequestRouting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ const invalidateTarget = Effect.fn("PullRequestRouting.invalidateTarget")(functi
inputs,
(input) =>
registry.run(target, request(WS_METHODS.pullRequestsInvalidate, input)).pipe(
// GitHub already accepted the write. A stalled reader must not delay its confirmation.
// A stalled reader must not delay the write's answer.
Effect.timeoutOption("1 second"),
Effect.orElseSucceed(() => undefined),
),
Expand Down Expand Up @@ -272,42 +272,39 @@ export function createPullRequestRouter() {
ref.repository.toLowerCase(),
String(ref.number),
]);
const finish = (operation: ReturnType<typeof request<T>>) =>
operation.pipe(
Effect.tap(() => {
if (!writes.has(tag) || used.size === 0) return Effect.void;
const targets = new Map<EnvironmentId, PullRequestRef[]>([
[origin.target.environmentId, [ref]],
]);
for (const entry of used.values()) {
if (
entry.origin !== origin.target.environmentId ||
!matchesReference(entry.reference, ref)
)
continue;
for (const target of [...entry.targets, origin.target.environmentId]) {
const refs = targets.get(target) ?? [];
if (!refs.some((existing) => existing.host === entry.reference.host))
refs.push(entry.reference);
targets.set(target, refs);
}
}
return Effect.forEach(
targets,
([target, refs]) =>
invalidateTarget(registry, origin.target.environmentId, target, [
...refs.map((reference) => ({
reference,
...(tag === WS_METHODS.pullRequestsSetFilesViewed
? { filesViewedOnly: true }
: {}),
})),
...(tag === WS_METHODS.pullRequestsSetFilesViewed ? [] : [{}]),
]),
{ concurrency: 4, discard: true },
);
}),
const invalidateReaders = Effect.suspend(() => {
if (!writes.has(tag) || used.size === 0) return Effect.void;
const targets = new Map<EnvironmentId, PullRequestRef[]>([
[origin.target.environmentId, [ref]],
]);
for (const entry of used.values()) {
if (entry.origin !== origin.target.environmentId || !matchesReference(entry.reference, ref))
continue;
for (const target of [...entry.targets, origin.target.environmentId]) {
const refs = targets.get(target) ?? [];
if (!refs.some((existing) => existing.host === entry.reference.host))
refs.push(entry.reference);
targets.set(target, refs);
}
}
return Effect.forEach(
targets,
([target, refs]) =>
invalidateTarget(registry, origin.target.environmentId, target, [
...refs.map((reference) => ({
reference,
...(tag === WS_METHODS.pullRequestsSetFilesViewed ? { filesViewedOnly: true } : {}),
})),
...(tag === WS_METHODS.pullRequestsSetFilesViewed ? [] : [{}]),
]),
{ concurrency: 4, discard: true },
);
});
// A failed viewed-files batch may have applied some paths. Other writes invalidate on success.
const finish = (operation: ReturnType<typeof request<T>>) =>
tag === WS_METHODS.pullRequestsSetFilesViewed
? operation.pipe(Effect.ensuring(invalidateReaders))
: operation.pipe(Effect.tap(() => invalidateReaders));
const alternatives = [];
for (const [id, entry] of entries) {
if (id === origin.target.environmentId) continue;
Expand Down
64 changes: 64 additions & 0 deletions packages/client-runtime/src/state/pullRequests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,70 @@ it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)(
),
);

it.effect("forgets routed viewed marks when a viewed-files batch fails", () =>
Effect.scoped(
Effect.gen(function* () {
const reference = {
projectId: ProjectId.make("project-1"),
host: "github.com",
repository: "acme/web",
number: 7,
};
const invalidations: { environment: string; input: unknown }[] = [];
const identity = () =>
Effect.succeed({
host: reference.host,
provider: "github",
accountId: "123",
viewer: "maria-rcks",
});
const clientFor = (environment: string) =>
({
[WS_METHODS.pullRequestsRouting]: identity,
[WS_METHODS.pullRequestsRoutingIdentity]: identity,
[WS_METHODS.pullRequestsFilesViewed]: () =>
Effect.succeed({ files: [{ path: "a.ts", state: "unviewed" }], truncated: false }),
// The host may have applied part of the batch before refusing the rest.
[WS_METHODS.pullRequestsSetFilesViewed]: () => Effect.fail("second path refused"),
[WS_METHODS.pullRequestsInvalidate]: (input: unknown) =>
Effect.sync(() => {
invalidations.push({ environment, input });
}),
}) as unknown as WsRpcProtocolClient;
const { environmentRegistry, supervisor } = yield* makeTestRuntime(
clientFor("origin"),
clientFor("local"),
);
const route = createPullRequestRouter();
const write = yield* Effect.gen(function* () {
yield* route(WS_METHODS.pullRequestsFilesViewed, reference);
return yield* Effect.exit(
route(WS_METHODS.pullRequestsSetFilesViewed, {
...reference,
files: [
{ path: "a.ts", viewed: true },
{ path: "b.ts", viewed: true },
],
}),
);
}).pipe(
Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
Effect.provideService(GitHubRoutingPermissions, trustedRouting),
Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
);
expect(write._tag).toBe("Failure");
expect(invalidations).toContainEqual({
environment: "local",
input: { reference: expect.objectContaining(reference), filesViewedOnly: true },
});
for (const call of invalidations) {
expect(call.input).toMatchObject({ filesViewedOnly: true });
}
}),
),
);

const TARGET = new PrimaryConnectionTarget({
environmentId: EnvironmentId.make("environment-1"),
label: "Test environment",
Expand Down
Loading