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
572572 }),
573573);
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
575604it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () =>
576605 Effect.gen(function* () {
577606 let refinementCalls = 0;
apps/server/src/pullRequest/PullRequestService.ts+14-10
......@@ -1252,20 +1252,24 @@ export const make = Effect.gen(function* () {
12521252 // One summary per host, which is what the viewer lookup already answers for: two GitHub
12531253 // hosts sign in separately, so collapsing them by kind would report one as the other.
12541254 const providers: ReadonlyArray<PullRequestProviderSummary> = [
1255 ...viewerResults.map((result) => ({
1256 host: result.host,
1257 kind: result.kind,
1258 searchesOnHost:
1259 projects.find((project) => project.host === result.host)?.api.capabilities.search ??
1260 false,
1261 projectCount: projectCounts.get(result.host) ?? 1,
1262 configured: result.viewer !== null,
1263 detail: result.error === null ? null : providerDetail(result.error),
1264 })),
1255 ...viewerResults.map((result) => {
1256 const capabilities = projects.find((project) => project.host === result.host)?.api
1257 .capabilities;
1258 return {
1259 host: result.host,
1260 kind: result.kind,
1261 searchesOnHost: capabilities?.search ?? false,
1262 actions: capabilities?.actions ?? [],
1263 projectCount: projectCounts.get(result.host) ?? 1,
1264 configured: result.viewer !== null,
1265 detail: result.error === null ? null : providerDetail(result.error),
1266 };
1267 }),
12651268 ...[...unimplemented].map(([host, { kind, projectCount }]) => ({
12661269 host,
12671270 kind,
12681271 searchesOnHost: false,
1272 actions: [],
12691273 projectCount,
12701274 configured: false,
12711275 detail: "This host cannot be browsed here yet.",
apps/web/src/components/pullRequest/PullRequestRow.tsx+6-1
......@@ -1,3 +1,4 @@
1import type { PullRequestAction } from "@t3tools/contracts";
12import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
23import { SearchIcon } from "lucide-react";
34import { PullRequestStackPopover } from "./PullRequestStackPopover";
......@@ -91,6 +92,7 @@ function PullRequestRowImpl({
9192 statsRef,
9293 onSelect,
9394 speedMode,
95 hostActions,
9496 onActed,
9597 closing = false,
9698 sweeping = false,
......@@ -113,6 +115,8 @@ function PullRequestRowImpl({
113115 statsRef?: RefCallback<HTMLDivElement>;
114116 onSelect: (entry: PullRequestRowTarget) => void;
115117 speedMode: boolean;
118 /** What this row's host can do, which is what speed mode offers. */
119 hostActions: ReadonlySet<PullRequestAction>;
116120 onActed: (result: PullRequestSpeedActionResult) => void;
117121 closing?: boolean;
118122 sweeping?: boolean;
......@@ -256,9 +260,10 @@ function PullRequestRowImpl({
256260 updatedAt={entry.updatedAt}
257261 />
258262 </button>
259 {entry.state !== "merged" && entry.provider === "github" ? (
263 {hostActions.size > 0 ? (
260264 <PullRequestSpeedActions
261265 entry={entry}
266 hostActions={hostActions}
262267 visible={speedMode}
263268 onActed={onActed}
264269 closing={closing}
apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx+6-7
......@@ -7,7 +7,7 @@ import { Spinner } from "../ui/spinner";
77import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
88import { resolvePullRequestMergeMethod } from "./pullRequestDetail.logic";
99import { PullRequestGlyph } from "./pullRequestIcons";
10import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
10import { pullRequestQuickActions, type EnvironmentPullRequestEntry } from "./pullRequestList.logic";
1111import {
1212 usePullRequestActionRunner,
1313 usePullRequestDefaultMergeMethodResolver,
......@@ -26,6 +26,7 @@ export interface PullRequestSpeedActionResult<Entry = EnvironmentPullRequestEntr
2626/** No detail or stack reads until a merge is clicked, even on a long list. */
2727export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntry>({
2828 entry,
29 hostActions,
2930 visible,
3031 onActed,
3132 closing = false,
......@@ -33,6 +34,8 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr
3334 onCloseSweepStart,
3435}: {
3536 entry: Entry;
37 /** What the row's host can do; actions it cannot are not offered. */
38 hostActions: ReadonlySet<PullRequestAction>;
3639 visible: boolean;
3740 onActed?: (result: PullRequestSpeedActionResult<Entry>) => void;
3841 closing?: boolean;
......@@ -70,13 +73,9 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr
7073 );
7174 },
7275 });
73 const actions =
74 entry.state === "closed"
75 ? (["reopen"] as const)
76 : entry.isDraft
77 ? (["close", "ready"] as const)
78 : (["close", "merge"] as const);
76 const actions = pullRequestQuickActions(entry, hostActions);
7977 const busy = actionPending || closing || sweeping;
78 if (actions.length === 0) return null;
8079 return (
8180 <div
8281 className="shrink-0 items-center gap-1 pr-3"
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+19-8
......@@ -1,6 +1,8 @@
11import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
2import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
3import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
2import {
3 sourceControlClients,
4 UNKNOWN_SOURCE_CONTROL_CLIENT,
5} from "@t3tools/client-runtime/source-control-clients";
46import {
57 resolveThreadPullRequestChains,
68 visibleThreadPullRequests,
......@@ -45,6 +47,7 @@ import {
4547 pullRequestChecksStatePresentation,
4648} from "./pullRequestPresentation";
4749import { PullRequestGlyph } from "./pullRequestIcons";
50import { pullRequestQuickActions } from "./pullRequestList.logic";
4851import { PullRequestSpeedActions } from "./PullRequestSpeedActions";
4952
5053const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {
......@@ -105,11 +108,12 @@ function LinkRow({
105108 const snapshot = link.snapshot;
106109 const open = snapshot === null || snapshot.state === "open";
107110 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;
108115 const actionEntry =
109 projectId !== null &&
110 snapshot !== null &&
111 snapshot.state !== "merged" &&
112 detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "github"
116 projectId !== null && snapshot !== null && snapshot.state !== "merged"
113117 ? {
114118 environmentId: threadRef.environmentId,
115119 projectId,
......@@ -121,6 +125,9 @@ function LinkRow({
121125 ...(link.stack === null ? {} : { stack: link.stack }),
122126 }
123127 : 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;
124131 return (
125132 <div
126133 className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")}
......@@ -246,7 +253,11 @@ function LinkRow({
246253 />
247254 </a>
248255 {actionEntry !== null ? (
249 <PullRequestSpeedActions entry={actionEntry} visible={speedMode} />
256 <PullRequestSpeedActions
257 entry={actionEntry}
258 hostActions={linkHostActions}
259 visible={speedMode}
260 />
250261 ) : null}
251262 {/* Out of the row's flow, so no row reserves a column for a button only the hovered one
252263 shows. It sits over the right end of the second line on the row's own hover color,
......@@ -261,7 +272,7 @@ function LinkRow({
261272 "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100",
262273 "has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100",
263274 "group-has-[[data-pull-request-action-pending=true]]/pr-row:hidden",
264 speedMode && actionEntry !== null && "hidden",
275 speedMode && hasQuickActions && "hidden",
265276 )}
266277 >
267278 <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 {
103103 showProvider: false,
104104 onSelect: () => {},
105105 speedMode: false,
106 hostActions: new Set(),
106107 onActed: () => {},
107108 });
108109}
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+40-1
......@@ -1,8 +1,15 @@
11import { 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";
38import { describe, expect, it } from "vite-plus/test";
49
510import {
11 pullRequestHostActions,
12 pullRequestQuickActions,
613 filterPullRequestsByInvolvement,
714 findScopedProject,
815 mergePullRequestLists,
......@@ -1720,3 +1727,35 @@ describe("pull request list override settlement", () => {
17201727 expect(swapped.map((row) => row.number)).toEqual([2, 1]);
17211728 });
17221729});
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>(
12621262 }
12631263 return kept.size === overrides.size ? overrides : kept;
12641264}
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
186186 async (entries: readonly EnvironmentPullRequestEntry[]) => {
187187 const batch = entries.filter((entry) => {
188188 const key = pullRequestEntryKey(entry);
189 if (entry.state !== "open" || entry.provider !== "github" || pending.current.has(key))
190 return false;
189 // The sweep only gathers rows whose host can close them, so state is all that is left.
190 if (entry.state !== "open" || pending.current.has(key)) return false;
191191 pending.current.add(key);
192192 return true;
193193 });
apps/web/src/routes/_chat.pull-requests.tsx+27-18
......@@ -88,6 +88,7 @@ import {
8888 pullRequestOverrideAfterAction,
8989 reusePullRequestEntries,
9090 settlePullRequestOverrides,
91 pullRequestHostActions,
9192} from "../components/pullRequest/pullRequestList.logic";
9293import {
9394 pullRequestListPreferences,
......@@ -1592,6 +1593,22 @@ function PullRequestsRouteView() {
15921593 ]);
15931594 /** What is actually on screen once the reader's pending answers are on the rows. */
15941595 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]);
15951612 const [closeSweepKeys, setCloseSweepKeys] = useState<ReadonlySet<string>>(() => new Set());
15961613 const closeSensorRef = useRef<SidebarPointerSensor | null>(null);
15971614 const closeSweepRows = useMemo(
......@@ -1605,8 +1622,14 @@ function PullRequestsRouteView() {
16051622 ),
16061623 [displayGroups],
16071624 );
1608 const closeSweepRef = useRef({ displayGroups, closeSweepRows, closingKeys, closeBatch });
1609 closeSweepRef.current = { displayGroups, closeSweepRows, closingKeys, closeBatch };
1625 const closeSweepRef = useRef({
1626 displayGroups,
1627 closeSweepRows,
1628 closingKeys,
1629 closeBatch,
1630 hostActionsOf,
1631 });
1632 closeSweepRef.current = { displayGroups, closeSweepRows, closingKeys, closeBatch, hostActionsOf };
16101633 useEffect(() => () => closeSensorRef.current?.cancel(), [filterKey, search.q, sort]);
16111634 useEffect(() => {
16121635 const onKeyDown = (event: KeyboardEvent) => {
......@@ -1632,7 +1655,7 @@ function PullRequestsRouteView() {
16321655 return (
16331656 row?.groupKey === group.key &&
16341657 row.entry.state === "open" &&
1635 row.entry.provider === "github" &&
1658 closeSweepRef.current.hostActionsOf(row.entry).has("close") &&
16361659 !closeSweepRef.current.closingKeys.has(key) &&
16371660 !scrollRef.current?.querySelector(
16381661 `[data-pull-request-key="${CSS.escape(key)}"] [data-pull-request-action-pending="true"]`,
......@@ -1766,21 +1789,6 @@ function PullRequestsRouteView() {
17661789 selectSurfaceInUrl(selectedPullRequestSurface);
17671790 };
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;
17841792 // The workspace's own projects already name their hosts, so the row's shape is known before
17851793 // the list is. Only its shape: which hosts can actually be read still comes from the server.
17861794 const expectedHosts = useMemo(() => {
......@@ -1942,6 +1950,7 @@ function PullRequestsRouteView() {
19421950 }
19431951 onSelect={selectEntry}
19441952 speedMode={speedMode}
1953 hostActions={hostActionsOf(entry)}
19451954 onActed={onSpeedAction}
19461955 closing={closingKeys.has(entryKey)}
19471956 sweeping={closeSweepKeys.has(entryKey)}
packages/contracts/src/pullRequest.ts+5
......@@ -634,6 +634,11 @@ export const PullRequestProviderSummary = Schema.Struct({
634634 kind: SourceControlProviderKind,
635635 /** False where a search has to be applied to the rows after they arrive. */
636636 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)),
637642 projectCount: PositiveInt,
638643 /** False when the provider's CLI or credentials are missing, with `detail` saying which. */
639644 configured: Schema.Boolean,
packages/source-control-azure-devops/src/client/definition.ts+9
......@@ -40,6 +40,15 @@ export const definition = defineSourceControlClient({
4040 defaultCloneTransport: "ssh",
4141 changeRequestUrl: ({ host, repository, number }) =>
4242 `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),
4352 checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`,
4453 authorProfileUrl: () => null,
4554 referenceAutolinkRepositoryUrl: () => null,
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts+2-9
......@@ -33,6 +33,7 @@ import type {
3333 AzureDevOpsPullRequest,
3434 AzureDevOpsRepositoryLocation,
3535} from "./azureDevOpsPullRequestJson.ts";
36import { definition } from "../client/definition.ts";
3637
3738/**
3839 * 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 = {
6061 // Reading a conversation is a plain REST read, but posting one is not something this can
6162 // claim without having run it, so the composer stays hidden.
6263 comment: false,
63 actions: [
64 "merge",
65 "ready",
66 "draft",
67 "close",
68 "reopen",
69 "enable-auto-merge",
70 "disable-auto-merge",
71 ],
64 actions: [...definition.changeRequestActions],
7265 // Azure squashes as a completion option; it has no rebase strategy of its own.
7366 mergeMethods: ["merge", "squash"],
7467 // `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({
2929 defaultCloneTransport: "ssh",
3030 changeRequestUrl: ({ host, repository, number }) =>
3131 `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),
3235 // Bitbucket has no checkout CLI, so clone the head branch from its own repository.
3336 checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) =>
3437 headRepositoryNameWithOwner &&
packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts+2-3
......@@ -12,13 +12,12 @@ import {
1212 type PullRequestProviderApi,
1313} from "@t3tools/source-control-core/server/PullRequestProvider";
1414import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts";
15import { definition } from "../client/definition.ts";
1516
1617const CAPABILITIES: PullRequestCapabilities = {
1718 diff: true,
1819 comment: true,
19 // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that
20 // moves one in or out of draft, so neither is offered rather than failing when pressed.
21 actions: ["merge", "close"],
20 actions: [...definition.changeRequestActions],
2221 mergeMethods: ["merge", "squash", "rebase"],
2322 search: true,
2423 // 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 @@
1212 * @module source-control-core/client/definition
1313 */
1414import {
15 type PullRequestAction,
1516 pullRequestHostOf,
1617 type PullRequestReviewVerdict,
1718 type RepositoryIdentity,
......@@ -77,6 +78,12 @@ export interface SourceControlClientDefinition {
7778 readonly defaultCloneTransport: "https" | "ssh";
7879 /** The web URL of a change request, or null when this client cannot build one for the host. */
7980 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>;
8087 /** A shell command that checks the change request out, or null when it cannot be built. */
8188 readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null;
8289 /** 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 = {
219226 publishHost: (signedInHost) => signedInHost ?? "your server",
220227 defaultCloneTransport: "ssh",
221228 changeRequestUrl: () => null,
229 changeRequestActions: new Set(),
222230 checkoutCommand: () => null,
223231 authorProfileUrl: () => null,
224232 referenceAutolinkRepositoryUrl: () => null,
packages/source-control-forgejo/src/client/definition.ts+1
......@@ -63,6 +63,7 @@ export const definition = defineSourceControlClient({
6363 }
6464 return `https://${host}/${repository}/pulls/${number}`;
6565 },
66 changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const),
6667 // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself.
6768 checkoutCommand: ({ number, repositoryUrl }) =>
6869 repositoryUrl
packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts+2-1
......@@ -33,12 +33,13 @@ import {
3333 forgejoChecks,
3434 forgejoReactions,
3535} from "./forgejoPullRequestJson.ts";
36import { definition } from "../client/definition.ts";
3637
3738const CAPABILITIES: PullRequestCapabilities = {
3839 diff: true,
3940 viewedFiles: "environment",
4041 comment: true,
41 actions: ["merge", "close", "reopen", "update-branch"],
42 actions: [...definition.changeRequestActions],
4243 mergeMethods: ["merge", "squash", "rebase"],
4344 updateMethods: ["merge", "rebase"],
4445 search: false,
packages/source-control-gitcafe/src/client/definition.ts+8
......@@ -31,6 +31,14 @@ export const definition = defineSourceControlClient({
3131 defaultCloneTransport: "ssh",
3232 changeRequestUrl: ({ host, repository, number }) =>
3333 `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),
3442 checkoutCommand: ({ number }) => `cafe pr checkout ${number}`,
3543 authorProfileUrl: () => null,
3644 referenceAutolinkRepositoryUrl: () => null,
packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts+2-1
......@@ -18,11 +18,12 @@ import {
1818import * as GitCafeApi from "./GitCafeApi.ts";
1919import * as GitCafeHosts from "./gitCafeHosts.ts";
2020import * as Json from "./gitCafePullRequestJson.ts";
21import { definition } from "../client/definition.ts";
2122
2223const CAPABILITIES: PullRequestCapabilities = {
2324 diff: true,
2425 comment: true,
25 actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"],
26 actions: [...definition.changeRequestActions],
2627 mergeMethods: ["merge", "squash", "rebase"],
2728 // Only through a stack: GitCafe's one branch update restacks every layer above it.
2829 updateMethods: ["rebase"],
packages/source-control-github/src/client/definition.ts+12
......@@ -30,6 +30,18 @@ export const definition = defineSourceControlClient({
3030 defaultCloneTransport: "https",
3131 changeRequestUrl: ({ host, repository, number }) =>
3232 `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),
3345 checkoutCommand: ({ number }) => `gh pr checkout ${number}`,
3446 authorProfileUrl: (login, repositoryUrl) =>
3547 login.endsWith("[bot]")
packages/source-control-github/src/server/GitHubPullRequestProvider.ts+2-12
......@@ -20,22 +20,12 @@ import {
2020 type ProviderRepositoryRef,
2121} from "@t3tools/source-control-core/server/PullRequestProvider";
2222import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";
23import { definition } from "../client/definition.ts";
2324
2425const CAPABILITIES: PullRequestCapabilities = {
2526 diff: true,
2627 comment: true,
27 actions: [
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 ],
28 actions: [...definition.changeRequestActions],
3929 mergeMethods: ["merge", "squash", "rebase"],
4030 updateMethods: ["merge", "rebase"],
4131 search: true,
packages/source-control-gitlab/src/client/definition.ts+10
......@@ -30,6 +30,16 @@ export const definition = defineSourceControlClient({
3030 defaultCloneTransport: "ssh",
3131 changeRequestUrl: ({ host, repository, number }) =>
3232 `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),
3343 checkoutCommand: ({ number }) => `glab mr checkout ${number}`,
3444 authorProfileUrl: () => null,
3545 referenceAutolinkRepositoryUrl: () => null,
packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts+2-10
......@@ -14,20 +14,12 @@ import {
1414 type ProviderChangeRequestDetail,
1515 type PullRequestProviderApi,
1616} from "@t3tools/source-control-core/server/PullRequestProvider";
17import { definition } from "../client/definition.ts";
1718
1819const CAPABILITIES: PullRequestCapabilities = {
1920 diff: true,
2021 comment: true,
21 actions: [
22 "merge",
23 "ready",
24 "draft",
25 "close",
26 "reopen",
27 "update-branch",
28 "enable-auto-merge",
29 "disable-auto-merge",
30 ],
22 actions: [...definition.changeRequestActions],
3123 // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it.
3224 mergeMethods: ["merge", "squash", "rebase"],
3325 // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing