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...@@ -84,6 +84,9 @@ function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeReque
84 url: pullRequest.url,84 url: pullRequest.url,
85 author: pullRequest.author,85 author: pullRequest.author,
86 headBranch: pullRequest.headBranch,86 headBranch: pullRequest.headBranch,
87 ...(pullRequest.headRepositoryNameWithOwner
88 ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner }
89 : {}),
87 baseBranch: pullRequest.baseBranch,90 baseBranch: pullRequest.baseBranch,
88 state: pullRequest.state,91 state: pullRequest.state,
89 isDraft: pullRequest.isDraft,92 isDraft: pullRequest.isDraft,
apps/server/src/pullRequest/PullRequestProvider.ts+1
...@@ -69,6 +69,7 @@ export interface ProviderChangeRequest {...@@ -69,6 +69,7 @@ export interface ProviderChangeRequest {
69 readonly url: string;69 readonly url: string;
70 readonly author: PullRequestActor | null;70 readonly author: PullRequestActor | null;
71 readonly headBranch: string;71 readonly headBranch: string;
72 readonly headRepositoryNameWithOwner?: string | null;
72 readonly baseBranch: string;73 readonly baseBranch: string;
73 readonly state: PullRequestState;74 readonly state: PullRequestState;
74 readonly isDraft: boolean;75 readonly isDraft: boolean;
apps/server/src/pullRequest/PullRequestService.ts+3
...@@ -1164,6 +1164,9 @@ export const make = Effect.gen(function* () {...@@ -1164,6 +1164,9 @@ export const make = Effect.gen(function* () {
1164 deletions: changeRequest.deletions,1164 deletions: changeRequest.deletions,
1165 changedFiles: changeRequest.changedFiles,1165 changedFiles: changeRequest.changedFiles,
1166 headBranch: changeRequest.headBranch,1166 headBranch: changeRequest.headBranch,
1167 ...(changeRequest.headRepositoryNameWithOwner === undefined
1168 ? {}
1169 : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }),
1167 baseBranch: changeRequest.baseBranch,1170 baseBranch: changeRequest.baseBranch,
1168 createdAt: changeRequest.createdAt,1171 createdAt: changeRequest.createdAt,
1169 updatedAt: changeRequest.updatedAt,1172 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...@@ -24,7 +24,7 @@ function pullRequest(overrides: Record<string, unknown> = {}): Record<string, un
24 created_on: "2026-06-16T05:04:32.258456+00:00",24 created_on: "2026-06-16T05:04:32.258456+00:00",
25 updated_on: "2026-06-16T05:04:33.750542+00:00",25 updated_on: "2026-06-16T05:04:33.750542+00:00",
26 author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" },26 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" } },
28 destination: { branch: { name: "master" } },28 destination: { branch: { name: "master" } },
29 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } },29 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } },
30 ...overrides,30 ...overrides,
...@@ -52,6 +52,7 @@ describe("decodePullRequestPageJson", () => {...@@ -52,6 +52,7 @@ describe("decodePullRequestPageJson", () => {
52 url: "https://bitbucket.org/acme/web/pull-requests/897",52 url: "https://bitbucket.org/acme/web/pull-requests/897",
53 author: { login: "bilal", name: "Bilal Hassan" },53 author: { login: "bilal", name: "Bilal Hassan" },
54 headBranch: "feat/page",54 headBranch: "feat/page",
55 headRepositoryNameWithOwner: "fork/web",
55 baseBranch: "master",56 baseBranch: "master",
56 state: "open",57 state: "open",
57 isDraft: false,58 isDraft: false,
apps/server/src/pullRequest/bitbucketPullRequestJson.ts+3
...@@ -52,6 +52,7 @@ const RawUserSchema = Schema.Struct({...@@ -52,6 +52,7 @@ const RawUserSchema = Schema.Struct({
52 */52 */
53const RawBranchSchema = Schema.Struct({53const RawBranchSchema = Schema.Struct({
54 branch: Schema.Struct({ name: TrimmedNonEmptyString }),54 branch: Schema.Struct({ name: TrimmedNonEmptyString }),
55 repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))),
55});56});
5657
57const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) });58const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) });
...@@ -177,6 +178,7 @@ export interface BitbucketPullRequest {...@@ -177,6 +178,7 @@ export interface BitbucketPullRequest {
177 readonly url: string;178 readonly url: string;
178 readonly author: PullRequestActor | null;179 readonly author: PullRequestActor | null;
179 readonly headBranch: string;180 readonly headBranch: string;
181 readonly headRepositoryNameWithOwner: string | null;
180 readonly baseBranch: string;182 readonly baseBranch: string;
181 readonly state: PullRequestState;183 readonly state: PullRequestState;
182 readonly isDraft: boolean;184 readonly isDraft: boolean;
...@@ -291,6 +293,7 @@ function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): Bi...@@ -291,6 +293,7 @@ function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): Bi
291 url: raw.links.html.href,293 url: raw.links.html.href,
292 author: toActor(raw.author),294 author: toActor(raw.author),
293 headBranch: raw.source.branch.name,295 headBranch: raw.source.branch.name,
296 headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null,
294 baseBranch: raw.destination.branch.name,297 baseBranch: raw.destination.branch.name,
295 state: toState(raw),298 state: toState(raw),
296 isDraft: raw.draft ?? false,299 isDraft: raw.draft ?? false,
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+41
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 BookOpenIcon,16 BookOpenIcon,
17 CircleDotIcon,17 CircleDotIcon,
18 ChevronDownIcon,18 ChevronDownIcon,
19 CopyIcon,
19 ExternalLinkIcon,20 ExternalLinkIcon,
20 FileDiffIcon,21 FileDiffIcon,
21 FolderGit2Icon,22 FolderGit2Icon,
...@@ -114,6 +115,7 @@ import {...@@ -114,6 +115,7 @@ import {
114 pullRequestActionMenuHasGroup,115 pullRequestActionMenuHasGroup,
115 pullRequestActionNeedsHostRefresh,116 pullRequestActionNeedsHostRefresh,
116 pullRequestComposerTarget,117 pullRequestComposerTarget,
118 pullRequestCheckoutCommand,
117 pullRequestFindingKey,119 pullRequestFindingKey,
118 pullRequestHandoffLabels,120 pullRequestHandoffLabels,
119 readableFailure,121 readableFailure,
...@@ -468,6 +470,16 @@ export function PullRequestDetailPanel({...@@ -468,6 +470,16 @@ export function PullRequestDetailPanel({
468 target: "branch name",470 target: "branch name",
469 timeout: 1600,471 timeout: 1600,
470 });472 });
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 });
471 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be483 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be
472 // clicked, so a reader who does click it lands on a chunk already in the module cache.484 // clicked, so a reader who does click it lands on a chunk already in the module cache.
473 useEffect(() => {485 useEffect(() => {
...@@ -507,6 +519,14 @@ export function PullRequestDetailPanel({...@@ -507,6 +519,14 @@ export function PullRequestDetailPanel({
507 [activity, coreDetail],519 [activity, coreDetail],
508 );520 );
509 const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url);521 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;
510 const branchRefsQuery = useEnvironmentQuery(530 const branchRefsQuery = useEnvironmentQuery(
511 detail === null531 detail === null
512 ? null532 ? null
...@@ -1758,6 +1778,27 @@ export function PullRequestDetailPanel({...@@ -1758,6 +1778,27 @@ export function PullRequestDetailPanel({
1758 {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`}1778 {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`}
1759 </TooltipPopup>1779 </TooltipPopup>
1760 </Tooltip>1780 </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}
1761 </span>1802 </span>
1762 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">1803 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
1763 <span className="inline-flex items-center gap-1.5 tabular-nums">1804 <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 {...@@ -25,6 +25,7 @@ import {
25 orderPullRequestComments,25 orderPullRequestComments,
26 pullRequestActionMenuHasGroup,26 pullRequestActionMenuHasGroup,
27 pullRequestActionNeedsHostRefresh,27 pullRequestActionNeedsHostRefresh,
28 pullRequestCheckoutCommand,
28 pullRequestComposerTarget,29 pullRequestComposerTarget,
29 pullRequestFindingKey,30 pullRequestFindingKey,
30 pullRequestHandoffLabels,31 pullRequestHandoffLabels,
...@@ -38,6 +39,23 @@ import {...@@ -38,6 +39,23 @@ import {
38} from "./pullRequestDetail.logic";39} from "./pullRequestDetail.logic";
39import type { ReviewCommentContext } from "~/reviewCommentContext";40import 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
41const TIMELINE_SOURCE: Pick<59const TIMELINE_SOURCE: Pick<
42 PullRequestDetailView,60 PullRequestDetailView,
43 "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt"61 "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt"
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+32
...@@ -11,11 +11,43 @@ import type {...@@ -11,11 +11,43 @@ import type {
11 PullRequestReviewThread,11 PullRequestReviewThread,
12 PullRequestState,12 PullRequestState,
13 PullRequestUpdateMethod,13 PullRequestUpdateMethod,
14 SourceControlProviderKind,
14 VcsRef,15 VcsRef,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
1617
17import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";18import { 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
19/** Activity changes only when the same host resource reports a newer revision. */51/** Activity changes only when the same host resource reports a newer revision. */
20export function shouldRefreshPullRequestActivity(52export function shouldRefreshPullRequestActivity(
21 previous: { readonly key: string; readonly updatedAt: string } | null,53 previous: { readonly key: string; readonly updatedAt: string } | null,
packages/contracts/src/pullRequest.ts+1
...@@ -660,6 +660,7 @@ export const PullRequestDetail = Schema.Struct({...@@ -660,6 +660,7 @@ export const PullRequestDetail = Schema.Struct({
660 deletions: NonNegativeInt,660 deletions: NonNegativeInt,
661 changedFiles: NonNegativeInt,661 changedFiles: NonNegativeInt,
662 headBranch: TrimmedNonEmptyString,662 headBranch: TrimmedNonEmptyString,
663 headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
663 baseBranch: TrimmedNonEmptyString,664 baseBranch: TrimmedNonEmptyString,
664 createdAt: IsoDateTime,665 createdAt: IsoDateTime,
665 updatedAt: IsoDateTime,666 updatedAt: IsoDateTime,