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
61 changes: 61 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3404,6 +3404,67 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>
}),
);

it.effect(
"keeps listing freshness tied to read start when filtered reads finish out of order",
() =>
Effect.gen(function* () {
const olderStarted = yield* Deferred.make<void>();
const releaseOlder = yield* Deferred.make<void>();
let reads = 0;
const updatedAt = "2026-07-02T00:00:00Z";
const service = yield* makeService({
projects: [
project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
],
providers: [
fakeProvider("github", {
listChangeRequests: ({ filters }) =>
Effect.gen(function* () {
reads += 1;
const older = filters?.checks === "failing";
if (older) {
yield* Deferred.succeed(olderStarted, undefined);
yield* Deferred.await(releaseOlder);
}
return {
items: [
{
...changeRequest(1, updatedAt),
checksState: older ? ("failing" as const) : ("passing" as const),
mergeability: older ? ("mergeable" as const) : ("conflicting" as const),
},
],
truncated: false,
continues: false,
};
}),
}),
],
});
const olderInput = { state: "open" as const, filters: { checks: "failing" as const } };
const newerInput = { state: "open" as const, filters: { checks: "passing" as const } };

const olderRead = yield* service.list(olderInput).pipe(Effect.forkChild());
yield* Deferred.await(olderStarted);
yield* TestClock.adjust("1 second");
const newer = yield* service.list(newerInput);
yield* Deferred.succeed(releaseOlder, undefined);
const older = yield* Fiber.join(olderRead);

assert.strictEqual(older.entries[0]?.checksState, "failing");
assert.strictEqual(older.entries[0]?.mergeability, "mergeable");
assert.strictEqual(newer.entries[0]?.checksState, "passing");
assert.strictEqual(newer.entries[0]?.mergeability, "conflicting");
assert.strictEqual(typeof older.entries[0]?.observedAt, "number");
assert.strictEqual(typeof newer.entries[0]?.observedAt, "number");
assert.isBelow(older.entries[0]!.observedAt!, newer.entries[0]!.observedAt!);

const cachedOlder = yield* service.list(olderInput);
assert.strictEqual(cachedOlder.entries[0]?.observedAt, older.entries[0]?.observedAt);
assert.strictEqual(reads, 2);
}),
);

it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and project turn", () =>
Effect.gen(function* () {
const calls: string[] = [];
Expand Down
31 changes: 23 additions & 8 deletions apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,12 @@ function withRateLimitBackoff(
Record<Exclude<keyof PullRequestProviderApi, keyof typeof wrapped>, never>;
}

// Capture before the provider read so a slow response keeps its original freshness through caches.
const observeRead = Effect.fnUntraced(function* <A, E, R>(read: Effect.Effect<A, E, R>) {
const observedAt = yield* Clock.currentTimeMillis;
return { value: yield* read, observedAt };
});

export const make = Effect.gen(function* () {
const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64);
const pullRequestRefreshes = yield* SubscriptionRef.make(0);
Expand Down Expand Up @@ -1076,6 +1082,7 @@ export const make = Effect.gen(function* () {
readonly project: SupportedProject;
readonly item: ProviderChangeRequest;
readonly viewer: string;
readonly observedAt: number;
}): PullRequestListEntry => {
const viewer = input.viewer.toLowerCase();
return {
Expand All @@ -1098,6 +1105,7 @@ export const make = Effect.gen(function* () {
deletions: input.item.deletions,
createdAt: input.item.createdAt,
updatedAt: input.item.updatedAt,
observedAt: input.observedAt,
...(input.item.checksState === undefined || input.item.checksState === null
? {}
: { checksState: input.item.checksState }),
Expand Down Expand Up @@ -1246,7 +1254,8 @@ export const make = Effect.gen(function* () {
}),
})
.pipe(
Effect.map((page): RepositoryBatch => {
observeRead,
Effect.map(({ value: page, observedAt }): RepositoryBatch => {
// The boundary instant was asked for inclusively, so the rows already sent at it
// come back with the slice. Dropping them here rather than asking for strictly
// older is what keeps their neighbours at the same instant from being skipped.
Expand All @@ -1262,7 +1271,7 @@ export const make = Effect.gen(function* () {
key,
entries: items
.filter((item) => matchesRowFilters(item, input.filters, viewer))
.map((item) => toEntry({ project, item, viewer })),
.map((item) => toEntry({ project, item, viewer, observedAt })),
errors: [],
truncated: page.truncated,
nextCursor:
Expand Down Expand Up @@ -1323,7 +1332,8 @@ export const make = Effect.gen(function* () {
? {}
: { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }),
}).pipe(
Effect.flatMap((page) =>
observeRead,
Effect.flatMap(({ value: page, observedAt }) =>
Effect.flatMap(Clock.currentTimeMillis, (now) => {
const rows = new Map<string, Array<ProviderChangeRequest>>();
for (const [key, visibleAt] of searchVisibleAt) {
Expand Down Expand Up @@ -1384,7 +1394,7 @@ export const make = Effect.gen(function* () {
key: project.cursorKey,
entries: items
.filter((item) => matchesRowFilters(item, input.filters, viewer))
.map((item) => toEntry({ project, item, viewer })),
.map((item) => toEntry({ project, item, viewer, observedAt })),
errors: [],
truncated: page.truncated,
nextCursor:
Expand Down Expand Up @@ -1551,7 +1561,8 @@ export const make = Effect.gen(function* () {
: project.api.getChangeRequestSummary(providerInput);
return read.pipe(
Effect.mapError(toPullRequestError("summary")),
Effect.map((changeRequest): PullRequestSummary => ({
observeRead,
Effect.map(({ value: changeRequest, observedAt }): PullRequestSummary => ({
provider: project.api.kind,
projectId: project.project.id,
repository: project.repository,
Expand All @@ -1564,6 +1575,7 @@ export const make = Effect.gen(function* () {
closedAt: changeRequest.closedAt ?? null,
mergedAt: changeRequest.mergedAt ?? null,
updatedAt: changeRequest.updatedAt,
observedAt,
...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }),
...(changeRequest.author === undefined ? {} : { author: changeRequest.author }),
...(changeRequest.additions === undefined
Expand Down Expand Up @@ -1634,12 +1646,12 @@ export const make = Effect.gen(function* () {
host: project.host,
number: input.number,
})
.pipe(Effect.mapError(toPullRequestError("detail"))),
.pipe(Effect.mapError(toPullRequestError("detail")), observeRead),
viewerOf(project),
],
{ concurrency: 2 },
).pipe(
Effect.map(([changeRequest, viewer]): PullRequestDetail => ({
Effect.map(([{ value: changeRequest, observedAt }, viewer]): PullRequestDetail => ({
provider: project.api.kind,
capabilities: project.api.capabilities,
projectId: project.project.id,
Expand All @@ -1664,6 +1676,7 @@ export const make = Effect.gen(function* () {
baseBranch: changeRequest.baseBranch,
createdAt: changeRequest.createdAt,
updatedAt: changeRequest.updatedAt,
observedAt,
mergedAt: changeRequest.mergedAt,
closedAt: changeRequest.closedAt,
reviewers: changeRequest.reviewers,
Expand Down Expand Up @@ -2894,12 +2907,14 @@ export const make = Effect.gen(function* () {
closedAt: detail.closedAt,
mergedAt: detail.mergedAt,
updatedAt: detail.updatedAt,
observedAt: detail.observedAt,
});
const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => {
const current = lastGoodSummary.peek(key);
if (current === undefined) return true;
if (current.state === "merged" && next.state !== "merged") return false;
return next.updatedAt >= current.updatedAt;
if (next.updatedAt !== current.updatedAt) return next.updatedAt > current.updatedAt;
return (next.observedAt ?? -Infinity) >= (current.observedAt ?? -Infinity);
};
const detail: PullRequestService["Service"]["detail"] = (input) => {
const key = refCacheKey(input);
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/pullRequest/gitHubPullRequestJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ describe("pull request list decoding", () => {
{ statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] },
// Neither a pass, a failure nor a wait is no verdict rather than a green tick.
{ statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] },
// Cancelled reads as failing here and in the detail header, so the two never flap.
{
statusCheckRollup: [
{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" },
{ name: "test", status: "COMPLETED", conclusion: "CANCELLED" },
],
},
{ statusCheckRollup: [] },
{},
]),
Expand All @@ -132,6 +139,7 @@ describe("pull request list decoding", () => {
"passing",
"failing",
null,
"failing",
null,
null,
]);
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/pullRequest/gitHubPullRequestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1447,8 +1447,9 @@ function toCheckEntries(
* GitHub's own indicator reads: a run that has already gone red will not go green by finishing.
*
* Null rather than "passing" for a head commit with no checks at all, so a repository that runs
* none shows nothing instead of a green tick it never earned. Checks whose verdict is neither a
* pass, a failure nor a wait — skipped, cancelled, neutral — count towards neither.
* none shows nothing instead of a green tick it never earned. A cancelled run is a failure, as
* GitHub's own rollup and the client's detail rollup both read it; skipped and neutral count
* towards neither, so the row and the detail header never disagree about one head commit.
*
* Counted off the deduped checks rather than the raw rollup, so the word and the list under it
* cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is
Expand All @@ -1463,7 +1464,7 @@ function rollupChecksState(
...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)),
];
if (statuses.length === 0) return null;
if (statuses.includes("failure")) return "failing";
if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing";
if (statuses.includes("pending") || statuses.includes("action-required")) return "pending";
return statuses.includes("success") ? "passing" : null;
}
Expand Down
28 changes: 20 additions & 8 deletions apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
Expand Down Expand Up @@ -62,7 +63,11 @@ import { ScrollArea } from "~/components/ui/scroll-area";
import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
import { faviconUrlForOrigin } from "~/lib/favicon";
import { useTheme } from "~/hooks/useTheme";
import { pullRequestEnvironment } from "~/state/pullRequests";
import {
newestPullRequestSummary,
pullRequestEnvironment,
useSharedPullRequestSummary,
} from "~/state/pullRequests";
import { useEnvironmentQuery } from "~/state/query";
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";

Expand Down Expand Up @@ -800,19 +805,26 @@ function PullRequestSurfaceIcon({
},
}),
).data;
const reference = useMemo(
() => ({
projectId: surface.projectId as ProjectId,
repository: surface.repository,
number: surface.number,
}),
[surface.projectId, surface.repository, surface.number],
);
const sharedSummary = useSharedPullRequestSummary(resolvedEnvironmentId, reference, null);
// The compact tab intentionally shows lifecycle and draft state only. Conflict warnings have
// their own presentation on surfaces that have mergeability, while this tab stays stable as
// detail data arrives.
const status =
linkedSnapshot !== null
? linkedSnapshot
: detail === null
? (seed ?? null)
: { state: detail.state, isDraft: detail.isDraft };
const status = linkedSnapshot ?? newestPullRequestSummary(detail, sharedSummary) ?? seed ?? null;
if (status === null) {
return <PullRequestGlyph.pullRequest className="size-3 shrink-0 text-muted-foreground" />;
}
const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft });
const presentation = resolvePullRequestState({
state: status.state,
isDraft: status.isDraft ?? detail?.isDraft ?? seed?.isDraft ?? false,
});
return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;
}

Expand Down
19 changes: 14 additions & 5 deletions apps/web/src/components/ThreadStatusIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,24 @@ export function useLinkedThreadPullRequest(
);
const fallback =
current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null;
const host = fallback == null ? undefined : parseChangeRequestUrl(fallback.url)?.host;
const reference =
fallback == null ? null : { ...fallback, ...(host === undefined ? {} : { host }) };
// Stable per link: the shared summary effect keys on this object, and a sidebar row must not
// touch the cache on every render.
const reference = useMemo(() => {
if (fallback == null) return null;
const host = parseChangeRequestUrl(fallback.url)?.host;
return { ...fallback, ...(host === undefined ? {} : { host }) };
}, [fallback]);
const queried = useEnvironmentQuery(
!enabled || environmentId === null || reference === null
? null
: linkedPullRequestDetailAtom({ environmentId, input: reference }),
).data;
const detail = useSharedPullRequestSummary(environmentId, reference, queried);
);
const detail = useSharedPullRequestSummary(
environmentId,
reference,
Comment thread
flamboh marked this conversation as resolved.
queried.data,
queried.dataUpdatedAt,
);

return useMemo(() => {
if (current !== null) return linkedPullRequestSnapshotStatus(current);
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ function ChecksBody({
export function PullRequestChecksPopover({
checksState,
checks,
stale = false,
environmentId,
reference,
threadRef = null,
Expand All @@ -117,6 +118,7 @@ export function PullRequestChecksPopover({
checksState: PullRequestChecksState;
/** The checks already in hand, for the detail header. Absent on a listing row. */
checks?: ReadonlyArray<PullRequestCheck>;
stale?: boolean;
environmentId?: EnvironmentId;
reference?: PullRequestRef;
/** Thread the popover sits beside; a listing row has none. */
Expand All @@ -125,7 +127,7 @@ export function PullRequestChecksPopover({
}) {
const presentation = pullRequestChecksStatePresentation(checksState);
// Counts beat the rollup's own wording where they are known, the way GitHub's own header reads.
const summary = checks === undefined ? null : summarizePullRequestChecks(checks);
const summary = checks === undefined || stale ? null : summarizePullRequestChecks(checks);
return (
<Popover>
{/* A listing row is itself a button, so the trigger renders as a span: a nested button is
Expand All @@ -148,7 +150,11 @@ export function PullRequestChecksPopover({
<PopoverPopup align="start" className="w-80 max-w-full" side="bottom">
<p className="mb-2 font-medium text-sm">{presentation.label}</p>
{summary === null ? null : <p className="mb-2 text-muted-foreground text-xs">{summary}</p>}
{checks !== undefined ? (
{stale ? (
<p className="text-muted-foreground text-xs">
Check details are out of date. Refresh the pull request to update them.
</p>
) : checks !== undefined ? (
<ChecksBody checks={checks} threadRef={threadRef} />
) : environmentId !== undefined && reference !== undefined ? (
<LazyChecksBody
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ vi.mock("~/lib/sourceControlActions", () => ({
usePreparePullRequestThreadAction: () => ({ run: prepareThread }),
}));
vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
vi.mock("~/state/pullRequests", () => ({
vi.mock("~/state/pullRequests", async (importOriginal) => ({
...(await importOriginal<typeof import("~/state/pullRequests")>()),
pullRequestEnvironment: { detail: () => "detail", activity: () => "activity" },
usePullRequestTurnRefresh: () => 0,
useSharedPullRequestSummary: () => null,
Expand Down
Loading
Loading