Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/BitbucketPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeReque
url: pullRequest.url,
author: pullRequest.author,
headBranch: pullRequest.headBranch,
...(pullRequest.headRepositoryNameWithOwner
? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner }
: {}),
baseBranch: pullRequest.baseBranch,
state: pullRequest.state,
isDraft: pullRequest.isDraft,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/pullRequest/PullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface ProviderChangeRequest {
readonly url: string;
readonly author: PullRequestActor | null;
readonly headBranch: string;
readonly headRepositoryNameWithOwner?: string | null;
readonly baseBranch: string;
readonly state: PullRequestState;
readonly isDraft: boolean;
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,9 @@ export const make = Effect.gen(function* () {
deletions: changeRequest.deletions,
changedFiles: changeRequest.changedFiles,
headBranch: changeRequest.headBranch,
...(changeRequest.headRepositoryNameWithOwner === undefined
? {}
: { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }),
baseBranch: changeRequest.baseBranch,
createdAt: changeRequest.createdAt,
updatedAt: changeRequest.updatedAt,
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function pullRequest(overrides: Record<string, unknown> = {}): Record<string, un
created_on: "2026-06-16T05:04:32.258456+00:00",
updated_on: "2026-06-16T05:04:33.750542+00:00",
author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" },
source: { branch: { name: "feat/page" } },
source: { branch: { name: "feat/page" }, repository: { full_name: "fork/web" } },
destination: { branch: { name: "master" } },
links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } },
...overrides,
Expand Down Expand Up @@ -52,6 +52,7 @@ describe("decodePullRequestPageJson", () => {
url: "https://bitbucket.org/acme/web/pull-requests/897",
author: { login: "bilal", name: "Bilal Hassan" },
headBranch: "feat/page",
headRepositoryNameWithOwner: "fork/web",
baseBranch: "master",
state: "open",
isDraft: false,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/bitbucketPullRequestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const RawUserSchema = Schema.Struct({
*/
const RawBranchSchema = Schema.Struct({
branch: Schema.Struct({ name: TrimmedNonEmptyString }),
repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))),
});

const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) });
Expand Down Expand Up @@ -177,6 +178,7 @@ export interface BitbucketPullRequest {
readonly url: string;
readonly author: PullRequestActor | null;
readonly headBranch: string;
readonly headRepositoryNameWithOwner: string | null;
readonly baseBranch: string;
readonly state: PullRequestState;
readonly isDraft: boolean;
Expand Down Expand Up @@ -291,6 +293,7 @@ function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): Bi
url: raw.links.html.href,
author: toActor(raw.author),
headBranch: raw.source.branch.name,
headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null,
baseBranch: raw.destination.branch.name,
state: toState(raw),
isDraft: raw.draft ?? false,
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
BookOpenIcon,
CircleDotIcon,
ChevronDownIcon,
CopyIcon,
ExternalLinkIcon,
FileDiffIcon,
FolderGit2Icon,
Expand Down Expand Up @@ -114,6 +115,7 @@ import {
pullRequestActionMenuHasGroup,
pullRequestActionNeedsHostRefresh,
pullRequestComposerTarget,
pullRequestCheckoutCommand,
pullRequestFindingKey,
pullRequestHandoffLabels,
readableFailure,
Expand Down Expand Up @@ -468,6 +470,16 @@ export function PullRequestDetailPanel({
target: "branch name",
timeout: 1600,
});
const { copyToClipboard: copyCheckoutCommandToClipboard } = useCopyToClipboard({
target: "pull request checkout command",
onCopy: () => toastManager.add({ type: "success", title: "Checkout command copied" }),
onError: (error) =>
toastManager.add({
type: "error",
title: "Could not copy checkout command",
description: error.message,
}),
});
// The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be
// clicked, so a reader who does click it lands on a chunk already in the module cache.
useEffect(() => {
Expand Down Expand Up @@ -507,6 +519,14 @@ export function PullRequestDetailPanel({
[activity, coreDetail],
);
const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url);
const checkoutCommand = detail
? pullRequestCheckoutCommand(
detail.provider,
detail.number,
detail.headBranch,
detail.headRepositoryNameWithOwner,
)
: null;
const branchRefsQuery = useEnvironmentQuery(
detail === null
? null
Expand Down Expand Up @@ -1772,6 +1792,19 @@ export function PullRequestDetailPanel({
/>
</span>
</div>
{checkoutCommand ? (
<Button
size="xs"
variant="outline"
className="mt-2 min-w-0 max-w-full gap-1.5 font-normal text-muted-foreground"
aria-label={`Copy checkout command: ${checkoutCommand}`}
title={checkoutCommand}
onClick={() => copyCheckoutCommandToClipboard(checkoutCommand)}
>
<code className="min-w-0 truncate text-[11px]">{checkoutCommand}</code>
<CopyIcon aria-hidden className="size-3" />
</Button>
) : null}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
</div>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
orderPullRequestComments,
pullRequestActionMenuHasGroup,
pullRequestActionNeedsHostRefresh,
pullRequestCheckoutCommand,
pullRequestComposerTarget,
pullRequestFindingKey,
pullRequestHandoffLabels,
Expand All @@ -38,6 +39,23 @@ import {
} from "./pullRequestDetail.logic";
import type { ReviewCommentContext } from "~/reviewCommentContext";

describe("pull request checkout commands", () => {
it.each([
["github", "feature", null, "gh pr checkout 42"],
["gitlab", "feature", null, "glab mr checkout 42"],
["azure-devops", "feature", null, "az repos pr checkout --id 42"],
[
"bitbucket",
"feature/checkout",
"maria/t3code",
"git clone --single-branch --branch feature/checkout https://bitbucket.org/maria/t3code.git t3code-pr-42",
],
["unknown", "feature", null, null],
] as const)("builds the %s command", (provider, branch, repository, expected) => {
expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected);
});
});

const TIMELINE_SOURCE: Pick<
PullRequestDetailView,
"createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt"
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,43 @@ import type {
PullRequestReviewThread,
PullRequestState,
PullRequestUpdateMethod,
SourceControlProviderKind,
VcsRef,
} from "@t3tools/contracts";

import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";

const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;

export function pullRequestCheckoutCommand(
provider: SourceControlProviderKind,
number: number,
headBranch: string,
headRepositoryNameWithOwner?: string | null,
): string | null {
switch (provider) {
case "github":
return `gh pr checkout ${number}`;
case "gitlab":
return `glab mr checkout ${number}`;
case "azure-devops":
return `az repos pr checkout --id ${number}`;
case "bitbucket": {
if (
!headRepositoryNameWithOwner ||
!bitbucketRepositoryName.test(headRepositoryNameWithOwner) ||
!safeShellArgument.test(headBranch)
) {
return null;
}
return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`;
}
case "unknown":
return null;
}
}

/** Activity changes only when the same host resource reports a newer revision. */
export function shouldRefreshPullRequestActivity(
previous: { readonly key: string; readonly updatedAt: string } | null,
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/pullRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ export const PullRequestDetail = Schema.Struct({
deletions: NonNegativeInt,
changedFiles: NonNegativeInt,
headBranch: TrimmedNonEmptyString,
headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
baseBranch: TrimmedNonEmptyString,
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
Expand Down
Loading