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