| author | |
| committer | |
| log | 9b40d3746388e573370488f73b50e1e869b79a45 |
| tree | 5a64aa06504457f7f949ca41b473070736060814 |
| parent | b0249ac8711703f1a1b8cd0f3acd11155f5bf172 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 572 | }), |
| 573 | 573 | ); |
| 574 | 574 | |
| 575 | it.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 | ||
| 575 | 604 | it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => |
| 576 | 605 | Effect.gen(function* () { |
| 577 | 606 | let refinementCalls = 0; |
apps/server/src/pullRequest/PullRequestService.ts+14-10| ... | ... | @@ -1252,20 +1252,24 @@ export const make = Effect.gen(function* () { |
| 1252 | 1252 | // One summary per host, which is what the viewer lookup already answers for: two GitHub |
| 1253 | 1253 | // hosts sign in separately, so collapsing them by kind would report one as the other. |
| 1254 | 1254 | 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 | }), | |
| 1265 | 1268 | ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ |
| 1266 | 1269 | host, |
| 1267 | 1270 | kind, |
| 1268 | 1271 | searchesOnHost: false, |
| 1272 | actions: [], | |
| 1269 | 1273 | projectCount, |
| 1270 | 1274 | configured: false, |
| 1271 | 1275 | detail: "This host cannot be browsed here yet.", |
apps/web/src/components/pullRequest/PullRequestRow.tsx+6-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { PullRequestAction } from "@t3tools/contracts"; | |
| 1 | 2 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 2 | 3 | import { SearchIcon } from "lucide-react"; |
| 3 | 4 | import { PullRequestStackPopover } from "./PullRequestStackPopover"; |
| ... | ... | @@ -91,6 +92,7 @@ function PullRequestRowImpl({ |
| 91 | 92 | statsRef, |
| 92 | 93 | onSelect, |
| 93 | 94 | speedMode, |
| 95 | hostActions, | |
| 94 | 96 | onActed, |
| 95 | 97 | closing = false, |
| 96 | 98 | sweeping = false, |
| ... | ... | @@ -113,6 +115,8 @@ function PullRequestRowImpl({ |
| 113 | 115 | statsRef?: RefCallback<HTMLDivElement>; |
| 114 | 116 | onSelect: (entry: PullRequestRowTarget) => void; |
| 115 | 117 | speedMode: boolean; |
| 118 | /** What this row's host can do, which is what speed mode offers. */ | |
| 119 | hostActions: ReadonlySet<PullRequestAction>; | |
| 116 | 120 | onActed: (result: PullRequestSpeedActionResult) => void; |
| 117 | 121 | closing?: boolean; |
| 118 | 122 | sweeping?: boolean; |
| ... | ... | @@ -256,9 +260,10 @@ function PullRequestRowImpl({ |
| 256 | 260 | updatedAt={entry.updatedAt} |
| 257 | 261 | /> |
| 258 | 262 | </button> |
| 259 | {entry.state !== "merged" && entry.provider === "github" ? ( | |
| 263 | {hostActions.size > 0 ? ( | |
| 260 | 264 | <PullRequestSpeedActions |
| 261 | 265 | entry={entry} |
| 266 | hostActions={hostActions} | |
| 262 | 267 | visible={speedMode} |
| 263 | 268 | onActed={onActed} |
| 264 | 269 | closing={closing} |
apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx+6-7| ... | ... | @@ -7,7 +7,7 @@ import { Spinner } from "../ui/spinner"; |
| 7 | 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 8 | 8 | import { resolvePullRequestMergeMethod } from "./pullRequestDetail.logic"; |
| 9 | 9 | import { PullRequestGlyph } from "./pullRequestIcons"; |
| 10 | import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; | |
| 10 | import { pullRequestQuickActions, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; | |
| 11 | 11 | import { |
| 12 | 12 | usePullRequestActionRunner, |
| 13 | 13 | usePullRequestDefaultMergeMethodResolver, |
| ... | ... | @@ -26,6 +26,7 @@ export interface PullRequestSpeedActionResult<Entry = EnvironmentPullRequestEntr |
| 26 | 26 | /** No detail or stack reads until a merge is clicked, even on a long list. */ |
| 27 | 27 | export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntry>({ |
| 28 | 28 | entry, |
| 29 | hostActions, | |
| 29 | 30 | visible, |
| 30 | 31 | onActed, |
| 31 | 32 | closing = false, |
| ... | ... | @@ -33,6 +34,8 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr |
| 33 | 34 | onCloseSweepStart, |
| 34 | 35 | }: { |
| 35 | 36 | entry: Entry; |
| 37 | /** What the row's host can do; actions it cannot are not offered. */ | |
| 38 | hostActions: ReadonlySet<PullRequestAction>; | |
| 36 | 39 | visible: boolean; |
| 37 | 40 | onActed?: (result: PullRequestSpeedActionResult<Entry>) => void; |
| 38 | 41 | closing?: boolean; |
| ... | ... | @@ -70,13 +73,9 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr |
| 70 | 73 | ); |
| 71 | 74 | }, |
| 72 | 75 | }); |
| 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); | |
| 79 | 77 | const busy = actionPending || closing || sweeping; |
| 78 | if (actions.length === 0) return null; | |
| 80 | 79 | return ( |
| 81 | 80 | <div |
| 82 | 81 | className="shrink-0 items-center gap-1 pr-3" |
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+19-8| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; |
| 2 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | |
| 3 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | |
| 2 | import { | |
| 3 | sourceControlClients, | |
| 4 | UNKNOWN_SOURCE_CONTROL_CLIENT, | |
| 5 | } from "@t3tools/client-runtime/source-control-clients"; | |
| 4 | 6 | import { |
| 5 | 7 | resolveThreadPullRequestChains, |
| 6 | 8 | visibleThreadPullRequests, |
| ... | ... | @@ -45,6 +47,7 @@ import { |
| 45 | 47 | pullRequestChecksStatePresentation, |
| 46 | 48 | } from "./pullRequestPresentation"; |
| 47 | 49 | import { PullRequestGlyph } from "./pullRequestIcons"; |
| 50 | import { pullRequestQuickActions } from "./pullRequestList.logic"; | |
| 48 | 51 | import { PullRequestSpeedActions } from "./PullRequestSpeedActions"; |
| 49 | 52 | |
| 50 | 53 | const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = { |
| ... | ... | @@ -105,11 +108,12 @@ function LinkRow({ |
| 105 | 108 | const snapshot = link.snapshot; |
| 106 | 109 | const open = snapshot === null || snapshot.state === "open"; |
| 107 | 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 | 115 | 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" | |
| 113 | 117 | ? { |
| 114 | 118 | environmentId: threadRef.environmentId, |
| 115 | 119 | projectId, |
| ... | ... | @@ -121,6 +125,9 @@ function LinkRow({ |
| 121 | 125 | ...(link.stack === null ? {} : { stack: link.stack }), |
| 122 | 126 | } |
| 123 | 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 | 131 | return ( |
| 125 | 132 | <div |
| 126 | 133 | className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")} |
| ... | ... | @@ -246,7 +253,11 @@ function LinkRow({ |
| 246 | 253 | /> |
| 247 | 254 | </a> |
| 248 | 255 | {actionEntry !== null ? ( |
| 249 | <PullRequestSpeedActions entry={actionEntry} visible={speedMode} /> | |
| 256 | <PullRequestSpeedActions | |
| 257 | entry={actionEntry} | |
| 258 | hostActions={linkHostActions} | |
| 259 | visible={speedMode} | |
| 260 | /> | |
| 250 | 261 | ) : null} |
| 251 | 262 | {/* Out of the row's flow, so no row reserves a column for a button only the hovered one |
| 252 | 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 | 272 | "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100", |
| 262 | 273 | "has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100", |
| 263 | 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 | 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 | 103 | showProvider: false, |
| 104 | 104 | onSelect: () => {}, |
| 105 | 105 | speedMode: false, |
| 106 | hostActions: new Set(), | |
| 106 | 107 | onActed: () => {}, |
| 107 | 108 | }); |
| 108 | 109 | } |
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+40-1| ... | ... | @@ -1,8 +1,15 @@ |
| 1 | 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts"; | |
| 2 | import type { | |
| 3 | EnvironmentId, | |
| 4 | ProjectId, | |
| 5 | PullRequestAction, | |
| 6 | PullRequestListEntry, | |
| 7 | } from "@t3tools/contracts"; | |
| 3 | 8 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 9 | |
| 5 | 10 | import { |
| 11 | pullRequestHostActions, | |
| 12 | pullRequestQuickActions, | |
| 6 | 13 | filterPullRequestsByInvolvement, |
| 7 | 14 | findScopedProject, |
| 8 | 15 | mergePullRequestLists, |
| ... | ... | @@ -1720,3 +1727,35 @@ describe("pull request list override settlement", () => { |
| 1720 | 1727 | expect(swapped.map((row) => row.number)).toEqual([2, 1]); |
| 1721 | 1728 | }); |
| 1722 | 1729 | }); |
| 1730 | ||
| 1731 | describe("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 | 1262 | } |
| 1263 | 1263 | return kept.size === overrides.size ? overrides : kept; |
| 1264 | 1264 | } |
| 1265 | ||
| 1266 | /** The row actions speed mode offers, in the order they are drawn. */ | |
| 1267 | export 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 | */ | |
| 1274 | export 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. */ | |
| 1293 | export 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 | 186 | async (entries: readonly EnvironmentPullRequestEntry[]) => { |
| 187 | 187 | const batch = entries.filter((entry) => { |
| 188 | 188 | 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; | |
| 191 | 191 | pending.current.add(key); |
| 192 | 192 | return true; |
| 193 | 193 | }); |
apps/web/src/routes/_chat.pull-requests.tsx+27-18| ... | ... | @@ -88,6 +88,7 @@ import { |
| 88 | 88 | pullRequestOverrideAfterAction, |
| 89 | 89 | reusePullRequestEntries, |
| 90 | 90 | settlePullRequestOverrides, |
| 91 | pullRequestHostActions, | |
| 91 | 92 | } from "../components/pullRequest/pullRequestList.logic"; |
| 92 | 93 | import { |
| 93 | 94 | pullRequestListPreferences, |
| ... | ... | @@ -1592,6 +1593,22 @@ function PullRequestsRouteView() { |
| 1592 | 1593 | ]); |
| 1593 | 1594 | /** What is actually on screen once the reader's pending answers are on the rows. */ |
| 1594 | 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 | 1612 | const [closeSweepKeys, setCloseSweepKeys] = useState<ReadonlySet<string>>(() => new Set()); |
| 1596 | 1613 | const closeSensorRef = useRef<SidebarPointerSensor | null>(null); |
| 1597 | 1614 | const closeSweepRows = useMemo( |
| ... | ... | @@ -1605,8 +1622,14 @@ function PullRequestsRouteView() { |
| 1605 | 1622 | ), |
| 1606 | 1623 | [displayGroups], |
| 1607 | 1624 | ); |
| 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 }; | |
| 1610 | 1633 | useEffect(() => () => closeSensorRef.current?.cancel(), [filterKey, search.q, sort]); |
| 1611 | 1634 | useEffect(() => { |
| 1612 | 1635 | const onKeyDown = (event: KeyboardEvent) => { |
| ... | ... | @@ -1632,7 +1655,7 @@ function PullRequestsRouteView() { |
| 1632 | 1655 | return ( |
| 1633 | 1656 | row?.groupKey === group.key && |
| 1634 | 1657 | row.entry.state === "open" && |
| 1635 | row.entry.provider === "github" && | |
| 1658 | closeSweepRef.current.hostActionsOf(row.entry).has("close") && | |
| 1636 | 1659 | !closeSweepRef.current.closingKeys.has(key) && |
| 1637 | 1660 | !scrollRef.current?.querySelector( |
| 1638 | 1661 | `[data-pull-request-key="${CSS.escape(key)}"] [data-pull-request-action-pending="true"]`, |
| ... | ... | @@ -1766,21 +1789,6 @@ function PullRequestsRouteView() { |
| 1766 | 1789 | selectSurfaceInUrl(selectedPullRequestSurface); |
| 1767 | 1790 | }; |
| 1768 | 1791 | |
| 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 | 1792 | // The workspace's own projects already name their hosts, so the row's shape is known before |
| 1785 | 1793 | // the list is. Only its shape: which hosts can actually be read still comes from the server. |
| 1786 | 1794 | const expectedHosts = useMemo(() => { |
| ... | ... | @@ -1942,6 +1950,7 @@ function PullRequestsRouteView() { |
| 1942 | 1950 | } |
| 1943 | 1951 | onSelect={selectEntry} |
| 1944 | 1952 | speedMode={speedMode} |
| 1953 | hostActions={hostActionsOf(entry)} | |
| 1945 | 1954 | onActed={onSpeedAction} |
| 1946 | 1955 | closing={closingKeys.has(entryKey)} |
| 1947 | 1956 | sweeping={closeSweepKeys.has(entryKey)} |
packages/contracts/src/pullRequest.ts+5| ... | ... | @@ -634,6 +634,11 @@ export const PullRequestProviderSummary = Schema.Struct({ |
| 634 | 634 | kind: SourceControlProviderKind, |
| 635 | 635 | /** False where a search has to be applied to the rows after they arrive. */ |
| 636 | 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 | 642 | projectCount: PositiveInt, |
| 638 | 643 | /** False when the provider's CLI or credentials are missing, with `detail` saying which. */ |
| 639 | 644 | configured: Schema.Boolean, |
packages/source-control-azure-devops/src/client/definition.ts+9| ... | ... | @@ -40,6 +40,15 @@ export const definition = defineSourceControlClient({ |
| 40 | 40 | defaultCloneTransport: "ssh", |
| 41 | 41 | changeRequestUrl: ({ host, repository, number }) => |
| 42 | 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 | 52 | checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`, |
| 44 | 53 | authorProfileUrl: () => null, |
| 45 | 54 | referenceAutolinkRepositoryUrl: () => null, |
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts+2-9| ... | ... | @@ -33,6 +33,7 @@ import type { |
| 33 | 33 | AzureDevOpsPullRequest, |
| 34 | 34 | AzureDevOpsRepositoryLocation, |
| 35 | 35 | } from "./azureDevOpsPullRequestJson.ts"; |
| 36 | import { definition } from "../client/definition.ts"; | |
| 36 | 37 | |
| 37 | 38 | /** |
| 38 | 39 | * 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 | 61 | // Reading a conversation is a plain REST read, but posting one is not something this can |
| 61 | 62 | // claim without having run it, so the composer stays hidden. |
| 62 | 63 | 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], | |
| 72 | 65 | // Azure squashes as a completion option; it has no rebase strategy of its own. |
| 73 | 66 | mergeMethods: ["merge", "squash"], |
| 74 | 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 | 29 | defaultCloneTransport: "ssh", |
| 30 | 30 | changeRequestUrl: ({ host, repository, number }) => |
| 31 | 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 | 35 | // Bitbucket has no checkout CLI, so clone the head branch from its own repository. |
| 33 | 36 | checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) => |
| 34 | 37 | headRepositoryNameWithOwner && |
packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts+2-3| ... | ... | @@ -12,13 +12,12 @@ import { |
| 12 | 12 | type PullRequestProviderApi, |
| 13 | 13 | } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 14 | 14 | import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; |
| 15 | import { definition } from "../client/definition.ts"; | |
| 15 | 16 | |
| 16 | 17 | const CAPABILITIES: PullRequestCapabilities = { |
| 17 | 18 | diff: true, |
| 18 | 19 | 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], | |
| 22 | 21 | mergeMethods: ["merge", "squash", "rebase"], |
| 23 | 22 | search: true, |
| 24 | 23 | // 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 | 12 | * @module source-control-core/client/definition |
| 13 | 13 | */ |
| 14 | 14 | import { |
| 15 | type PullRequestAction, | |
| 15 | 16 | pullRequestHostOf, |
| 16 | 17 | type PullRequestReviewVerdict, |
| 17 | 18 | type RepositoryIdentity, |
| ... | ... | @@ -77,6 +78,12 @@ export interface SourceControlClientDefinition { |
| 77 | 78 | readonly defaultCloneTransport: "https" | "ssh"; |
| 78 | 79 | /** The web URL of a change request, or null when this client cannot build one for the host. */ |
| 79 | 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 | 87 | /** A shell command that checks the change request out, or null when it cannot be built. */ |
| 81 | 88 | readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null; |
| 82 | 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 | 226 | publishHost: (signedInHost) => signedInHost ?? "your server", |
| 220 | 227 | defaultCloneTransport: "ssh", |
| 221 | 228 | changeRequestUrl: () => null, |
| 229 | changeRequestActions: new Set(), | |
| 222 | 230 | checkoutCommand: () => null, |
| 223 | 231 | authorProfileUrl: () => null, |
| 224 | 232 | referenceAutolinkRepositoryUrl: () => null, |
packages/source-control-forgejo/src/client/definition.ts+1| ... | ... | @@ -63,6 +63,7 @@ export const definition = defineSourceControlClient({ |
| 63 | 63 | } |
| 64 | 64 | return `https://${host}/${repository}/pulls/${number}`; |
| 65 | 65 | }, |
| 66 | changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const), | |
| 66 | 67 | // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. |
| 67 | 68 | checkoutCommand: ({ number, repositoryUrl }) => |
| 68 | 69 | repositoryUrl |
packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts+2-1| ... | ... | @@ -33,12 +33,13 @@ import { |
| 33 | 33 | forgejoChecks, |
| 34 | 34 | forgejoReactions, |
| 35 | 35 | } from "./forgejoPullRequestJson.ts"; |
| 36 | import { definition } from "../client/definition.ts"; | |
| 36 | 37 | |
| 37 | 38 | const CAPABILITIES: PullRequestCapabilities = { |
| 38 | 39 | diff: true, |
| 39 | 40 | viewedFiles: "environment", |
| 40 | 41 | comment: true, |
| 41 | actions: ["merge", "close", "reopen", "update-branch"], | |
| 42 | actions: [...definition.changeRequestActions], | |
| 42 | 43 | mergeMethods: ["merge", "squash", "rebase"], |
| 43 | 44 | updateMethods: ["merge", "rebase"], |
| 44 | 45 | search: false, |
packages/source-control-gitcafe/src/client/definition.ts+8| ... | ... | @@ -31,6 +31,14 @@ export const definition = defineSourceControlClient({ |
| 31 | 31 | defaultCloneTransport: "ssh", |
| 32 | 32 | changeRequestUrl: ({ host, repository, number }) => |
| 33 | 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 | 42 | checkoutCommand: ({ number }) => `cafe pr checkout ${number}`, |
| 35 | 43 | authorProfileUrl: () => null, |
| 36 | 44 | referenceAutolinkRepositoryUrl: () => null, |
packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts+2-1| ... | ... | @@ -18,11 +18,12 @@ import { |
| 18 | 18 | import * as GitCafeApi from "./GitCafeApi.ts"; |
| 19 | 19 | import * as GitCafeHosts from "./gitCafeHosts.ts"; |
| 20 | 20 | import * as Json from "./gitCafePullRequestJson.ts"; |
| 21 | import { definition } from "../client/definition.ts"; | |
| 21 | 22 | |
| 22 | 23 | const CAPABILITIES: PullRequestCapabilities = { |
| 23 | 24 | diff: true, |
| 24 | 25 | comment: true, |
| 25 | actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"], | |
| 26 | actions: [...definition.changeRequestActions], | |
| 26 | 27 | mergeMethods: ["merge", "squash", "rebase"], |
| 27 | 28 | // Only through a stack: GitCafe's one branch update restacks every layer above it. |
| 28 | 29 | updateMethods: ["rebase"], |
packages/source-control-github/src/client/definition.ts+12| ... | ... | @@ -30,6 +30,18 @@ export const definition = defineSourceControlClient({ |
| 30 | 30 | defaultCloneTransport: "https", |
| 31 | 31 | changeRequestUrl: ({ host, repository, number }) => |
| 32 | 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 | 45 | checkoutCommand: ({ number }) => `gh pr checkout ${number}`, |
| 34 | 46 | authorProfileUrl: (login, repositoryUrl) => |
| 35 | 47 | login.endsWith("[bot]") |
packages/source-control-github/src/server/GitHubPullRequestProvider.ts+2-12| ... | ... | @@ -20,22 +20,12 @@ import { |
| 20 | 20 | type ProviderRepositoryRef, |
| 21 | 21 | } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 22 | 22 | import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; |
| 23 | import { definition } from "../client/definition.ts"; | |
| 23 | 24 | |
| 24 | 25 | const CAPABILITIES: PullRequestCapabilities = { |
| 25 | 26 | diff: true, |
| 26 | 27 | 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], | |
| 39 | 29 | mergeMethods: ["merge", "squash", "rebase"], |
| 40 | 30 | updateMethods: ["merge", "rebase"], |
| 41 | 31 | search: true, |
packages/source-control-gitlab/src/client/definition.ts+10| ... | ... | @@ -30,6 +30,16 @@ export const definition = defineSourceControlClient({ |
| 30 | 30 | defaultCloneTransport: "ssh", |
| 31 | 31 | changeRequestUrl: ({ host, repository, number }) => |
| 32 | 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 | 43 | checkoutCommand: ({ number }) => `glab mr checkout ${number}`, |
| 34 | 44 | authorProfileUrl: () => null, |
| 35 | 45 | referenceAutolinkRepositoryUrl: () => null, |
packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts+2-10| ... | ... | @@ -14,20 +14,12 @@ import { |
| 14 | 14 | type ProviderChangeRequestDetail, |
| 15 | 15 | type PullRequestProviderApi, |
| 16 | 16 | } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 17 | import { definition } from "../client/definition.ts"; | |
| 17 | 18 | |
| 18 | 19 | const CAPABILITIES: PullRequestCapabilities = { |
| 19 | 20 | diff: true, |
| 20 | 21 | 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], | |
| 31 | 23 | // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. |
| 32 | 24 | mergeMethods: ["merge", "squash", "rebase"], |
| 33 | 25 | // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing |