import { WS_METHODS, type PullRequestDetail, type PullRequestDiffInput, type PullRequestSummary, type VcsStatusResult,} from "@t3tools/contracts";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import * as Option from "effect/Option";import * as SubscriptionRef from "effect/SubscriptionRef";import { Atom } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, createEnvironmentQueryAtomFamily,} from "./runtime.ts";import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";import type { EnvironmentRegistry } from "../connection/registry.ts";import { EnvironmentSupervisor } from "../connection/supervisor.ts"; export { type PullRequestDiffLoadError, PullRequestDiffCredentialRejectedError, PullRequestDiffLoader, pullRequestDiffLoaderLayer,} from "./pullRequestDiffHttp.ts"; export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( "EnvironmentHttpConnectionNotReadyError",)<{ readonly message: string }> {} export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; function createPullRequestRefreshAtomFamily<R, E>( runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,) { return createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:pull-requests:turn-refreshes", tag: WS_METHODS.pullRequestsSubscribeRefreshes, });} /** Refresh only the live fields a linked thread renders. */export function createLinkedPullRequestSummaryAtomFamily<R, E>( runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, refreshes = createPullRequestRefreshAtomFamily(runtime),) { return createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:linked-summary", tag: WS_METHODS.pullRequestsSummary, staleTimeMs: 60_000, refreshIntervalMs: 60_000, idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), });} export function pullRequestDetailToVcsStatus( detail: PullRequestDetail | PullRequestSummary,): NonNullable<VcsStatusResult["pr"]> { return { number: detail.number, title: detail.title, url: detail.url, baseRef: detail.baseBranch, headRef: detail.headBranch, state: detail.state, ...(detail.isDraft === true ? { isDraft: true } : {}), updatedAt: detail.updatedAt, };} /** * Reopening a PR within a minute reuses detail and activity. Explicit refreshes and * turn notifications still revalidate. Mutations run serially per environment: actions on the same * pull request are order-sensitive, and the detail view refetches after each one. */export function createPullRequestEnvironmentAtoms<R, E>( runtime: Atom.AtomRuntime<EnvironmentRegistry | PullRequestDiffLoader | R, E>,) { const refreshes = createPullRequestRefreshAtomFamily(runtime); const commandScheduler = createAtomCommandScheduler(); const serialPerEnvironment = { mode: "serial", key: ({ environmentId }: { readonly environmentId: string }) => environmentId, } as const; const activity = createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:activity", tag: WS_METHODS.pullRequestsActivity, staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }); return { refreshes, list: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:list", tag: WS_METHODS.pullRequestsList, staleTimeMs: 30_000, refreshTrigger: ({ environmentId, input }) => input.cursors === undefined ? refreshes({ environmentId, input: {} }) : undefined, }), /** * The line counts for rows the listing has already handed over. Its own query because the * listing is quicker without them — measured over twelve repositories, ~4.0s against ~7.1s — * so the rows arrive first and their stats a moment later. Kept longer than the listing: * a change request's size only moves when somebody pushes to it. */ listStats: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:list-stats", tag: WS_METHODS.pullRequestsListStats, staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), detail: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:detail", tag: WS_METHODS.pullRequestsDetail, staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), activity, threadComments: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:thread-comments", tag: WS_METHODS.pullRequestsThreadComments, scheduler: commandScheduler, concurrency: { mode: "singleFlight", key: ({ environmentId, input }) => JSON.stringify([environmentId, input.threadId, input.cursor]), }, }), diff: createEnvironmentQueryAtomFamily(runtime, { label: "environment-data:pull-requests:diff", staleTimeMs: 60_000, execute: (input: PullRequestDiffInput) => Effect.gen(function* () { const supervisor = yield* EnvironmentSupervisor; const loader = yield* PullRequestDiffLoader; const prepared = yield* SubscriptionRef.get(supervisor.prepared); if (Option.isNone(prepared)) { return yield* new EnvironmentHttpConnectionNotReadyError({ message: "The environment HTTP connection is not ready.", }); } return yield* loader.load(prepared.value, input); }), }), diffFileContents: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:diff-file-contents", tag: WS_METHODS.pullRequestsDiffFileContents, scheduler: commandScheduler, concurrency: { mode: "singleFlight", key: ({ environmentId, input }) => JSON.stringify([ environmentId, input.projectId, input.repository, input.number, input.commit ?? null, input.changeType, input.oldPath, input.newPath, ]), }, }), runAction: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:run-action", tag: WS_METHODS.pullRequestsRunAction, scheduler: commandScheduler, concurrency: serialPerEnvironment, }), update: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:update", tag: WS_METHODS.pullRequestsUpdate, scheduler: commandScheduler, concurrency: serialPerEnvironment, }), comment: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:comment", tag: WS_METHODS.pullRequestsComment, scheduler: commandScheduler, concurrency: serialPerEnvironment, }), updateComment: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:update-comment", tag: WS_METHODS.pullRequestsUpdateComment, scheduler: commandScheduler, concurrency: serialPerEnvironment, onSuccess: ({ environmentId, input: { projectId, repository, number } }, registry) => Effect.sync(() => registry.refresh(activity({ environmentId, input: { projectId, repository, number } })), ), }), submitReview: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:submit-review", tag: WS_METHODS.pullRequestsSubmitReview, scheduler: commandScheduler, concurrency: serialPerEnvironment, }), replyToThread: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:reply-to-thread", tag: WS_METHODS.pullRequestsReplyToThread, scheduler: commandScheduler, concurrency: serialPerEnvironment, }), /** * Its own query rather than part of the detail: the people who may be asked are only wanted * once somebody opens the reviewer menu, so this atom is read then and not before. Kept fresh * for a minute, because who has access to a repository changes far more slowly than the * change request it is being read for. */ reviewerCandidates: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:reviewer-candidates", tag: WS_METHODS.pullRequestsReviewerCandidates, staleTimeMs: 60_000, }), requestReviewers: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:request-reviewers", tag: WS_METHODS.pullRequestsRequestReviewers, scheduler: commandScheduler, concurrency: serialPerEnvironment, }), /** Read when the label menu opens, and kept for a minute, like the reviewer candidates. */ labelCandidates: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:label-candidates", tag: WS_METHODS.pullRequestsLabelCandidates, staleTimeMs: 60_000, }), setLabels: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:set-labels", tag: WS_METHODS.pullRequestsSetLabels, scheduler: commandScheduler, concurrency: serialPerEnvironment, }), setThreadResolution: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:set-thread-resolution", tag: WS_METHODS.pullRequestsSetThreadResolution, scheduler: commandScheduler, concurrency: serialPerEnvironment, }), setReaction: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:set-reaction", tag: WS_METHODS.pullRequestsSetReaction, scheduler: commandScheduler, concurrency: serialPerEnvironment, }), /** * Explicit refresh: forget the server's cached answers, then re-run the reads. A separate * request rather than a flag on a read, so only a person's refresh spends host requests * while every silent re-read shares the cache. */ invalidate: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:invalidate", tag: WS_METHODS.pullRequestsInvalidate, scheduler: commandScheduler, concurrency: serialPerEnvironment, }), };}
import {
WS_METHODS,
type PullRequestDetail,
type PullRequestDiffInput,
type PullRequestSummary,
type VcsStatusResult,
} from "@t3tools/contracts";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as SubscriptionRef from "effect/SubscriptionRef";
import { Atom } from "effect/unstable/reactivity";
import {
createAtomCommandScheduler,
createEnvironmentRpcCommand,
createEnvironmentRpcQueryAtomFamily,
createEnvironmentRpcSubscriptionAtomFamily,
createEnvironmentQueryAtomFamily,
} from "./runtime.ts";
import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
import type { EnvironmentRegistry } from "../connection/registry.ts";
import { EnvironmentSupervisor } from "../connection/supervisor.ts";
export {
type PullRequestDiffLoadError,
PullRequestDiffCredentialRejectedError,
PullRequestDiffLoader,
pullRequestDiffLoaderLayer,
} from "./pullRequestDiffHttp.ts";
export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(
"EnvironmentHttpConnectionNotReadyError",
)<{ readonly message: string }> {}
export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000;
function createPullRequestRefreshAtomFamily<R, E>(
runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
) {
return createEnvironmentRpcSubscriptionAtomFamily(runtime, {
label: "environment-data:pull-requests:turn-refreshes",
tag: WS_METHODS.pullRequestsSubscribeRefreshes,
});
}
/** Refresh only the live fields a linked thread renders. */
export function createLinkedPullRequestSummaryAtomFamily<R, E>(
runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
refreshes = createPullRequestRefreshAtomFamily(runtime),
) {
return createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:linked-summary",
tag: WS_METHODS.pullRequestsSummary,
staleTimeMs: 60_000,
refreshIntervalMs: 60_000,
idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
});
}
export function pullRequestDetailToVcsStatus(
detail: PullRequestDetail | PullRequestSummary,
): NonNullable<VcsStatusResult["pr"]> {
return {
number: detail.number,
title: detail.title,
url: detail.url,
baseRef: detail.baseBranch,
headRef: detail.headBranch,
state: detail.state,
...(detail.isDraft === true ? { isDraft: true } : {}),
updatedAt: detail.updatedAt,
};
}
/**
* Reopening a PR within a minute reuses detail and activity. Explicit refreshes and
* turn notifications still revalidate. Mutations run serially per environment: actions on the same
* pull request are order-sensitive, and the detail view refetches after each one.
*/
export function createPullRequestEnvironmentAtoms<R, E>(
runtime: Atom.AtomRuntime<EnvironmentRegistry | PullRequestDiffLoader | R, E>,
) {
const refreshes = createPullRequestRefreshAtomFamily(runtime);
const commandScheduler = createAtomCommandScheduler();
const serialPerEnvironment = {
mode: "serial",
key: ({ environmentId }: { readonly environmentId: string }) => environmentId,
} as const;
const activity = createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:activity",
tag: WS_METHODS.pullRequestsActivity,
staleTimeMs: 60_000,
refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
});
return {
refreshes,
list: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:list",
tag: WS_METHODS.pullRequestsList,
staleTimeMs: 30_000,
refreshTrigger: ({ environmentId, input }) =>
input.cursors === undefined ? refreshes({ environmentId, input: {} }) : undefined,
}),
/**
* The line counts for rows the listing has already handed over. Its own query because the
* listing is quicker without them — measured over twelve repositories, ~4.0s against ~7.1s —
* so the rows arrive first and their stats a moment later. Kept longer than the listing:
* a change request's size only moves when somebody pushes to it.
*/
listStats: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:list-stats",
tag: WS_METHODS.pullRequestsListStats,
staleTimeMs: 60_000,
refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
}),
detail: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:detail",
tag: WS_METHODS.pullRequestsDetail,
staleTimeMs: 60_000,
refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
}),
activity,
threadComments: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:thread-comments",
tag: WS_METHODS.pullRequestsThreadComments,
scheduler: commandScheduler,
concurrency: {
mode: "singleFlight",
key: ({ environmentId, input }) =>
JSON.stringify([environmentId, input.threadId, input.cursor]),
},
}),
diff: createEnvironmentQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:diff",
staleTimeMs: 60_000,
execute: (input: PullRequestDiffInput) =>
Effect.gen(function* () {
const supervisor = yield* EnvironmentSupervisor;
const loader = yield* PullRequestDiffLoader;
const prepared = yield* SubscriptionRef.get(supervisor.prepared);
if (Option.isNone(prepared)) {
return yield* new EnvironmentHttpConnectionNotReadyError({
message: "The environment HTTP connection is not ready.",
});
}
return yield* loader.load(prepared.value, input);
}),
}),
diffFileContents: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:diff-file-contents",
tag: WS_METHODS.pullRequestsDiffFileContents,
scheduler: commandScheduler,
concurrency: {
mode: "singleFlight",
key: ({ environmentId, input }) =>
JSON.stringify([
environmentId,
input.projectId,
input.repository,
input.number,
input.commit ?? null,
input.changeType,
input.oldPath,
input.newPath,
]),
},
}),
runAction: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:run-action",
tag: WS_METHODS.pullRequestsRunAction,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
update: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:update",
tag: WS_METHODS.pullRequestsUpdate,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
comment: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:comment",
tag: WS_METHODS.pullRequestsComment,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
updateComment: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:update-comment",
tag: WS_METHODS.pullRequestsUpdateComment,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
onSuccess: ({ environmentId, input: { projectId, repository, number } }, registry) =>
Effect.sync(() =>
registry.refresh(activity({ environmentId, input: { projectId, repository, number } })),
),
}),
submitReview: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:submit-review",
tag: WS_METHODS.pullRequestsSubmitReview,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
replyToThread: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:reply-to-thread",
tag: WS_METHODS.pullRequestsReplyToThread,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
/**
* Its own query rather than part of the detail: the people who may be asked are only wanted
* once somebody opens the reviewer menu, so this atom is read then and not before. Kept fresh
* for a minute, because who has access to a repository changes far more slowly than the
* change request it is being read for.
*/
reviewerCandidates: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:reviewer-candidates",
tag: WS_METHODS.pullRequestsReviewerCandidates,
staleTimeMs: 60_000,
}),
requestReviewers: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:request-reviewers",
tag: WS_METHODS.pullRequestsRequestReviewers,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
/** Read when the label menu opens, and kept for a minute, like the reviewer candidates. */
labelCandidates: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:label-candidates",
tag: WS_METHODS.pullRequestsLabelCandidates,
staleTimeMs: 60_000,
}),
setLabels: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:set-labels",
tag: WS_METHODS.pullRequestsSetLabels,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
setThreadResolution: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:set-thread-resolution",
tag: WS_METHODS.pullRequestsSetThreadResolution,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
setReaction: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:set-reaction",
tag: WS_METHODS.pullRequestsSetReaction,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
/**
* Explicit refresh: forget the server's cached answers, then re-run the reads. A separate
* request rather than a flag on a read, so only a person's refresh spends host requests
* while every silent re-read shares the cache.
*/
invalidate: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:invalidate",
tag: WS_METHODS.pullRequestsInvalidate,
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
};
}