diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index cb544d4898ac..bd0a0cd42e58 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 e4d8178dbbe9..db6b8424a8fe 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 a1467e79f05f..dfdd0f36890f 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 b0e8c69517bc..24c49bc4a6ac 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 ebaa045a66a7..4e4f04f2ec54 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 04f4711aeaa8..f56929c5dbc8 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 59c1416aecc9..3ca753df2b82 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 02e0e9e26d46..86fcbe400bc9 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 031439f9a293..2ff64a56e6e2 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 57c48b9a1992..c0c1722cf07b 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 965726c223b1..78d31da360f5 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 a513d86e1001..dc02f6351fed 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 d09ec748b3ef..3d26d5401602 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 77d241a8a6ba..886573d67761 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 3ec31d39270d..d3425fe64650 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 51b3974a6bd2..d55c205240dc 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 38d03014dd3c..dffd90f8577a 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 70d8c107ea0b..4bbd54b73cf9 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 9e6adcff8fbe..b1345d41f5a4 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 b99972d58af0..baa40936aca1 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 e1d875b56827..e7040cfe3cc4 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 db6548eb18f3..89307f696d7a 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