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
11 changes: 9 additions & 2 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,12 @@ export function PullRequestDetailPanel({
* An action changed this pull request on the host, so a list showing it is now out of date.
* Told rather than assumed: only the page knows whether it is showing one.
*/
onActed?: () => void;
/**
* Each host action as it goes: "sent" the moment it leaves, so a list can answer before the
* host does; "done" or "failed" when the host has spoken. Undefined for one the caller cannot
* name, which is only ever "done".
*/
onActed?: (action?: PullRequestAction, phase?: "sent" | "done" | "failed") => void;
/** Page-owned detail columns use this to clear the selected pull request. */
onClose?: () => void;
/**
Expand Down Expand Up @@ -961,6 +966,7 @@ export function PullRequestDetailPanel({
method?: PullRequestMergeMethod,
updateMethod?: PullRequestUpdateMethod,
) => {
onActed?.(action, "sent");
const result = await runAction({
environmentId,
input: {
Expand Down Expand Up @@ -988,6 +994,7 @@ export function PullRequestDetailPanel({
title: ACTION_FAILURE_LABELS[action],
description: readableFailure(failure, hint),
});
onActed?.(action, "failed");
return false;
}
toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
Expand All @@ -1001,7 +1008,7 @@ export function PullRequestDetailPanel({
} else {
refreshDetail();
}
onActed?.();
onActed?.(action, "done");
return true;
};

Expand Down
94 changes: 94 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import {
resolveQueryEnvironmentIds,
resolveSelectedEnvironmentId,
type EnvironmentPullRequestEntry,
applyPullRequestOverrides,
pullRequestOverrideAfterAction,
reusePullRequestEntries,
settlePullRequestOverrides,
} from "./pullRequestList.logic";
import {
pullRequestListPreferences,
Expand Down Expand Up @@ -1600,3 +1604,93 @@ describe("the priority groups against a paginated feed", () => {
).toEqual([6123]);
});
});

describe("pull request list overrides", () => {
const entry = (number: number, state: "open" | "closed" | "merged") =>
({
host: "github.com",
repository: "pingdotgg/t3code",
number,
state,
isDraft: false,
updatedAt: "2026-07-01T00:00:00Z",
labels: [],
}) as unknown as PullRequestListEntry;
const key = (row: { number: number }) => `#${row.number}`;

it("maps the actions that change a row's state and nothing else", () => {
const now = new Date("2026-07-02T00:00:00Z");
expect(pullRequestOverrideAfterAction(entry(1, "open"), "close", now, 7)).toEqual({
state: "closed",
updatedAt: "2026-07-02T00:00:00.000Z",
token: 7,
at: now.getTime(),
});
expect(pullRequestOverrideAfterAction(entry(1, "closed"), "reopen", now, 1)?.state).toBe(
"open",
);
expect(pullRequestOverrideAfterAction(entry(1, "open"), "merge", now, 1)?.state).toBe("merged");
expect(pullRequestOverrideAfterAction(entry(1, "open"), "draft", now, 1)?.isDraft).toBe(true);
expect(pullRequestOverrideAfterAction(entry(1, "open"), "update-branch", now, 1)).toBeNull();
});

it("writes the override over the row and drops it from a list whose state it left", () => {
const rows = [entry(1, "open"), entry(2, "open")];
const overrides = new Map([
["#1", { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at: 0 }],
]);
expect(
applyPullRequestOverrides(rows, overrides, key, "open").map((row) => row.number),
).toEqual([2]);
const all = applyPullRequestOverrides(rows, overrides, key, "all");
expect(all.map((row) => [row.number, row.state])).toEqual([
[1, "closed"],
[2, "open"],
]);
expect(applyPullRequestOverrides(rows, new Map(), key, "open")).toBe(rows);
});

it("hands back the held object for a row a refresh did not change", () => {
const previous = [entry(1, "open"), entry(2, "open")];
const next = [{ ...entry(1, "open") }, { ...entry(2, "open"), state: "merged" as const }];
const reused = reusePullRequestEntries(previous, next, key);
expect(reused[0]).toBe(previous[0]);
expect(reused[1]).toBe(next[1]);
expect(
reusePullRequestEntries(previous, [{ ...entry(1, "open") }, { ...entry(2, "open") }], key),
).toBe(previous);
});
});

describe("pull request list override settlement", () => {
const entry = (number: number, state: "open" | "closed" | "merged") =>
({ number, state, isDraft: false, labels: [] }) as unknown as PullRequestListEntry;
const key = (row: { number: number }) => `#${row.number}`;

it("keeps an override until an answer agrees with it", () => {
const at = 1_000_000;
const closed = { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at };
const overrides = new Map([["#1", closed]]);
// A read from before the action still says open: the override stands.
expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, at + 5_000)).toBe(
overrides,
);
// Absent from the answer says nothing: the row may live in another group or page.
expect(settlePullRequestOverrides(overrides, [entry(2, "open")], key, at + 5_000).size).toBe(1);
// Present as closed: confirmed.
expect(settlePullRequestOverrides(overrides, [entry(1, "closed")], key, at + 5_000).size).toBe(
0,
);
// Present as open a good while later: the host's news, which outranks the note.
expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, at + 90_000).size).toBe(
0,
);
});

it("does not hand back the old order when only the order changed", () => {
const previous = [entry(1, "open"), entry(2, "open")];
const swapped = reusePullRequestEntries(previous, [entry(2, "open"), entry(1, "open")], key);
expect(swapped).not.toBe(previous);
expect(swapped.map((row) => row.number)).toEqual([2, 1]);
});
});
126 changes: 126 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestList.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
} from "@t3tools/contracts";
import type {
ProjectId,
PullRequestAction,
PullRequestActor,
PullRequestDiffStat,
PullRequestInvolvement,
PullRequestLabel,
PullRequestListCursors,
PullRequestListFilters,
PullRequestListState,
PullRequestState,
} from "@t3tools/contracts";

import { toSortableTimestamp } from "../../lib/threadSort";
Expand Down Expand Up @@ -1140,3 +1142,127 @@ export function withDiffStat<
const stat = statsByRow.get(pullRequestDiffStatKey(entry));
return stat === undefined ? entry : { ...entry, ...stat };
}

/**
* What a row should say the moment an action is sent, before any host has answered. The host
* is the record and a later read replaces this, but the reader pressed the button and should
* see the row answer at once: a closed pull request leaves an "open" list on the click, not
* after the reads that follow.
*/
export interface PullRequestListOverride {
readonly state: PullRequestState;
readonly isDraft?: boolean;
readonly updatedAt: string;
/** Which action wrote it, so a failure takes back its own note and not a later one's. */
readonly token: number;
/** When it was written, in the reader's clock. */
readonly at: number;
}

export function pullRequestOverrideAfterAction(
entry: Pick<PullRequestListEntry, "state" | "isDraft">,
action: PullRequestAction,
now: Date,
token: number,
): PullRequestListOverride | null {
const stamp = { updatedAt: now.toISOString(), token, at: now.getTime() };
switch (action) {
case "close":
return { state: "closed", ...stamp };
case "reopen":
return { state: "open", ...stamp };
case "merge":
return { state: "merged", ...stamp };
case "draft":
return { state: entry.state, isDraft: true, ...stamp };
case "ready":
return { state: entry.state, isDraft: false, ...stamp };
default:
return null;
}
}

/** The rows with their pending answers written over them, and the ones the list's state filter no longer holds dropped. */
export function applyPullRequestOverrides<Entry extends PullRequestListEntry>(
entries: ReadonlyArray<Entry>,
overrides: ReadonlyMap<string, PullRequestListOverride>,
keyOf: (entry: Entry) => string,
state: PullRequestListState,
): ReadonlyArray<Entry> {
if (overrides.size === 0) return entries;
const out: Entry[] = [];
for (const entry of entries) {
const override = overrides.get(keyOf(entry));
if (override === undefined) {
out.push(entry);
continue;
}
if (state !== "all" && override.state !== state) continue;
out.push({ ...entry, ...override });
}
return out;
}

/**
* A fresh answer with the rows it did not change handed back as the objects already held, so a
* memoized row whose data is the same does not render again. Every refresh otherwise rebuilds
* every entry, and a hundred rows repaint for the one that moved.
*/
export function reusePullRequestEntries<Entry extends PullRequestListEntry>(
previous: ReadonlyArray<Entry>,
next: ReadonlyArray<Entry>,
keyOf: (entry: Entry) => string,
): ReadonlyArray<Entry> {
if (previous.length === 0) return next;
const held = new Map(previous.map((entry) => [keyOf(entry), entry]));
let reused = 0;
const out = next.map((entry) => {
const before = held.get(keyOf(entry));
if (before !== undefined && JSON.stringify(before) === JSON.stringify(entry)) {
reused += 1;
return before;
}
return entry;
});
return reused === next.length &&
previous.length === next.length &&
previous.every((entry, index) => keyOf(entry) === keyOf(next[index]!))
? previous
: out;
}

/** How long a read that disagrees is taken for a stale one rather than for news. */
const PULL_REQUEST_OVERRIDE_TRUST_MS = 60_000;

/**
* The overrides an answer has confirmed, dropped; the rest kept. A read that started before
* the action can land after it and still say the old thing, so an override is not cleared
* because an answer arrived but because the answer agrees: the row is there in the state the
* override said. A row that is absent says nothing — the authored and reviewing groups are read
* apart from the feed, and a page is only a page — so absence never confirms. A row present in
* another state is taken for a stale read for a minute, and for the host's news after that,
* which is how a pull request reopened elsewhere comes back.
*/
export function settlePullRequestOverrides<Entry extends PullRequestListEntry>(
overrides: ReadonlyMap<string, PullRequestListOverride>,
answered: ReadonlyArray<Entry>,
keyOf: (entry: Entry) => string,
now: number,
): ReadonlyMap<string, PullRequestListOverride> {
if (overrides.size === 0) return overrides;
const byKey = new Map(answered.map((entry) => [keyOf(entry), entry]));
const kept = new Map<string, PullRequestListOverride>();
for (const [key, override] of overrides) {
const row = byKey.get(key);
if (row === undefined) {
kept.set(key, override);
continue;
}
const agrees =
row.state === override.state &&
(override.isDraft === undefined || row.isDraft === override.isDraft);
const outranked = now - override.at > PULL_REQUEST_OVERRIDE_TRUST_MS;
if (!agrees && !outranked) kept.set(key, override);
}
return kept.size === overrides.size ? overrides : kept;
}
Loading
Loading