authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-01 23:50:27-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 23:50:27-04:00
log9fdafdf114a88bd7309883b4578e74cbe786d04d
treed3b69f8e5dbdd555d72b2c5541d0df2a677506e8
parent47a95332a2b8fe47da9fb0af97fd6f0b542a18ee
signature Signed by PGP key B5690EEEBB952194

feat(pull-requests): copy provider checkout commands (#9086)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

9 files changed, 104 insertions(+), 1 deletions(-)

apps/server/src/pullRequest/BitbucketPullRequestProvider.ts+3
......@@ -84,6 +84,9 @@ function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeReque
8484 url: pullRequest.url,
8585 author: pullRequest.author,
8686 headBranch: pullRequest.headBranch,
87 ...(pullRequest.headRepositoryNameWithOwner
88 ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner }
89 : {}),
8790 baseBranch: pullRequest.baseBranch,
8891 state: pullRequest.state,
8992 isDraft: pullRequest.isDraft,
apps/server/src/pullRequest/PullRequestProvider.ts+1
......@@ -69,6 +69,7 @@ export interface ProviderChangeRequest {
6969 readonly url: string;
7070 readonly author: PullRequestActor | null;
7171 readonly headBranch: string;
72 readonly headRepositoryNameWithOwner?: string | null;
7273 readonly baseBranch: string;
7374 readonly state: PullRequestState;
7475 readonly isDraft: boolean;
apps/server/src/pullRequest/PullRequestService.ts+3
......@@ -1164,6 +1164,9 @@ export const make = Effect.gen(function* () {
11641164 deletions: changeRequest.deletions,
11651165 changedFiles: changeRequest.changedFiles,
11661166 headBranch: changeRequest.headBranch,
1167 ...(changeRequest.headRepositoryNameWithOwner === undefined
1168 ? {}
1169 : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }),
11671170 baseBranch: changeRequest.baseBranch,
11681171 createdAt: changeRequest.createdAt,
11691172 updatedAt: changeRequest.updatedAt,
apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts+2-1
......@@ -24,7 +24,7 @@ function pullRequest(overrides: Record<string, unknown> = {}): Record<string, un
2424 created_on: "2026-06-16T05:04:32.258456+00:00",
2525 updated_on: "2026-06-16T05:04:33.750542+00:00",
2626 author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" },
27 source: { branch: { name: "feat/page" } },
27 source: { branch: { name: "feat/page" }, repository: { full_name: "fork/web" } },
2828 destination: { branch: { name: "master" } },
2929 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } },
3030 ...overrides,
......@@ -52,6 +52,7 @@ describe("decodePullRequestPageJson", () => {
5252 url: "https://bitbucket.org/acme/web/pull-requests/897",
5353 author: { login: "bilal", name: "Bilal Hassan" },
5454 headBranch: "feat/page",
55 headRepositoryNameWithOwner: "fork/web",
5556 baseBranch: "master",
5657 state: "open",
5758 isDraft: false,
apps/server/src/pullRequest/bitbucketPullRequestJson.ts+3
......@@ -52,6 +52,7 @@ const RawUserSchema = Schema.Struct({
5252 */
5353const RawBranchSchema = Schema.Struct({
5454 branch: Schema.Struct({ name: TrimmedNonEmptyString }),
55 repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))),
5556});
5657
5758const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) });
......@@ -177,6 +178,7 @@ export interface BitbucketPullRequest {
177178 readonly url: string;
178179 readonly author: PullRequestActor | null;
179180 readonly headBranch: string;
181 readonly headRepositoryNameWithOwner: string | null;
180182 readonly baseBranch: string;
181183 readonly state: PullRequestState;
182184 readonly isDraft: boolean;
......@@ -291,6 +293,7 @@ function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): Bi
291293 url: raw.links.html.href,
292294 author: toActor(raw.author),
293295 headBranch: raw.source.branch.name,
296 headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null,
294297 baseBranch: raw.destination.branch.name,
295298 state: toState(raw),
296299 isDraft: raw.draft ?? false,
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+41
......@@ -16,6 +16,7 @@ import {
1616 BookOpenIcon,
1717 CircleDotIcon,
1818 ChevronDownIcon,
19 CopyIcon,
1920 ExternalLinkIcon,
2021 FileDiffIcon,
2122 FolderGit2Icon,
......@@ -114,6 +115,7 @@ import {
114115 pullRequestActionMenuHasGroup,
115116 pullRequestActionNeedsHostRefresh,
116117 pullRequestComposerTarget,
118 pullRequestCheckoutCommand,
117119 pullRequestFindingKey,
118120 pullRequestHandoffLabels,
119121 readableFailure,
......@@ -468,6 +470,16 @@ export function PullRequestDetailPanel({
468470 target: "branch name",
469471 timeout: 1600,
470472 });
473 const { copyToClipboard: copyCheckoutCommandToClipboard } = useCopyToClipboard({
474 target: "pull request checkout command",
475 onCopy: () => toastManager.add({ type: "success", title: "Checkout command copied" }),
476 onError: (error) =>
477 toastManager.add({
478 type: "error",
479 title: "Could not copy checkout command",
480 description: error.message,
481 }),
482 });
471483 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be
472484 // clicked, so a reader who does click it lands on a chunk already in the module cache.
473485 useEffect(() => {
......@@ -507,6 +519,14 @@ export function PullRequestDetailPanel({
507519 [activity, coreDetail],
508520 );
509521 const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url);
522 const checkoutCommand = detail
523 ? pullRequestCheckoutCommand(
524 detail.provider,
525 detail.number,
526 detail.headBranch,
527 detail.headRepositoryNameWithOwner,
528 )
529 : null;
510530 const branchRefsQuery = useEnvironmentQuery(
511531 detail === null
512532 ? null
......@@ -1758,6 +1778,27 @@ export function PullRequestDetailPanel({
17581778 {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`}
17591779 </TooltipPopup>
17601780 </Tooltip>
1781 {checkoutCommand ? (
1782 <Tooltip>
1783 <TooltipTrigger
1784 render={
1785 <Button
1786 size="micro"
1787 variant="outline"
1788 className="ml-1 min-w-0 basis-40 shrink-[0.75] gap-1 font-normal text-muted-foreground"
1789 aria-label={`Copy checkout command: ${checkoutCommand}`}
1790 onClick={() => copyCheckoutCommandToClipboard(checkoutCommand)}
1791 />
1792 }
1793 >
1794 <code className="min-w-0 truncate">{checkoutCommand}</code>
1795 <CopyIcon aria-hidden />
1796 </TooltipTrigger>
1797 <TooltipPopup className="max-w-96 wrap-anywhere font-mono" side="bottom">
1798 {checkoutCommand}
1799 </TooltipPopup>
1800 </Tooltip>
1801 ) : null}
17611802 </span>
17621803 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
17631804 <span className="inline-flex items-center gap-1.5 tabular-nums">
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+18
......@@ -25,6 +25,7 @@ import {
2525 orderPullRequestComments,
2626 pullRequestActionMenuHasGroup,
2727 pullRequestActionNeedsHostRefresh,
28 pullRequestCheckoutCommand,
2829 pullRequestComposerTarget,
2930 pullRequestFindingKey,
3031 pullRequestHandoffLabels,
......@@ -38,6 +39,23 @@ import {
3839} from "./pullRequestDetail.logic";
3940import type { ReviewCommentContext } from "~/reviewCommentContext";
4041
42describe("pull request checkout commands", () => {
43 it.each([
44 ["github", "feature", null, "gh pr checkout 42"],
45 ["gitlab", "feature", null, "glab mr checkout 42"],
46 ["azure-devops", "feature", null, "az repos pr checkout --id 42"],
47 [
48 "bitbucket",
49 "feature/checkout",
50 "maria/t3code",
51 "git clone --single-branch --branch feature/checkout https://bitbucket.org/maria/t3code.git t3code-pr-42",
52 ],
53 ["unknown", "feature", null, null],
54 ] as const)("builds the %s command", (provider, branch, repository, expected) => {
55 expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected);
56 });
57});
58
4159const TIMELINE_SOURCE: Pick<
4260 PullRequestDetailView,
4361 "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt"
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+32
......@@ -11,11 +11,43 @@ import type {
1111 PullRequestReviewThread,
1212 PullRequestState,
1313 PullRequestUpdateMethod,
14 SourceControlProviderKind,
1415 VcsRef,
1516} from "@t3tools/contracts";
1617
1718import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";
1819
20const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
21const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
22
23export function pullRequestCheckoutCommand(
24 provider: SourceControlProviderKind,
25 number: number,
26 headBranch: string,
27 headRepositoryNameWithOwner?: string | null,
28): string | null {
29 switch (provider) {
30 case "github":
31 return `gh pr checkout ${number}`;
32 case "gitlab":
33 return `glab mr checkout ${number}`;
34 case "azure-devops":
35 return `az repos pr checkout --id ${number}`;
36 case "bitbucket": {
37 if (
38 !headRepositoryNameWithOwner ||
39 !bitbucketRepositoryName.test(headRepositoryNameWithOwner) ||
40 !safeShellArgument.test(headBranch)
41 ) {
42 return null;
43 }
44 return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`;
45 }
46 case "unknown":
47 return null;
48 }
49}
50
1951/** Activity changes only when the same host resource reports a newer revision. */
2052export function shouldRefreshPullRequestActivity(
2153 previous: { readonly key: string; readonly updatedAt: string } | null,
packages/contracts/src/pullRequest.ts+1
......@@ -660,6 +660,7 @@ export const PullRequestDetail = Schema.Struct({
660660 deletions: NonNegativeInt,
661661 changedFiles: NonNegativeInt,
662662 headBranch: TrimmedNonEmptyString,
663 headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
663664 baseBranch: TrimmedNonEmptyString,
664665 createdAt: IsoDateTime,
665666 updatedAt: IsoDateTime,