| 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,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 | */ |
| 53 | const RawBranchSchema = Schema.Struct({ | 53 | const 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 | }); |
| 56 | 57 | ||
| 57 | const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); | 58 | const 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 be | 483 | // 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 === null | 531 | detail === null |
| 512 | ? null | 532 | ? 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"; |
| 39 | import type { ReviewCommentContext } from "~/reviewCommentContext"; | 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 | const TIMELINE_SOURCE: Pick< | 59 | const 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"; |
| 16 | 17 | ||
| 17 | import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; | 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 | /** 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. */ |
| 20 | export function shouldRefreshPullRequestActivity( | 52 | export 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, |