authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 12:07:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 12:07:01-07:00
log9b40d3746388e573370488f73b50e1e869b79a45
tree5a64aa06504457f7f949ca41b473070736060814
parentb0249ac8711703f1a1b8cd0f3acd11155f5bf172
signature Signed by PGP key B5690EEEBB952194

feat(pull-requests): quick actions follow each host's capabilities, not GitHub (#17774)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

24 files changed, 254 insertions(+), 83 deletions(-)

apps/server/src/pullRequest/PullRequestService.test.ts+29
...@@ -572,6 +572,35 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos...@@ -572,6 +572,35 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos
572 }),572 }),
573);573);
574574
575it.effect("reports each host's actions with its summary, so rows can offer them unread", () =>
576 Effect.gen(function* () {
577 const service = yield* makeService({
578 projects: [
579 project({
580 id: "p1",
581 title: "web",
582 workspaceRoot: "/repo",
583 repository: "acme/web",
584 provider: SourceControlProviderKind.make("bitbucket"),
585 host: "bitbucket.org",
586 }),
587 ],
588 providers: [
589 fakeProvider(SourceControlProviderKind.make("bitbucket"), {
590 capabilities: {
591 ...fakeProvider(SourceControlProviderKind.make("bitbucket")).capabilities,
592 actions: ["merge", "close"],
593 },
594 }),
595 ],
596 });
597
598 const result = yield* service.list({ state: "open" });
599
600 assert.deepStrictEqual(result.providers[0]?.actions, ["merge", "close"]);
601 }),
602);
603
575it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () =>604it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () =>
576 Effect.gen(function* () {605 Effect.gen(function* () {
577 let refinementCalls = 0;606 let refinementCalls = 0;
apps/server/src/pullRequest/PullRequestService.ts+14-10
...@@ -1252,20 +1252,24 @@ export const make = Effect.gen(function* () {...@@ -1252,20 +1252,24 @@ export const make = Effect.gen(function* () {
1252 // One summary per host, which is what the viewer lookup already answers for: two GitHub1252 // One summary per host, which is what the viewer lookup already answers for: two GitHub
1253 // hosts sign in separately, so collapsing them by kind would report one as the other.1253 // hosts sign in separately, so collapsing them by kind would report one as the other.
1254 const providers: ReadonlyArray<PullRequestProviderSummary> = [1254 const providers: ReadonlyArray<PullRequestProviderSummary> = [
1255 ...viewerResults.map((result) => ({1255 ...viewerResults.map((result) => {
1256 host: result.host,1256 const capabilities = projects.find((project) => project.host === result.host)?.api
1257 kind: result.kind,1257 .capabilities;
1258 searchesOnHost:1258 return {
1259 projects.find((project) => project.host === result.host)?.api.capabilities.search ??1259 host: result.host,
1260 false,1260 kind: result.kind,
1261 projectCount: projectCounts.get(result.host) ?? 1,1261 searchesOnHost: capabilities?.search ?? false,
1262 configured: result.viewer !== null,1262 actions: capabilities?.actions ?? [],
1263 detail: result.error === null ? null : providerDetail(result.error),1263 projectCount: projectCounts.get(result.host) ?? 1,
1264 })),1264 configured: result.viewer !== null,
1265 detail: result.error === null ? null : providerDetail(result.error),
1266 };
1267 }),
1265 ...[...unimplemented].map(([host, { kind, projectCount }]) => ({1268 ...[...unimplemented].map(([host, { kind, projectCount }]) => ({
1266 host,1269 host,
1267 kind,1270 kind,
1268 searchesOnHost: false,1271 searchesOnHost: false,
1272 actions: [],
1269 projectCount,1273 projectCount,
1270 configured: false,1274 configured: false,
1271 detail: "This host cannot be browsed here yet.",1275 detail: "This host cannot be browsed here yet.",
apps/web/src/components/pullRequest/PullRequestRow.tsx+6-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import type { PullRequestAction } from "@t3tools/contracts";
1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
2import { SearchIcon } from "lucide-react";3import { SearchIcon } from "lucide-react";
3import { PullRequestStackPopover } from "./PullRequestStackPopover";4import { PullRequestStackPopover } from "./PullRequestStackPopover";
...@@ -91,6 +92,7 @@ function PullRequestRowImpl({...@@ -91,6 +92,7 @@ function PullRequestRowImpl({
91 statsRef,92 statsRef,
92 onSelect,93 onSelect,
93 speedMode,94 speedMode,
95 hostActions,
94 onActed,96 onActed,
95 closing = false,97 closing = false,
96 sweeping = false,98 sweeping = false,
...@@ -113,6 +115,8 @@ function PullRequestRowImpl({...@@ -113,6 +115,8 @@ function PullRequestRowImpl({
113 statsRef?: RefCallback<HTMLDivElement>;115 statsRef?: RefCallback<HTMLDivElement>;
114 onSelect: (entry: PullRequestRowTarget) => void;116 onSelect: (entry: PullRequestRowTarget) => void;
115 speedMode: boolean;117 speedMode: boolean;
118 /** What this row's host can do, which is what speed mode offers. */
119 hostActions: ReadonlySet<PullRequestAction>;
116 onActed: (result: PullRequestSpeedActionResult) => void;120 onActed: (result: PullRequestSpeedActionResult) => void;
117 closing?: boolean;121 closing?: boolean;
118 sweeping?: boolean;122 sweeping?: boolean;
...@@ -256,9 +260,10 @@ function PullRequestRowImpl({...@@ -256,9 +260,10 @@ function PullRequestRowImpl({
256 updatedAt={entry.updatedAt}260 updatedAt={entry.updatedAt}
257 />261 />
258 </button>262 </button>
259 {entry.state !== "merged" && entry.provider === "github" ? (263 {hostActions.size > 0 ? (
260 <PullRequestSpeedActions264 <PullRequestSpeedActions
261 entry={entry}265 entry={entry}
266 hostActions={hostActions}
262 visible={speedMode}267 visible={speedMode}
263 onActed={onActed}268 onActed={onActed}
264 closing={closing}269 closing={closing}
apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx+6-7
...@@ -7,7 +7,7 @@ import { Spinner } from "../ui/spinner";...@@ -7,7 +7,7 @@ import { Spinner } from "../ui/spinner";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8import { resolvePullRequestMergeMethod } from "./pullRequestDetail.logic";8import { resolvePullRequestMergeMethod } from "./pullRequestDetail.logic";
9import { PullRequestGlyph } from "./pullRequestIcons";9import { PullRequestGlyph } from "./pullRequestIcons";
10import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";10import { pullRequestQuickActions, type EnvironmentPullRequestEntry } from "./pullRequestList.logic";
11import {11import {
12 usePullRequestActionRunner,12 usePullRequestActionRunner,
13 usePullRequestDefaultMergeMethodResolver,13 usePullRequestDefaultMergeMethodResolver,
...@@ -26,6 +26,7 @@ export interface PullRequestSpeedActionResult<Entry = EnvironmentPullRequestEntr...@@ -26,6 +26,7 @@ export interface PullRequestSpeedActionResult<Entry = EnvironmentPullRequestEntr
26/** No detail or stack reads until a merge is clicked, even on a long list. */26/** No detail or stack reads until a merge is clicked, even on a long list. */
27export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntry>({27export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntry>({
28 entry,28 entry,
29 hostActions,
29 visible,30 visible,
30 onActed,31 onActed,
31 closing = false,32 closing = false,
...@@ -33,6 +34,8 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr...@@ -33,6 +34,8 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr
33 onCloseSweepStart,34 onCloseSweepStart,
34}: {35}: {
35 entry: Entry;36 entry: Entry;
37 /** What the row's host can do; actions it cannot are not offered. */
38 hostActions: ReadonlySet<PullRequestAction>;
36 visible: boolean;39 visible: boolean;
37 onActed?: (result: PullRequestSpeedActionResult<Entry>) => void;40 onActed?: (result: PullRequestSpeedActionResult<Entry>) => void;
38 closing?: boolean;41 closing?: boolean;
...@@ -70,13 +73,9 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr...@@ -70,13 +73,9 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr
70 );73 );
71 },74 },
72 });75 });
73 const actions =76 const actions = pullRequestQuickActions(entry, hostActions);
74 entry.state === "closed"
75 ? (["reopen"] as const)
76 : entry.isDraft
77 ? (["close", "ready"] as const)
78 : (["close", "merge"] as const);
79 const busy = actionPending || closing || sweeping;77 const busy = actionPending || closing || sweeping;
78 if (actions.length === 0) return null;
80 return (79 return (
81 <div80 <div
82 className="shrink-0 items-center gap-1 pr-3"81 className="shrink-0 items-center gap-1 pr-3"
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+19-8
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";1import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
2import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";2import {
3import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";3 sourceControlClients,
4 UNKNOWN_SOURCE_CONTROL_CLIENT,
5} from "@t3tools/client-runtime/source-control-clients";
4import {6import {
5 resolveThreadPullRequestChains,7 resolveThreadPullRequestChains,
6 visibleThreadPullRequests,8 visibleThreadPullRequests,
...@@ -45,6 +47,7 @@ import {...@@ -45,6 +47,7 @@ import {
45 pullRequestChecksStatePresentation,47 pullRequestChecksStatePresentation,
46} from "./pullRequestPresentation";48} from "./pullRequestPresentation";
47import { PullRequestGlyph } from "./pullRequestIcons";49import { PullRequestGlyph } from "./pullRequestIcons";
50import { pullRequestQuickActions } from "./pullRequestList.logic";
48import { PullRequestSpeedActions } from "./PullRequestSpeedActions";51import { PullRequestSpeedActions } from "./PullRequestSpeedActions";
4952
50const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {53const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {
...@@ -105,11 +108,12 @@ function LinkRow({...@@ -105,11 +108,12 @@ function LinkRow({
105 const snapshot = link.snapshot;108 const snapshot = link.snapshot;
106 const open = snapshot === null || snapshot.state === "open";109 const open = snapshot === null || snapshot.state === "open";
107 const watching = link.watch !== undefined;110 const watching = link.watch !== undefined;
111 // A link carries no host summary, so the host's own definition says what it can do.
112 const linkHostActions = (
113 sourceControlClients.findByChangeRequestUrl(link.url) ?? UNKNOWN_SOURCE_CONTROL_CLIENT
114 ).changeRequestActions;
108 const actionEntry =115 const actionEntry =
109 projectId !== null &&116 projectId !== null && snapshot !== null && snapshot.state !== "merged"
110 snapshot !== null &&
111 snapshot.state !== "merged" &&
112 detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "github"
113 ? {117 ? {
114 environmentId: threadRef.environmentId,118 environmentId: threadRef.environmentId,
115 projectId,119 projectId,
...@@ -121,6 +125,9 @@ function LinkRow({...@@ -121,6 +125,9 @@ function LinkRow({
121 ...(link.stack === null ? {} : { stack: link.stack }),125 ...(link.stack === null ? {} : { stack: link.stack }),
122 }126 }
123 : null;127 : null;
128 // Speed mode swaps the menu for quick actions only where a row has some to offer.
129 const hasQuickActions =
130 actionEntry !== null && pullRequestQuickActions(actionEntry, linkHostActions).length > 0;
124 return (131 return (
125 <div132 <div
126 className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")}133 className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")}
...@@ -246,7 +253,11 @@ function LinkRow({...@@ -246,7 +253,11 @@ function LinkRow({
246 />253 />
247 </a>254 </a>
248 {actionEntry !== null ? (255 {actionEntry !== null ? (
249 <PullRequestSpeedActions entry={actionEntry} visible={speedMode} />256 <PullRequestSpeedActions
257 entry={actionEntry}
258 hostActions={linkHostActions}
259 visible={speedMode}
260 />
250 ) : null}261 ) : null}
251 {/* Out of the row's flow, so no row reserves a column for a button only the hovered one262 {/* Out of the row's flow, so no row reserves a column for a button only the hovered one
252 shows. It sits over the right end of the second line on the row's own hover color,263 shows. It sits over the right end of the second line on the row's own hover color,
...@@ -261,7 +272,7 @@ function LinkRow({...@@ -261,7 +272,7 @@ function LinkRow({
261 "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100",272 "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100",
262 "has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100",273 "has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100",
263 "group-has-[[data-pull-request-action-pending=true]]/pr-row:hidden",274 "group-has-[[data-pull-request-action-pending=true]]/pr-row:hidden",
264 speedMode && actionEntry !== null && "hidden",275 speedMode && hasQuickActions && "hidden",
265 )}276 )}
266 >277 >
267 <span aria-hidden className="absolute inset-0 bg-accent/60" />278 <span aria-hidden className="absolute inset-0 bg-accent/60" />
apps/web/src/components/pullRequest/pullRequestChecks.test.tsx+1
...@@ -103,6 +103,7 @@ function row(overrides: Partial<EnvironmentPullRequestEntry>): ReactNode {...@@ -103,6 +103,7 @@ function row(overrides: Partial<EnvironmentPullRequestEntry>): ReactNode {
103 showProvider: false,103 showProvider: false,
104 onSelect: () => {},104 onSelect: () => {},
105 speedMode: false,105 speedMode: false,
106 hostActions: new Set(),
106 onActed: () => {},107 onActed: () => {},
107 });108 });
108}109}
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+40-1
...@@ -1,8 +1,15 @@...@@ -1,8 +1,15 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";1import { SourceControlProviderKind } from "@t3tools/contracts";
2import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts";2import type {
3 EnvironmentId,
4 ProjectId,
5 PullRequestAction,
6 PullRequestListEntry,
7} from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";8import { describe, expect, it } from "vite-plus/test";
49
5import {10import {
11 pullRequestHostActions,
12 pullRequestQuickActions,
6 filterPullRequestsByInvolvement,13 filterPullRequestsByInvolvement,
7 findScopedProject,14 findScopedProject,
8 mergePullRequestLists,15 mergePullRequestLists,
...@@ -1720,3 +1727,35 @@ describe("pull request list override settlement", () => {...@@ -1720,3 +1727,35 @@ describe("pull request list override settlement", () => {
1720 expect(swapped.map((row) => row.number)).toEqual([2, 1]);1727 expect(swapped.map((row) => row.number)).toEqual([2, 1]);
1721 });1728 });
1722});1729});
1730
1731describe("pull request quick actions", () => {
1732 const summary = (host: string, kind: string, actions?: ReadonlyArray<PullRequestAction>) => ({
1733 host,
1734 kind: SourceControlProviderKind.make(kind),
1735 searchesOnHost: true,
1736 projectCount: 1,
1737 configured: true,
1738 detail: null,
1739 ...(actions === undefined ? {} : { actions }),
1740 });
1741
1742 it("offers what each host reports, and GitHub's legacy set when a server reports none", () => {
1743 const actionsOf = pullRequestHostActions([
1744 summary("bitbucket.org", "bitbucket", ["merge", "close"]),
1745 summary("github.com", "github"),
1746 ]);
1747 const bitbucket = actionsOf({
1748 host: "bitbucket.org",
1749 provider: SourceControlProviderKind.make("bitbucket"),
1750 });
1751 expect(pullRequestQuickActions({ state: "closed", isDraft: false }, bitbucket)).toEqual([]);
1752 const github = actionsOf({
1753 host: "github.com",
1754 provider: SourceControlProviderKind.make("github"),
1755 });
1756 expect(pullRequestQuickActions({ state: "open", isDraft: true }, github)).toEqual([
1757 "close",
1758 "ready",
1759 ]);
1760 });
1761});
apps/web/src/components/pullRequest/pullRequestList.logic.ts+42
...@@ -1262,3 +1262,45 @@ export function settlePullRequestOverrides<Entry extends PullRequestListEntry>(...@@ -1262,3 +1262,45 @@ export function settlePullRequestOverrides<Entry extends PullRequestListEntry>(
1262 }1262 }
1263 return kept.size === overrides.size ? overrides : kept;1263 return kept.size === overrides.size ? overrides : kept;
1264}1264}
1265
1266/** The row actions speed mode offers, in the order they are drawn. */
1267export type PullRequestQuickAction = "close" | "merge" | "ready" | "reopen";
1268
1269/**
1270 * Which actions each host can take on a listed row, before any row's detail is read. Hosts are
1271 * matched by the host summary; a server that does not report actions per host only ever offered
1272 * these on GitHub, so that is what it keeps getting.
1273 */
1274export function pullRequestHostActions(
1275 providers: ReadonlyArray<PullRequestListResult["providers"][number]>,
1276): (
1277 entry: Pick<EnvironmentPullRequestEntry, "host" | "provider">,
1278) => ReadonlySet<PullRequestAction> {
1279 const byHost = new Map(
1280 providers.flatMap((provider) =>
1281 provider.actions === undefined
1282 ? []
1283 : [[provider.host.toLowerCase(), new Set(provider.actions)] as const],
1284 ),
1285 );
1286 const legacy = new Set<PullRequestAction>(["close", "merge", "ready", "reopen"]);
1287 const none = new Set<PullRequestAction>();
1288 return (entry) =>
1289 byHost.get(entry.host.toLowerCase()) ?? (entry.provider === "github" ? legacy : none);
1290}
1291
1292/** The quick actions a row offers: what its state allows, narrowed to what its host can do. */
1293export function pullRequestQuickActions(
1294 entry: Pick<EnvironmentPullRequestEntry, "state" | "isDraft">,
1295 hostActions: ReadonlySet<PullRequestAction>,
1296): ReadonlyArray<PullRequestQuickAction> {
1297 const forState: ReadonlyArray<PullRequestQuickAction> =
1298 entry.state === "merged"
1299 ? []
1300 : entry.state === "closed"
1301 ? ["reopen"]
1302 : entry.isDraft
1303 ? ["close", "ready"]
1304 : ["close", "merge"];
1305 return forState.filter((action) => hostActions.has(action));
1306}
apps/web/src/components/pullRequest/usePullRequestActions.ts+2-2
...@@ -186,8 +186,8 @@ export function usePullRequestCloseBatch(onClosed: (entry: EnvironmentPullReques...@@ -186,8 +186,8 @@ export function usePullRequestCloseBatch(onClosed: (entry: EnvironmentPullReques
186 async (entries: readonly EnvironmentPullRequestEntry[]) => {186 async (entries: readonly EnvironmentPullRequestEntry[]) => {
187 const batch = entries.filter((entry) => {187 const batch = entries.filter((entry) => {
188 const key = pullRequestEntryKey(entry);188 const key = pullRequestEntryKey(entry);
189 if (entry.state !== "open" || entry.provider !== "github" || pending.current.has(key))189 // The sweep only gathers rows whose host can close them, so state is all that is left.
190 return false;190 if (entry.state !== "open" || pending.current.has(key)) return false;
191 pending.current.add(key);191 pending.current.add(key);
192 return true;192 return true;
193 });193 });
apps/web/src/routes/_chat.pull-requests.tsx+27-18
...@@ -88,6 +88,7 @@ import {...@@ -88,6 +88,7 @@ import {
88 pullRequestOverrideAfterAction,88 pullRequestOverrideAfterAction,
89 reusePullRequestEntries,89 reusePullRequestEntries,
90 settlePullRequestOverrides,90 settlePullRequestOverrides,
91 pullRequestHostActions,
91} from "../components/pullRequest/pullRequestList.logic";92} from "../components/pullRequest/pullRequestList.logic";
92import {93import {
93 pullRequestListPreferences,94 pullRequestListPreferences,
...@@ -1592,6 +1593,22 @@ function PullRequestsRouteView() {...@@ -1592,6 +1593,22 @@ function PullRequestsRouteView() {
1592 ]);1593 ]);
1593 /** What is actually on screen once the reader's pending answers are on the rows. */1594 /** What is actually on screen once the reader's pending answers are on the rows. */
1594 const shownCount = displayGroups.reduce((count, group) => count + group.entries.length, 0);1595 const shownCount = displayGroups.reduce((count, group) => count + group.entries.length, 0);
1596 // The provider list is the workspace's hosts, not the filtered ones, so switching to a host
1597 // cannot make the switcher that got you there disappear.
1598 const [hosts, setHosts] = useState<PullRequestListResult["providers"]>([]);
1599 useEffect(() => {
1600 // Only from an answer to these filters. Rows carried over from the previous ones bring the
1601 // host summaries of the question they answered, and coming back from one host to all of them
1602 // would take the switcher's other hosts out of it on the strength of the narrowed answer.
1603 if (answered === null) return;
1604 // An unfiltered response is the full set of hosts. A filtered one only seeds the switcher
1605 // when there is nothing to seed it with, which is a link that arrived already scoped.
1606 setHosts((previous) =>
1607 search.host === undefined || previous.length === 0 ? answered.providers : previous,
1608 );
1609 }, [answered, search.host]);
1610 const showProvider = hosts.length > 1;
1611 const hostActionsOf = useMemo(() => pullRequestHostActions(hosts), [hosts]);
1595 const [closeSweepKeys, setCloseSweepKeys] = useState<ReadonlySet<string>>(() => new Set());1612 const [closeSweepKeys, setCloseSweepKeys] = useState<ReadonlySet<string>>(() => new Set());
1596 const closeSensorRef = useRef<SidebarPointerSensor | null>(null);1613 const closeSensorRef = useRef<SidebarPointerSensor | null>(null);
1597 const closeSweepRows = useMemo(1614 const closeSweepRows = useMemo(
...@@ -1605,8 +1622,14 @@ function PullRequestsRouteView() {...@@ -1605,8 +1622,14 @@ function PullRequestsRouteView() {
1605 ),1622 ),
1606 [displayGroups],1623 [displayGroups],
1607 );1624 );
1608 const closeSweepRef = useRef({ displayGroups, closeSweepRows, closingKeys, closeBatch });1625 const closeSweepRef = useRef({
1609 closeSweepRef.current = { displayGroups, closeSweepRows, closingKeys, closeBatch };1626 displayGroups,
1627 closeSweepRows,
1628 closingKeys,
1629 closeBatch,
1630 hostActionsOf,
1631 });
1632 closeSweepRef.current = { displayGroups, closeSweepRows, closingKeys, closeBatch, hostActionsOf };
1610 useEffect(() => () => closeSensorRef.current?.cancel(), [filterKey, search.q, sort]);1633 useEffect(() => () => closeSensorRef.current?.cancel(), [filterKey, search.q, sort]);
1611 useEffect(() => {1634 useEffect(() => {
1612 const onKeyDown = (event: KeyboardEvent) => {1635 const onKeyDown = (event: KeyboardEvent) => {
...@@ -1632,7 +1655,7 @@ function PullRequestsRouteView() {...@@ -1632,7 +1655,7 @@ function PullRequestsRouteView() {
1632 return (1655 return (
1633 row?.groupKey === group.key &&1656 row?.groupKey === group.key &&
1634 row.entry.state === "open" &&1657 row.entry.state === "open" &&
1635 row.entry.provider === "github" &&1658 closeSweepRef.current.hostActionsOf(row.entry).has("close") &&
1636 !closeSweepRef.current.closingKeys.has(key) &&1659 !closeSweepRef.current.closingKeys.has(key) &&
1637 !scrollRef.current?.querySelector(1660 !scrollRef.current?.querySelector(
1638 `[data-pull-request-key="${CSS.escape(key)}"] [data-pull-request-action-pending="true"]`,1661 `[data-pull-request-key="${CSS.escape(key)}"] [data-pull-request-action-pending="true"]`,
...@@ -1766,21 +1789,6 @@ function PullRequestsRouteView() {...@@ -1766,21 +1789,6 @@ function PullRequestsRouteView() {
1766 selectSurfaceInUrl(selectedPullRequestSurface);1789 selectSurfaceInUrl(selectedPullRequestSurface);
1767 };1790 };
17681791
1769 // The provider list is the workspace's hosts, not the filtered ones, so switching to a host
1770 // cannot make the switcher that got you there disappear.
1771 const [hosts, setHosts] = useState<PullRequestListResult["providers"]>([]);
1772 useEffect(() => {
1773 // Only from an answer to these filters. Rows carried over from the previous ones bring the
1774 // host summaries of the question they answered, and coming back from one host to all of them
1775 // would take the switcher's other hosts out of it on the strength of the narrowed answer.
1776 if (answered === null) return;
1777 // An unfiltered response is the full set of hosts. A filtered one only seeds the switcher
1778 // when there is nothing to seed it with, which is a link that arrived already scoped.
1779 setHosts((previous) =>
1780 search.host === undefined || previous.length === 0 ? answered.providers : previous,
1781 );
1782 }, [answered, search.host]);
1783 const showProvider = hosts.length > 1;
1784 // The workspace's own projects already name their hosts, so the row's shape is known before1792 // The workspace's own projects already name their hosts, so the row's shape is known before
1785 // the list is. Only its shape: which hosts can actually be read still comes from the server.1793 // the list is. Only its shape: which hosts can actually be read still comes from the server.
1786 const expectedHosts = useMemo(() => {1794 const expectedHosts = useMemo(() => {
...@@ -1942,6 +1950,7 @@ function PullRequestsRouteView() {...@@ -1942,6 +1950,7 @@ function PullRequestsRouteView() {
1942 }1950 }
1943 onSelect={selectEntry}1951 onSelect={selectEntry}
1944 speedMode={speedMode}1952 speedMode={speedMode}
1953 hostActions={hostActionsOf(entry)}
1945 onActed={onSpeedAction}1954 onActed={onSpeedAction}
1946 closing={closingKeys.has(entryKey)}1955 closing={closingKeys.has(entryKey)}
1947 sweeping={closeSweepKeys.has(entryKey)}1956 sweeping={closeSweepKeys.has(entryKey)}
packages/contracts/src/pullRequest.ts+5
...@@ -634,6 +634,11 @@ export const PullRequestProviderSummary = Schema.Struct({...@@ -634,6 +634,11 @@ export const PullRequestProviderSummary = Schema.Struct({
634 kind: SourceControlProviderKind,634 kind: SourceControlProviderKind,
635 /** False where a search has to be applied to the rows after they arrive. */635 /** False where a search has to be applied to the rows after they arrive. */
636 searchesOnHost: Schema.Boolean,636 searchesOnHost: Schema.Boolean,
637 /**
638 * The actions this host can carry out, so a row can offer them before its detail is read.
639 * Absent from servers older than this field; clients then offer what they always did.
640 */
641 actions: Schema.optional(Schema.Array(PullRequestAction)),
637 projectCount: PositiveInt,642 projectCount: PositiveInt,
638 /** False when the provider's CLI or credentials are missing, with `detail` saying which. */643 /** False when the provider's CLI or credentials are missing, with `detail` saying which. */
639 configured: Schema.Boolean,644 configured: Schema.Boolean,
packages/source-control-azure-devops/src/client/definition.ts+9
...@@ -40,6 +40,15 @@ export const definition = defineSourceControlClient({...@@ -40,6 +40,15 @@ export const definition = defineSourceControlClient({
40 defaultCloneTransport: "ssh",40 defaultCloneTransport: "ssh",
41 changeRequestUrl: ({ host, repository, number }) =>41 changeRequestUrl: ({ host, repository, number }) =>
42 `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`,42 `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`,
43 changeRequestActions: new Set([
44 "merge",
45 "ready",
46 "draft",
47 "close",
48 "reopen",
49 "enable-auto-merge",
50 "disable-auto-merge",
51 ] as const),
43 checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`,52 checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`,
44 authorProfileUrl: () => null,53 authorProfileUrl: () => null,
45 referenceAutolinkRepositoryUrl: () => null,54 referenceAutolinkRepositoryUrl: () => null,
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts+2-9
...@@ -33,6 +33,7 @@ import type {...@@ -33,6 +33,7 @@ import type {
33 AzureDevOpsPullRequest,33 AzureDevOpsPullRequest,
34 AzureDevOpsRepositoryLocation,34 AzureDevOpsRepositoryLocation,
35} from "./azureDevOpsPullRequestJson.ts";35} from "./azureDevOpsPullRequestJson.ts";
36import { definition } from "../client/definition.ts";
3637
37/**38/**
38 * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a39 * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a
...@@ -60,15 +61,7 @@ const CAPABILITIES: PullRequestCapabilities = {...@@ -60,15 +61,7 @@ const CAPABILITIES: PullRequestCapabilities = {
60 // Reading a conversation is a plain REST read, but posting one is not something this can61 // Reading a conversation is a plain REST read, but posting one is not something this can
61 // claim without having run it, so the composer stays hidden.62 // claim without having run it, so the composer stays hidden.
62 comment: false,63 comment: false,
63 actions: [64 actions: [...definition.changeRequestActions],
64 "merge",
65 "ready",
66 "draft",
67 "close",
68 "reopen",
69 "enable-auto-merge",
70 "disable-auto-merge",
71 ],
72 // Azure squashes as a completion option; it has no rebase strategy of its own.65 // Azure squashes as a completion option; it has no rebase strategy of its own.
73 mergeMethods: ["merge", "squash"],66 mergeMethods: ["merge", "squash"],
74 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all.67 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all.
packages/source-control-bitbucket/src/client/definition.ts+3
...@@ -29,6 +29,9 @@ export const definition = defineSourceControlClient({...@@ -29,6 +29,9 @@ export const definition = defineSourceControlClient({
29 defaultCloneTransport: "ssh",29 defaultCloneTransport: "ssh",
30 changeRequestUrl: ({ host, repository, number }) =>30 changeRequestUrl: ({ host, repository, number }) =>
31 `https://${host}/${repository}/pull-requests/${number}`,31 `https://${host}/${repository}/pull-requests/${number}`,
32 // No endpoint reopens a declined pull request, and nothing documented moves one in or out of
33 // draft, so neither is offered rather than failing when pressed.
34 changeRequestActions: new Set(["merge", "close"] as const),
32 // Bitbucket has no checkout CLI, so clone the head branch from its own repository.35 // Bitbucket has no checkout CLI, so clone the head branch from its own repository.
33 checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) =>36 checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) =>
34 headRepositoryNameWithOwner &&37 headRepositoryNameWithOwner &&
packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts+2-3
...@@ -12,13 +12,12 @@ import {...@@ -12,13 +12,12 @@ import {
12 type PullRequestProviderApi,12 type PullRequestProviderApi,
13} from "@t3tools/source-control-core/server/PullRequestProvider";13} from "@t3tools/source-control-core/server/PullRequestProvider";
14import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts";14import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts";
15import { definition } from "../client/definition.ts";
1516
16const CAPABILITIES: PullRequestCapabilities = {17const CAPABILITIES: PullRequestCapabilities = {
17 diff: true,18 diff: true,
18 comment: true,19 comment: true,
19 // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that20 actions: [...definition.changeRequestActions],
20 // moves one in or out of draft, so neither is offered rather than failing when pressed.
21 actions: ["merge", "close"],
22 mergeMethods: ["merge", "squash", "rebase"],21 mergeMethods: ["merge", "squash", "rebase"],
23 search: true,22 search: true,
24 // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is23 // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is
packages/source-control-core/src/client/definition.ts+8
...@@ -12,6 +12,7 @@...@@ -12,6 +12,7 @@
12 * @module source-control-core/client/definition12 * @module source-control-core/client/definition
13 */13 */
14import {14import {
15 type PullRequestAction,
15 pullRequestHostOf,16 pullRequestHostOf,
16 type PullRequestReviewVerdict,17 type PullRequestReviewVerdict,
17 type RepositoryIdentity,18 type RepositoryIdentity,
...@@ -77,6 +78,12 @@ export interface SourceControlClientDefinition {...@@ -77,6 +78,12 @@ export interface SourceControlClientDefinition {
77 readonly defaultCloneTransport: "https" | "ssh";78 readonly defaultCloneTransport: "https" | "ssh";
78 /** The web URL of a change request, or null when this client cannot build one for the host. */79 /** The web URL of a change request, or null when this client cannot build one for the host. */
79 readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null;80 readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null;
81 /**
82 * The actions this host can take on a change request, the same list its server provider
83 * declares in `capabilities.actions`. Surfaces with no server answer to hand, such as a thread's
84 * linked pull requests, offer actions from this.
85 */
86 readonly changeRequestActions: ReadonlySet<PullRequestAction>;
80 /** A shell command that checks the change request out, or null when it cannot be built. */87 /** A shell command that checks the change request out, or null when it cannot be built. */
81 readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null;88 readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null;
82 /** A comment author's profile page, or null where the host has none clients can link. */89 /** A comment author's profile page, or null where the host has none clients can link. */
...@@ -219,6 +226,7 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {...@@ -219,6 +226,7 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {
219 publishHost: (signedInHost) => signedInHost ?? "your server",226 publishHost: (signedInHost) => signedInHost ?? "your server",
220 defaultCloneTransport: "ssh",227 defaultCloneTransport: "ssh",
221 changeRequestUrl: () => null,228 changeRequestUrl: () => null,
229 changeRequestActions: new Set(),
222 checkoutCommand: () => null,230 checkoutCommand: () => null,
223 authorProfileUrl: () => null,231 authorProfileUrl: () => null,
224 referenceAutolinkRepositoryUrl: () => null,232 referenceAutolinkRepositoryUrl: () => null,
packages/source-control-forgejo/src/client/definition.ts+1
...@@ -63,6 +63,7 @@ export const definition = defineSourceControlClient({...@@ -63,6 +63,7 @@ export const definition = defineSourceControlClient({
63 }63 }
64 return `https://${host}/${repository}/pulls/${number}`;64 return `https://${host}/${repository}/pulls/${number}`;
65 },65 },
66 changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const),
66 // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself.67 // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself.
67 checkoutCommand: ({ number, repositoryUrl }) =>68 checkoutCommand: ({ number, repositoryUrl }) =>
68 repositoryUrl69 repositoryUrl
packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts+2-1
...@@ -33,12 +33,13 @@ import {...@@ -33,12 +33,13 @@ import {
33 forgejoChecks,33 forgejoChecks,
34 forgejoReactions,34 forgejoReactions,
35} from "./forgejoPullRequestJson.ts";35} from "./forgejoPullRequestJson.ts";
36import { definition } from "../client/definition.ts";
3637
37const CAPABILITIES: PullRequestCapabilities = {38const CAPABILITIES: PullRequestCapabilities = {
38 diff: true,39 diff: true,
39 viewedFiles: "environment",40 viewedFiles: "environment",
40 comment: true,41 comment: true,
41 actions: ["merge", "close", "reopen", "update-branch"],42 actions: [...definition.changeRequestActions],
42 mergeMethods: ["merge", "squash", "rebase"],43 mergeMethods: ["merge", "squash", "rebase"],
43 updateMethods: ["merge", "rebase"],44 updateMethods: ["merge", "rebase"],
44 search: false,45 search: false,
packages/source-control-gitcafe/src/client/definition.ts+8
...@@ -31,6 +31,14 @@ export const definition = defineSourceControlClient({...@@ -31,6 +31,14 @@ export const definition = defineSourceControlClient({
31 defaultCloneTransport: "ssh",31 defaultCloneTransport: "ssh",
32 changeRequestUrl: ({ host, repository, number }) =>32 changeRequestUrl: ({ host, repository, number }) =>
33 `https://${host}/${repository}/pulls/${number}`,33 `https://${host}/${repository}/pulls/${number}`,
34 changeRequestActions: new Set([
35 "ready",
36 "draft",
37 "close",
38 "reopen",
39 "merge",
40 "update-branch",
41 ] as const),
34 checkoutCommand: ({ number }) => `cafe pr checkout ${number}`,42 checkoutCommand: ({ number }) => `cafe pr checkout ${number}`,
35 authorProfileUrl: () => null,43 authorProfileUrl: () => null,
36 referenceAutolinkRepositoryUrl: () => null,44 referenceAutolinkRepositoryUrl: () => null,
packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts+2-1
...@@ -18,11 +18,12 @@ import {...@@ -18,11 +18,12 @@ import {
18import * as GitCafeApi from "./GitCafeApi.ts";18import * as GitCafeApi from "./GitCafeApi.ts";
19import * as GitCafeHosts from "./gitCafeHosts.ts";19import * as GitCafeHosts from "./gitCafeHosts.ts";
20import * as Json from "./gitCafePullRequestJson.ts";20import * as Json from "./gitCafePullRequestJson.ts";
21import { definition } from "../client/definition.ts";
2122
22const CAPABILITIES: PullRequestCapabilities = {23const CAPABILITIES: PullRequestCapabilities = {
23 diff: true,24 diff: true,
24 comment: true,25 comment: true,
25 actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"],26 actions: [...definition.changeRequestActions],
26 mergeMethods: ["merge", "squash", "rebase"],27 mergeMethods: ["merge", "squash", "rebase"],
27 // Only through a stack: GitCafe's one branch update restacks every layer above it.28 // Only through a stack: GitCafe's one branch update restacks every layer above it.
28 updateMethods: ["rebase"],29 updateMethods: ["rebase"],
packages/source-control-github/src/client/definition.ts+12
...@@ -30,6 +30,18 @@ export const definition = defineSourceControlClient({...@@ -30,6 +30,18 @@ export const definition = defineSourceControlClient({
30 defaultCloneTransport: "https",30 defaultCloneTransport: "https",
31 changeRequestUrl: ({ host, repository, number }) =>31 changeRequestUrl: ({ host, repository, number }) =>
32 `https://${host}/${repository}/pull/${number}`,32 `https://${host}/${repository}/pull/${number}`,
33 changeRequestActions: new Set([
34 "merge",
35 "ready",
36 "draft",
37 "close",
38 "reopen",
39 "update-branch",
40 "enable-auto-merge",
41 "disable-auto-merge",
42 "revert",
43 "approve-workflows",
44 ] as const),
33 checkoutCommand: ({ number }) => `gh pr checkout ${number}`,45 checkoutCommand: ({ number }) => `gh pr checkout ${number}`,
34 authorProfileUrl: (login, repositoryUrl) =>46 authorProfileUrl: (login, repositoryUrl) =>
35 login.endsWith("[bot]")47 login.endsWith("[bot]")
packages/source-control-github/src/server/GitHubPullRequestProvider.ts+2-12
...@@ -20,22 +20,12 @@ import {...@@ -20,22 +20,12 @@ import {
20 type ProviderRepositoryRef,20 type ProviderRepositoryRef,
21} from "@t3tools/source-control-core/server/PullRequestProvider";21} from "@t3tools/source-control-core/server/PullRequestProvider";
22import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";22import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";
23import { definition } from "../client/definition.ts";
2324
24const CAPABILITIES: PullRequestCapabilities = {25const CAPABILITIES: PullRequestCapabilities = {
25 diff: true,26 diff: true,
26 comment: true,27 comment: true,
27 actions: [28 actions: [...definition.changeRequestActions],
28 "merge",
29 "ready",
30 "draft",
31 "close",
32 "reopen",
33 "update-branch",
34 "enable-auto-merge",
35 "disable-auto-merge",
36 "revert",
37 "approve-workflows",
38 ],
39 mergeMethods: ["merge", "squash", "rebase"],29 mergeMethods: ["merge", "squash", "rebase"],
40 updateMethods: ["merge", "rebase"],30 updateMethods: ["merge", "rebase"],
41 search: true,31 search: true,
packages/source-control-gitlab/src/client/definition.ts+10
...@@ -30,6 +30,16 @@ export const definition = defineSourceControlClient({...@@ -30,6 +30,16 @@ export const definition = defineSourceControlClient({
30 defaultCloneTransport: "ssh",30 defaultCloneTransport: "ssh",
31 changeRequestUrl: ({ host, repository, number }) =>31 changeRequestUrl: ({ host, repository, number }) =>
32 `https://${host}/${repository}/-/merge_requests/${number}`,32 `https://${host}/${repository}/-/merge_requests/${number}`,
33 changeRequestActions: new Set([
34 "merge",
35 "ready",
36 "draft",
37 "close",
38 "reopen",
39 "update-branch",
40 "enable-auto-merge",
41 "disable-auto-merge",
42 ] as const),
33 checkoutCommand: ({ number }) => `glab mr checkout ${number}`,43 checkoutCommand: ({ number }) => `glab mr checkout ${number}`,
34 authorProfileUrl: () => null,44 authorProfileUrl: () => null,
35 referenceAutolinkRepositoryUrl: () => null,45 referenceAutolinkRepositoryUrl: () => null,
packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts+2-10
...@@ -14,20 +14,12 @@ import {...@@ -14,20 +14,12 @@ import {
14 type ProviderChangeRequestDetail,14 type ProviderChangeRequestDetail,
15 type PullRequestProviderApi,15 type PullRequestProviderApi,
16} from "@t3tools/source-control-core/server/PullRequestProvider";16} from "@t3tools/source-control-core/server/PullRequestProvider";
17import { definition } from "../client/definition.ts";
1718
18const CAPABILITIES: PullRequestCapabilities = {19const CAPABILITIES: PullRequestCapabilities = {
19 diff: true,20 diff: true,
20 comment: true,21 comment: true,
21 actions: [22 actions: [...definition.changeRequestActions],
22 "merge",
23 "ready",
24 "draft",
25 "close",
26 "reopen",
27 "update-branch",
28 "enable-auto-merge",
29 "disable-auto-merge",
30 ],
31 // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it.23 // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it.
32 mergeMethods: ["merge", "squash", "rebase"],24 mergeMethods: ["merge", "squash", "rebase"],
33 // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing25 // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing