From 9b40d3746388e573370488f73b50e1e869b79a45 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:07:01 -0700 Subject: [PATCH] feat(pull-requests): quick actions follow each host's capabilities, not GitHub (#17774) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestService.test.ts | 29 ++++++++++++ .../src/pullRequest/PullRequestService.ts | 24 +++++----- .../components/pullRequest/PullRequestRow.tsx | 7 ++- .../pullRequest/PullRequestSpeedActions.tsx | 13 +++--- .../pullRequest/ThreadPullRequestsPanel.tsx | 27 +++++++---- .../pullRequest/pullRequestChecks.test.tsx | 1 + .../pullRequest/pullRequestList.logic.test.ts | 41 ++++++++++++++++- .../pullRequest/pullRequestList.logic.ts | 42 +++++++++++++++++ .../pullRequest/usePullRequestActions.ts | 4 +- apps/web/src/routes/_chat.pull-requests.tsx | 45 +++++++++++-------- packages/contracts/src/pullRequest.ts | 5 +++ .../src/client/definition.ts | 9 ++++ .../server/AzureDevOpsPullRequestProvider.ts | 11 +---- .../src/client/definition.ts | 3 ++ .../server/BitbucketPullRequestProvider.ts | 5 +-- .../src/client/definition.ts | 8 ++++ .../src/client/definition.ts | 1 + .../src/server/ForgejoPullRequestProvider.ts | 3 +- .../src/client/definition.ts | 8 ++++ .../src/server/GitCafePullRequestProvider.ts | 3 +- .../src/client/definition.ts | 12 +++++ .../src/server/GitHubPullRequestProvider.ts | 14 +----- .../src/client/definition.ts | 10 +++++ .../src/server/GitLabPullRequestProvider.ts | 12 +---- 24 files changed, 254 insertions(+), 83 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index cb544d4898acc4eb5043492977d19068ec275069..bd0a0cd42e58fb3b18eaf715677819c0426fddde 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -572,6 +572,35 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos }), ); +it.effect("reports each host's actions with its summary, so rows can offer them unread", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/repo", + repository: "acme/web", + provider: SourceControlProviderKind.make("bitbucket"), + host: "bitbucket.org", + }), + ], + providers: [ + fakeProvider(SourceControlProviderKind.make("bitbucket"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("bitbucket")).capabilities, + actions: ["merge", "close"], + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(result.providers[0]?.actions, ["merge", "close"]); + }), +); + it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => Effect.gen(function* () { let refinementCalls = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index e4d8178dbbe9d363c2c99f0fdc77dd0881a2259a..db6b8424a8fee37a4b19f93a4a6495a464f9d821 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1252,20 +1252,24 @@ export const make = Effect.gen(function* () { // One summary per host, which is what the viewer lookup already answers for: two GitHub // hosts sign in separately, so collapsing them by kind would report one as the other. const providers: ReadonlyArray = [ - ...viewerResults.map((result) => ({ - host: result.host, - kind: result.kind, - searchesOnHost: - projects.find((project) => project.host === result.host)?.api.capabilities.search ?? - false, - projectCount: projectCounts.get(result.host) ?? 1, - configured: result.viewer !== null, - detail: result.error === null ? null : providerDetail(result.error), - })), + ...viewerResults.map((result) => { + const capabilities = projects.find((project) => project.host === result.host)?.api + .capabilities; + return { + host: result.host, + kind: result.kind, + searchesOnHost: capabilities?.search ?? false, + actions: capabilities?.actions ?? [], + projectCount: projectCounts.get(result.host) ?? 1, + configured: result.viewer !== null, + detail: result.error === null ? null : providerDetail(result.error), + }; + }), ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ host, kind, searchesOnHost: false, + actions: [], projectCount, configured: false, detail: "This host cannot be browsed here yet.", diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index a1467e79f05f7e4bc1ad32743788f7db82d86f78..dfdd0f36890f6aac10ec9ebeafc690f0496cd749 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,3 +1,4 @@ +import type { PullRequestAction } from "@t3tools/contracts"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { SearchIcon } from "lucide-react"; import { PullRequestStackPopover } from "./PullRequestStackPopover"; @@ -91,6 +92,7 @@ function PullRequestRowImpl({ statsRef, onSelect, speedMode, + hostActions, onActed, closing = false, sweeping = false, @@ -113,6 +115,8 @@ function PullRequestRowImpl({ statsRef?: RefCallback; onSelect: (entry: PullRequestRowTarget) => void; speedMode: boolean; + /** What this row's host can do, which is what speed mode offers. */ + hostActions: ReadonlySet; onActed: (result: PullRequestSpeedActionResult) => void; closing?: boolean; sweeping?: boolean; @@ -256,9 +260,10 @@ function PullRequestRowImpl({ updatedAt={entry.updatedAt} /> - {entry.state !== "merged" && entry.provider === "github" ? ( + {hostActions.size > 0 ? ( ({ entry, + hostActions, visible, onActed, closing = false, @@ -33,6 +34,8 @@ export function PullRequestSpeedActions; visible: boolean; onActed?: (result: PullRequestSpeedActionResult) => void; closing?: boolean; @@ -70,13 +73,9 @@ export function PullRequestSpeedActions = { @@ -105,11 +108,12 @@ function LinkRow({ const snapshot = link.snapshot; const open = snapshot === null || snapshot.state === "open"; const watching = link.watch !== undefined; + // A link carries no host summary, so the host's own definition says what it can do. + const linkHostActions = ( + sourceControlClients.findByChangeRequestUrl(link.url) ?? UNKNOWN_SOURCE_CONTROL_CLIENT + ).changeRequestActions; const actionEntry = - projectId !== null && - snapshot !== null && - snapshot.state !== "merged" && - detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "github" + projectId !== null && snapshot !== null && snapshot.state !== "merged" ? { environmentId: threadRef.environmentId, projectId, @@ -121,6 +125,9 @@ function LinkRow({ ...(link.stack === null ? {} : { stack: link.stack }), } : null; + // Speed mode swaps the menu for quick actions only where a row has some to offer. + const hasQuickActions = + actionEntry !== null && pullRequestQuickActions(actionEntry, linkHostActions).length > 0; return (
{actionEntry !== null ? ( - + ) : null} {/* Out of the row's flow, so no row reserves a column for a button only the hovered one shows. It sits over the right end of the second line on the row's own hover color, @@ -261,7 +272,7 @@ function LinkRow({ "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100", "has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100", "group-has-[[data-pull-request-action-pending=true]]/pr-row:hidden", - speedMode && actionEntry !== null && "hidden", + speedMode && hasQuickActions && "hidden", )} > diff --git a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx index b0e8c69517bc384f924234f0361a3052aa891d15..24c49bc4a6ace8f3ff347afad3aeb2a69af0ac58 100644 --- a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx +++ b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx @@ -103,6 +103,7 @@ function row(overrides: Partial): ReactNode { showProvider: false, onSelect: () => {}, speedMode: false, + hostActions: new Set(), onActed: () => {}, }); } diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index ebaa045a66a75dada399e06dfe2bfb89fb280b15..4e4f04f2ec54e75d685d50e6468ec48a7a6d7269 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -1,8 +1,15 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts"; +import type { + EnvironmentId, + ProjectId, + PullRequestAction, + PullRequestListEntry, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + pullRequestHostActions, + pullRequestQuickActions, filterPullRequestsByInvolvement, findScopedProject, mergePullRequestLists, @@ -1720,3 +1727,35 @@ describe("pull request list override settlement", () => { expect(swapped.map((row) => row.number)).toEqual([2, 1]); }); }); + +describe("pull request quick actions", () => { + const summary = (host: string, kind: string, actions?: ReadonlyArray) => ({ + host, + kind: SourceControlProviderKind.make(kind), + searchesOnHost: true, + projectCount: 1, + configured: true, + detail: null, + ...(actions === undefined ? {} : { actions }), + }); + + it("offers what each host reports, and GitHub's legacy set when a server reports none", () => { + const actionsOf = pullRequestHostActions([ + summary("bitbucket.org", "bitbucket", ["merge", "close"]), + summary("github.com", "github"), + ]); + const bitbucket = actionsOf({ + host: "bitbucket.org", + provider: SourceControlProviderKind.make("bitbucket"), + }); + expect(pullRequestQuickActions({ state: "closed", isDraft: false }, bitbucket)).toEqual([]); + const github = actionsOf({ + host: "github.com", + provider: SourceControlProviderKind.make("github"), + }); + expect(pullRequestQuickActions({ state: "open", isDraft: true }, github)).toEqual([ + "close", + "ready", + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 04f4711aeaa85131e4afecf3e922b4e7f375b0d7..f56929c5dbc8db882b9aa7090c0a9190c5c42780 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1262,3 +1262,45 @@ export function settlePullRequestOverrides( } return kept.size === overrides.size ? overrides : kept; } + +/** The row actions speed mode offers, in the order they are drawn. */ +export type PullRequestQuickAction = "close" | "merge" | "ready" | "reopen"; + +/** + * Which actions each host can take on a listed row, before any row's detail is read. Hosts are + * matched by the host summary; a server that does not report actions per host only ever offered + * these on GitHub, so that is what it keeps getting. + */ +export function pullRequestHostActions( + providers: ReadonlyArray, +): ( + entry: Pick, +) => ReadonlySet { + const byHost = new Map( + providers.flatMap((provider) => + provider.actions === undefined + ? [] + : [[provider.host.toLowerCase(), new Set(provider.actions)] as const], + ), + ); + const legacy = new Set(["close", "merge", "ready", "reopen"]); + const none = new Set(); + return (entry) => + byHost.get(entry.host.toLowerCase()) ?? (entry.provider === "github" ? legacy : none); +} + +/** The quick actions a row offers: what its state allows, narrowed to what its host can do. */ +export function pullRequestQuickActions( + entry: Pick, + hostActions: ReadonlySet, +): ReadonlyArray { + const forState: ReadonlyArray = + entry.state === "merged" + ? [] + : entry.state === "closed" + ? ["reopen"] + : entry.isDraft + ? ["close", "ready"] + : ["close", "merge"]; + return forState.filter((action) => hostActions.has(action)); +} diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index 59c1416aecc90f0c870e2acffd93fbea8f95adb9..3ca753df2b82a3b2c41f37ddc2911a62dfcfe30f 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -186,8 +186,8 @@ export function usePullRequestCloseBatch(onClosed: (entry: EnvironmentPullReques async (entries: readonly EnvironmentPullRequestEntry[]) => { const batch = entries.filter((entry) => { const key = pullRequestEntryKey(entry); - if (entry.state !== "open" || entry.provider !== "github" || pending.current.has(key)) - return false; + // The sweep only gathers rows whose host can close them, so state is all that is left. + if (entry.state !== "open" || pending.current.has(key)) return false; pending.current.add(key); return true; }); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 02e0e9e26d4647dbba787b7418543a542dbf6ed8..86fcbe400bc965e6e987968bf085e0269e0926a0 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -88,6 +88,7 @@ import { pullRequestOverrideAfterAction, reusePullRequestEntries, settlePullRequestOverrides, + pullRequestHostActions, } from "../components/pullRequest/pullRequestList.logic"; import { pullRequestListPreferences, @@ -1592,6 +1593,22 @@ function PullRequestsRouteView() { ]); /** What is actually on screen once the reader's pending answers are on the rows. */ const shownCount = displayGroups.reduce((count, group) => count + group.entries.length, 0); + // The provider list is the workspace's hosts, not the filtered ones, so switching to a host + // cannot make the switcher that got you there disappear. + const [hosts, setHosts] = useState([]); + useEffect(() => { + // Only from an answer to these filters. Rows carried over from the previous ones bring the + // host summaries of the question they answered, and coming back from one host to all of them + // would take the switcher's other hosts out of it on the strength of the narrowed answer. + if (answered === null) return; + // An unfiltered response is the full set of hosts. A filtered one only seeds the switcher + // when there is nothing to seed it with, which is a link that arrived already scoped. + setHosts((previous) => + search.host === undefined || previous.length === 0 ? answered.providers : previous, + ); + }, [answered, search.host]); + const showProvider = hosts.length > 1; + const hostActionsOf = useMemo(() => pullRequestHostActions(hosts), [hosts]); const [closeSweepKeys, setCloseSweepKeys] = useState>(() => new Set()); const closeSensorRef = useRef(null); const closeSweepRows = useMemo( @@ -1605,8 +1622,14 @@ function PullRequestsRouteView() { ), [displayGroups], ); - const closeSweepRef = useRef({ displayGroups, closeSweepRows, closingKeys, closeBatch }); - closeSweepRef.current = { displayGroups, closeSweepRows, closingKeys, closeBatch }; + const closeSweepRef = useRef({ + displayGroups, + closeSweepRows, + closingKeys, + closeBatch, + hostActionsOf, + }); + closeSweepRef.current = { displayGroups, closeSweepRows, closingKeys, closeBatch, hostActionsOf }; useEffect(() => () => closeSensorRef.current?.cancel(), [filterKey, search.q, sort]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -1632,7 +1655,7 @@ function PullRequestsRouteView() { return ( row?.groupKey === group.key && row.entry.state === "open" && - row.entry.provider === "github" && + closeSweepRef.current.hostActionsOf(row.entry).has("close") && !closeSweepRef.current.closingKeys.has(key) && !scrollRef.current?.querySelector( `[data-pull-request-key="${CSS.escape(key)}"] [data-pull-request-action-pending="true"]`, @@ -1766,21 +1789,6 @@ function PullRequestsRouteView() { selectSurfaceInUrl(selectedPullRequestSurface); }; - // The provider list is the workspace's hosts, not the filtered ones, so switching to a host - // cannot make the switcher that got you there disappear. - const [hosts, setHosts] = useState([]); - useEffect(() => { - // Only from an answer to these filters. Rows carried over from the previous ones bring the - // host summaries of the question they answered, and coming back from one host to all of them - // would take the switcher's other hosts out of it on the strength of the narrowed answer. - if (answered === null) return; - // An unfiltered response is the full set of hosts. A filtered one only seeds the switcher - // when there is nothing to seed it with, which is a link that arrived already scoped. - setHosts((previous) => - search.host === undefined || previous.length === 0 ? answered.providers : previous, - ); - }, [answered, search.host]); - const showProvider = hosts.length > 1; // The workspace's own projects already name their hosts, so the row's shape is known before // the list is. Only its shape: which hosts can actually be read still comes from the server. const expectedHosts = useMemo(() => { @@ -1942,6 +1950,7 @@ function PullRequestsRouteView() { } onSelect={selectEntry} speedMode={speedMode} + hostActions={hostActionsOf(entry)} onActed={onSpeedAction} closing={closingKeys.has(entryKey)} sweeping={closeSweepKeys.has(entryKey)} diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 031439f9a2935441f79b771467ca8bcbc7202799..2ff64a56e6e23dc19864e9a4c82add23433cddcc 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -634,6 +634,11 @@ export const PullRequestProviderSummary = Schema.Struct({ kind: SourceControlProviderKind, /** False where a search has to be applied to the rows after they arrive. */ searchesOnHost: Schema.Boolean, + /** + * The actions this host can carry out, so a row can offer them before its detail is read. + * Absent from servers older than this field; clients then offer what they always did. + */ + actions: Schema.optional(Schema.Array(PullRequestAction)), projectCount: PositiveInt, /** False when the provider's CLI or credentials are missing, with `detail` saying which. */ configured: Schema.Boolean, diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index 57c48b9a19929bb8c3aabe88156bf27bc96f76da..c0c1722cf07bc33b6e1011b6f975aa11dfe4c99e 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -40,6 +40,15 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`, + changeRequestActions: new Set([ + "merge", + "ready", + "draft", + "close", + "reopen", + "enable-auto-merge", + "disable-auto-merge", + ] as const), checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index 965726c223b10f2f2471768933f8dd057aba08e9..78d31da360f5e8ffd8349b0a86d53d7b552ad774 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -33,6 +33,7 @@ import type { AzureDevOpsPullRequest, AzureDevOpsRepositoryLocation, } from "./azureDevOpsPullRequestJson.ts"; +import { definition } from "../client/definition.ts"; /** * 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 = { // Reading a conversation is a plain REST read, but posting one is not something this can // claim without having run it, so the composer stays hidden. comment: false, - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "enable-auto-merge", - "disable-auto-merge", - ], + actions: [...definition.changeRequestActions], // Azure squashes as a completion option; it has no rebase strategy of its own. mergeMethods: ["merge", "squash"], // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index a513d86e10017b6316d037cb30b4c4ef88b026d0..dc02f6351fed6d38491b9c64f0aa180b30a96184 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -29,6 +29,9 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/pull-requests/${number}`, + // No endpoint reopens a declined pull request, and nothing documented moves one in or out of + // draft, so neither is offered rather than failing when pressed. + changeRequestActions: new Set(["merge", "close"] as const), // Bitbucket has no checkout CLI, so clone the head branch from its own repository. checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) => headRepositoryNameWithOwner && diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index d09ec748b3ef58d0818c0cf3dcffe8bd26e6f5a5..3d26d5401602995b9e31c8a63fee3c501f3dfaa4 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -12,13 +12,12 @@ import { type PullRequestProviderApi, } from "@t3tools/source-control-core/server/PullRequestProvider"; import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; +import { definition } from "../client/definition.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that - // moves one in or out of draft, so neither is offered rather than failing when pressed. - actions: ["merge", "close"], + actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], search: true, // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 77d241a8a6bafec684979dbe1929f6ecd5cfec84..886573d67761d23820b0310e178ad427689624e4 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -12,6 +12,7 @@ * @module source-control-core/client/definition */ import { + type PullRequestAction, pullRequestHostOf, type PullRequestReviewVerdict, type RepositoryIdentity, @@ -77,6 +78,12 @@ export interface SourceControlClientDefinition { readonly defaultCloneTransport: "https" | "ssh"; /** The web URL of a change request, or null when this client cannot build one for the host. */ readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; + /** + * The actions this host can take on a change request, the same list its server provider + * declares in `capabilities.actions`. Surfaces with no server answer to hand, such as a thread's + * linked pull requests, offer actions from this. + */ + readonly changeRequestActions: ReadonlySet; /** A shell command that checks the change request out, or null when it cannot be built. */ readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null; /** 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 = { publishHost: (signedInHost) => signedInHost ?? "your server", defaultCloneTransport: "ssh", changeRequestUrl: () => null, + changeRequestActions: new Set(), checkoutCommand: () => null, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index 3ec31d39270df970c1f82726c10e32c24b070260..d3425fe646508b633d7a93bf78316a9977de0699 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -63,6 +63,7 @@ export const definition = defineSourceControlClient({ } return `https://${host}/${repository}/pulls/${number}`; }, + changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const), // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. checkoutCommand: ({ number, repositoryUrl }) => repositoryUrl diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index 51b3974a6bd26166d3773bd38ffc26d670351114..d55c205240dc2f65e7684a1ab3ef795128e71d39 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -33,12 +33,13 @@ import { forgejoChecks, forgejoReactions, } from "./forgejoPullRequestJson.ts"; +import { definition } from "../client/definition.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, viewedFiles: "environment", comment: true, - actions: ["merge", "close", "reopen", "update-branch"], + actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], updateMethods: ["merge", "rebase"], search: false, diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 38d03014dd3c95c511c8fae9d6100130209dd2b5..dffd90f8577a2ca0dd7ff836920a9fa83fef95be 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -31,6 +31,14 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/pulls/${number}`, + changeRequestActions: new Set([ + "ready", + "draft", + "close", + "reopen", + "merge", + "update-branch", + ] as const), checkoutCommand: ({ number }) => `cafe pr checkout ${number}`, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 70d8c107ea0b250f3f8f0ba7b0055618843417ce..4bbd54b73cf9d1620ed4815510fd9599598715f2 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -18,11 +18,12 @@ import { import * as GitCafeApi from "./GitCafeApi.ts"; import * as GitCafeHosts from "./gitCafeHosts.ts"; import * as Json from "./gitCafePullRequestJson.ts"; +import { definition } from "../client/definition.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"], + actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], // Only through a stack: GitCafe's one branch update restacks every layer above it. updateMethods: ["rebase"], diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 9e6adcff8fbe3d0230609e3a8ff4172f029d0fde..b1345d41f5a42c742ba0dd662bb9666046bcb9bc 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -30,6 +30,18 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "https", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/pull/${number}`, + changeRequestActions: new Set([ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + "revert", + "approve-workflows", + ] as const), checkoutCommand: ({ number }) => `gh pr checkout ${number}`, authorProfileUrl: (login, repositoryUrl) => login.endsWith("[bot]") diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index b99972d58af036b9011f9a5ad0ad135e015ad6f5..baa40936aca15c412a6161c8387593de62310687 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -20,22 +20,12 @@ import { type ProviderRepositoryRef, } from "@t3tools/source-control-core/server/PullRequestProvider"; import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; +import { definition } from "../client/definition.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "update-branch", - "enable-auto-merge", - "disable-auto-merge", - "revert", - "approve-workflows", - ], + actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], updateMethods: ["merge", "rebase"], search: true, diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index e1d875b56827df3e59163fdb1f78a53613494c37..e7040cfe3cc46cc6d9df774efe7e27e9ef5c3c1b 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -30,6 +30,16 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/-/merge_requests/${number}`, + changeRequestActions: new Set([ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ] as const), checkoutCommand: ({ number }) => `glab mr checkout ${number}`, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index db6548eb18f32b5b692f5472983ae5460c6bc315..89307f696d7ae3e4f816d82aab8f38b1c6a761ff 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -14,20 +14,12 @@ import { type ProviderChangeRequestDetail, type PullRequestProviderApi, } from "@t3tools/source-control-core/server/PullRequestProvider"; +import { definition } from "../client/definition.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "update-branch", - "enable-auto-merge", - "disable-auto-merge", - ], + actions: [...definition.changeRequestActions], // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. mergeMethods: ["merge", "squash", "rebase"], // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing -- 2.54.0