authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-27 00:08:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-27 00:08:45-07:00
logd8a485ea7e93f4e498be3314c0094a3af675938f
tree5ee4baa82cdc8a1f7a89c1c124bc7268c79d4509
parent02989fe600c2aff728b09c5600f75a3a073b3468
signature Signed by PGP key B5690EEEBB952194

Support `gh pr checkout` pull request references (#1457)


8 files changed, 323 insertions(+), 16 deletions(-)

apps/web/src/components/BranchToolbar.logic.test.ts+36
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 resolveBranchSelectionTarget,6 resolveBranchSelectionTarget,
7 resolveDraftEnvModeAfterBranchChange,7 resolveDraftEnvModeAfterBranchChange,
8 resolveBranchToolbarValue,8 resolveBranchToolbarValue,
9 shouldIncludeBranchPickerItem,
9} from "./BranchToolbar.logic";10} from "./BranchToolbar.logic";
1011
11describe("resolveDraftEnvModeAfterBranchChange", () => {12describe("resolveDraftEnvModeAfterBranchChange", () => {
...@@ -267,3 +268,38 @@ describe("resolveBranchSelectionTarget", () => {...@@ -267,3 +268,38 @@ describe("resolveBranchSelectionTarget", () => {
267 });268 });
268 });269 });
269});270});
271
272describe("shouldIncludeBranchPickerItem", () => {
273 it("keeps the synthetic checkout PR item visible for gh pr checkout input", () => {
274 expect(
275 shouldIncludeBranchPickerItem({
276 itemValue: "__checkout_pull_request__:1359",
277 normalizedQuery: "gh pr checkout 1359",
278 createBranchItemValue: "__create_new_branch__:gh pr checkout 1359",
279 checkoutPullRequestItemValue: "__checkout_pull_request__:1359",
280 }),
281 ).toBe(true);
282 });
283
284 it("keeps the synthetic create-branch item visible for arbitrary branch input", () => {
285 expect(
286 shouldIncludeBranchPickerItem({
287 itemValue: "__create_new_branch__:feature/demo",
288 normalizedQuery: "feature/demo",
289 createBranchItemValue: "__create_new_branch__:feature/demo",
290 checkoutPullRequestItemValue: null,
291 }),
292 ).toBe(true);
293 });
294
295 it("still filters ordinary branch items by query text", () => {
296 expect(
297 shouldIncludeBranchPickerItem({
298 itemValue: "main",
299 normalizedQuery: "gh pr checkout 1359",
300 createBranchItemValue: "__create_new_branch__:gh pr checkout 1359",
301 checkoutPullRequestItemValue: "__checkout_pull_request__:1359",
302 }),
303 ).toBe(false);
304 });
305});
apps/web/src/components/BranchToolbar.logic.ts+23
...@@ -123,3 +123,26 @@ export function resolveBranchSelectionTarget(input: {...@@ -123,3 +123,26 @@ export function resolveBranchSelectionTarget(input: {
123 reuseExistingWorktree: false,123 reuseExistingWorktree: false,
124 };124 };
125}125}
126
127export function shouldIncludeBranchPickerItem(input: {
128 itemValue: string;
129 normalizedQuery: string;
130 createBranchItemValue: string | null;
131 checkoutPullRequestItemValue: string | null;
132}): boolean {
133 const { itemValue, normalizedQuery, createBranchItemValue, checkoutPullRequestItemValue } = input;
134
135 if (normalizedQuery.length === 0) {
136 return true;
137 }
138
139 if (createBranchItemValue && itemValue === createBranchItemValue) {
140 return true;
141 }
142
143 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
144 return true;
145 }
146
147 return itemValue.toLowerCase().includes(normalizedQuery);
148}
apps/web/src/components/BranchToolbarBranchSelector.tsx+15-5
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 EnvMode,28 EnvMode,
29 resolveBranchSelectionTarget,29 resolveBranchSelectionTarget,
30 resolveBranchToolbarValue,30 resolveBranchToolbarValue,
31 shouldIncludeBranchPickerItem,
31} from "./BranchToolbar.logic";32} from "./BranchToolbar.logic";
32import { Button } from "./ui/button";33import { Button } from "./ui/button";
33import {34import {
...@@ -134,11 +135,20 @@ export function BranchToolbarBranchSelector({...@@ -134,11 +135,20 @@ export function BranchToolbarBranchSelector({
134 () =>135 () =>
135 normalizedDeferredBranchQuery.length === 0136 normalizedDeferredBranchQuery.length === 0
136 ? branchPickerItems137 ? branchPickerItems
137 : branchPickerItems.filter((itemValue) => {138 : branchPickerItems.filter((itemValue) =>
138 if (createBranchItemValue && itemValue === createBranchItemValue) return true;139 shouldIncludeBranchPickerItem({
139 return itemValue.toLowerCase().includes(normalizedDeferredBranchQuery);140 itemValue,
140 }),141 normalizedQuery: normalizedDeferredBranchQuery,
141 [branchPickerItems, createBranchItemValue, normalizedDeferredBranchQuery],142 createBranchItemValue,
143 checkoutPullRequestItemValue,
144 }),
145 ),
146 [
147 branchPickerItems,
148 checkoutPullRequestItemValue,
149 createBranchItemValue,
150 normalizedDeferredBranchQuery,
151 ],
142 );152 );
143 const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(153 const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(
144 canonicalActiveBranch,154 canonicalActiveBranch,
apps/web/src/components/ChatView.browser.tsx+158
...@@ -56,6 +56,7 @@ interface TestFixture {...@@ -56,6 +56,7 @@ interface TestFixture {
5656
57let fixture: TestFixture;57let fixture: TestFixture;
58const wsRequests: WsRequestEnvelope["body"][] = [];58const wsRequests: WsRequestEnvelope["body"][] = [];
59let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null;
59const wsLink = ws.link(/ws(s)?:\/\/.*/);60const wsLink = ws.link(/ws(s)?:\/\/.*/);
6061
61interface ViewportSpec {62interface ViewportSpec {
...@@ -433,6 +434,10 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {...@@ -433,6 +434,10 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
433}434}
434435
435function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {436function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
437 const customResult = customWsRpcResolver?.(body);
438 if (customResult !== undefined) {
439 return customResult;
440 }
436 const tag = body._tag;441 const tag = body._tag;
437 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {442 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
438 return fixture.snapshot;443 return fixture.snapshot;
...@@ -767,9 +772,11 @@ async function mountChatView(options: {...@@ -767,9 +772,11 @@ async function mountChatView(options: {
767 viewport: ViewportSpec;772 viewport: ViewportSpec;
768 snapshot: OrchestrationReadModel;773 snapshot: OrchestrationReadModel;
769 configureFixture?: (fixture: TestFixture) => void;774 configureFixture?: (fixture: TestFixture) => void;
775 resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined;
770}): Promise<MountedChatView> {776}): Promise<MountedChatView> {
771 fixture = buildFixture(options.snapshot);777 fixture = buildFixture(options.snapshot);
772 options.configureFixture?.(fixture);778 options.configureFixture?.(fixture);
779 customWsRpcResolver = options.resolveRpc ?? null;
773 await setViewport(options.viewport);780 await setViewport(options.viewport);
774 await waitForProductionStyles();781 await waitForProductionStyles();
775782
...@@ -795,6 +802,7 @@ async function mountChatView(options: {...@@ -795,6 +802,7 @@ async function mountChatView(options: {
795 await waitForLayout();802 await waitForLayout();
796803
797 const cleanup = async () => {804 const cleanup = async () => {
805 customWsRpcResolver = null;
798 await screen.unmount();806 await screen.unmount();
799 host.remove();807 host.remove();
800 };808 };
...@@ -854,6 +862,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -854,6 +862,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
854 localStorage.clear();862 localStorage.clear();
855 document.body.innerHTML = "";863 document.body.innerHTML = "";
856 wsRequests.length = 0;864 wsRequests.length = 0;
865 customWsRpcResolver = null;
857 useComposerDraftStore.setState({866 useComposerDraftStore.setState({
858 draftsByThreadId: {},867 draftsByThreadId: {},
859 draftThreadsByThreadId: {},868 draftThreadsByThreadId: {},
...@@ -869,6 +878,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -869,6 +878,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
869 });878 });
870879
871 afterEach(() => {880 afterEach(() => {
881 customWsRpcResolver = null;
872 document.body.innerHTML = "";882 document.body.innerHTML = "";
873 });883 });
874884
...@@ -1357,6 +1367,154 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1357,6 +1367,154 @@ describe("ChatView timeline estimator parity (full app)", () => {
1357 }1367 }
1358 });1368 });
13591369
1370 it("runs setup scripts after preparing a pull request worktree thread", async () => {
1371 useComposerDraftStore.setState({
1372 draftThreadsByThreadId: {
1373 [THREAD_ID]: {
1374 projectId: PROJECT_ID,
1375 createdAt: NOW_ISO,
1376 runtimeMode: "full-access",
1377 interactionMode: "default",
1378 branch: null,
1379 worktreePath: null,
1380 envMode: "local",
1381 },
1382 },
1383 projectDraftThreadIdByProjectId: {
1384 [PROJECT_ID]: THREAD_ID,
1385 },
1386 });
1387
1388 const mounted = await mountChatView({
1389 viewport: DEFAULT_VIEWPORT,
1390 snapshot: withProjectScripts(createDraftOnlySnapshot(), [
1391 {
1392 id: "setup",
1393 name: "Setup",
1394 command: "bun install",
1395 icon: "configure",
1396 runOnWorktreeCreate: true,
1397 },
1398 ]),
1399 resolveRpc: (body) => {
1400 if (body._tag === WS_METHODS.gitResolvePullRequest) {
1401 return {
1402 pullRequest: {
1403 number: 1359,
1404 title: "Add thread archiving and settings navigation",
1405 url: "https://github.com/pingdotgg/t3code/pull/1359",
1406 baseBranch: "main",
1407 headBranch: "archive-settings-overhaul",
1408 state: "open",
1409 },
1410 };
1411 }
1412 if (body._tag === WS_METHODS.gitPreparePullRequestThread) {
1413 return {
1414 pullRequest: {
1415 number: 1359,
1416 title: "Add thread archiving and settings navigation",
1417 url: "https://github.com/pingdotgg/t3code/pull/1359",
1418 baseBranch: "main",
1419 headBranch: "archive-settings-overhaul",
1420 state: "open",
1421 },
1422 branch: "archive-settings-overhaul",
1423 worktreePath: "/repo/worktrees/pr-1359",
1424 };
1425 }
1426 return undefined;
1427 },
1428 });
1429
1430 try {
1431 const branchButton = await waitForElement(
1432 () =>
1433 Array.from(document.querySelectorAll("button")).find(
1434 (button) => button.textContent?.trim() === "main",
1435 ) as HTMLButtonElement | null,
1436 "Unable to find branch selector button.",
1437 );
1438 branchButton.click();
1439
1440 const branchInput = await waitForElement(
1441 () => document.querySelector<HTMLInputElement>('input[placeholder="Search branches..."]'),
1442 "Unable to find branch search input.",
1443 );
1444 branchInput.focus();
1445 await page.getByPlaceholder("Search branches...").fill("1359");
1446
1447 const checkoutItem = await waitForElement(
1448 () =>
1449 Array.from(document.querySelectorAll("span")).find(
1450 (element) => element.textContent?.trim() === "Checkout Pull Request",
1451 ) as HTMLSpanElement | null,
1452 "Unable to find checkout pull request option.",
1453 );
1454 checkoutItem.click();
1455
1456 const worktreeButton = await waitForElement(
1457 () =>
1458 Array.from(document.querySelectorAll("button")).find(
1459 (button) => button.textContent?.trim() === "Worktree",
1460 ) as HTMLButtonElement | null,
1461 "Unable to find Worktree button.",
1462 );
1463 worktreeButton.click();
1464
1465 await vi.waitFor(
1466 () => {
1467 const prepareRequest = wsRequests.find(
1468 (request) => request._tag === WS_METHODS.gitPreparePullRequestThread,
1469 );
1470 expect(prepareRequest).toMatchObject({
1471 _tag: WS_METHODS.gitPreparePullRequestThread,
1472 cwd: "/repo/project",
1473 reference: "1359",
1474 mode: "worktree",
1475 });
1476 },
1477 { timeout: 8_000, interval: 16 },
1478 );
1479
1480 await vi.waitFor(
1481 () => {
1482 const openRequest = wsRequests.find(
1483 (request) =>
1484 request._tag === WS_METHODS.terminalOpen && request.cwd === "/repo/worktrees/pr-1359",
1485 );
1486 expect(openRequest).toMatchObject({
1487 _tag: WS_METHODS.terminalOpen,
1488 threadId: expect.any(String),
1489 cwd: "/repo/worktrees/pr-1359",
1490 env: {
1491 T3CODE_PROJECT_ROOT: "/repo/project",
1492 T3CODE_WORKTREE_PATH: "/repo/worktrees/pr-1359",
1493 },
1494 });
1495 },
1496 { timeout: 8_000, interval: 16 },
1497 );
1498
1499 await vi.waitFor(
1500 () => {
1501 const writeRequest = wsRequests.find(
1502 (request) =>
1503 request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r",
1504 );
1505 expect(writeRequest).toMatchObject({
1506 _tag: WS_METHODS.terminalWrite,
1507 threadId: expect.any(String),
1508 data: "bun install\r",
1509 });
1510 },
1511 { timeout: 8_000, interval: 16 },
1512 );
1513 } finally {
1514 await mounted.cleanup();
1515 }
1516 });
1517
1360 it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {1518 it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
1361 const mounted = await mountChatView({1519 const mounted = await mountChatView({
1362 viewport: DEFAULT_VIEWPORT,1520 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.tsx+63-4
...@@ -239,6 +239,12 @@ interface ChatViewProps {...@@ -239,6 +239,12 @@ interface ChatViewProps {
239 threadId: ThreadId;239 threadId: ThreadId;
240}240}
241241
242interface PendingPullRequestSetupRequest {
243 threadId: ThreadId;
244 worktreePath: string;
245 scriptId: string;
246}
247
242export default function ChatView({ threadId }: ChatViewProps) {248export default function ChatView({ threadId }: ChatViewProps) {
243 const threads = useStore((store) => store.threads);249 const threads = useStore((store) => store.threads);
244 const projects = useStore((store) => store.projects);250 const projects = useStore((store) => store.projects);
...@@ -350,6 +356,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -350,6 +356,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
350 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);356 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
351 const [pullRequestDialogState, setPullRequestDialogState] =357 const [pullRequestDialogState, setPullRequestDialogState] =
352 useState<PullRequestDialogState | null>(null);358 useState<PullRequestDialogState | null>(null);
359 const [pendingPullRequestSetupRequest, setPendingPullRequestSetupRequest] =
360 useState<PendingPullRequestSetupRequest | null>(null);
353 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<361 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
354 Record<string, string[]>362 Record<string, string[]>
355 >({});363 >({});
...@@ -527,14 +535,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -527,14 +535,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
527 params: { threadId: storedDraftThread.threadId },535 params: { threadId: storedDraftThread.threadId },
528 });536 });
529 }537 }
530 return;538 return storedDraftThread.threadId;
531 }539 }
532540
533 const activeDraftThread = getDraftThread(threadId);541 const activeDraftThread = getDraftThread(threadId);
534 if (!isServerThread && activeDraftThread?.projectId === activeProject.id) {542 if (!isServerThread && activeDraftThread?.projectId === activeProject.id) {
535 setDraftThreadContext(threadId, input);543 setDraftThreadContext(threadId, input);
536 setProjectDraftThreadId(activeProject.id, threadId, input);544 setProjectDraftThreadId(activeProject.id, threadId, input);
537 return;545 return threadId;
538 }546 }
539547
540 clearProjectDraftThreadId(activeProject.id);548 clearProjectDraftThreadId(activeProject.id);
...@@ -549,6 +557,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -549,6 +557,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
549 to: "/$threadId",557 to: "/$threadId",
550 params: { threadId: nextThreadId },558 params: { threadId: nextThreadId },
551 });559 });
560 return nextThreadId;
552 },561 },
553 [562 [
554 activeProject,563 activeProject,
...@@ -565,13 +574,24 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -565,13 +574,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
565574
566 const handlePreparedPullRequestThread = useCallback(575 const handlePreparedPullRequestThread = useCallback(
567 async (input: { branch: string; worktreePath: string | null }) => {576 async (input: { branch: string; worktreePath: string | null }) => {
568 await openOrReuseProjectDraftThread({577 const targetThreadId = await openOrReuseProjectDraftThread({
569 branch: input.branch,578 branch: input.branch,
570 worktreePath: input.worktreePath,579 worktreePath: input.worktreePath,
571 envMode: input.worktreePath ? "worktree" : "local",580 envMode: input.worktreePath ? "worktree" : "local",
572 });581 });
582 const setupScript =
583 input.worktreePath && activeProject ? setupProjectScript(activeProject.scripts) : null;
584 if (targetThreadId && input.worktreePath && setupScript) {
585 setPendingPullRequestSetupRequest({
586 threadId: targetThreadId,
587 worktreePath: input.worktreePath,
588 scriptId: setupScript.id,
589 });
590 } else {
591 setPendingPullRequestSetupRequest(null);
592 }
573 },593 },
574 [openOrReuseProjectDraftThread],594 [activeProject, openOrReuseProjectDraftThread],
575 );595 );
576596
577 useEffect(() => {597 useEffect(() => {
...@@ -1418,6 +1438,45 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1418,6 +1438,45 @@ export default function ChatView({ threadId }: ChatViewProps) {
1418 terminalState.terminalIds,1438 terminalState.terminalIds,
1419 ],1439 ],
1420 );1440 );
1441
1442 useEffect(() => {
1443 if (!pendingPullRequestSetupRequest || !activeProject || !activeThreadId || !activeThread) {
1444 return;
1445 }
1446 if (pendingPullRequestSetupRequest.threadId !== activeThreadId) {
1447 return;
1448 }
1449 if (activeThread.worktreePath !== pendingPullRequestSetupRequest.worktreePath) {
1450 return;
1451 }
1452
1453 const setupScript =
1454 activeProject.scripts.find(
1455 (script) => script.id === pendingPullRequestSetupRequest.scriptId,
1456 ) ?? null;
1457 setPendingPullRequestSetupRequest(null);
1458 if (!setupScript) {
1459 return;
1460 }
1461
1462 void runProjectScript(setupScript, {
1463 cwd: pendingPullRequestSetupRequest.worktreePath,
1464 worktreePath: pendingPullRequestSetupRequest.worktreePath,
1465 rememberAsLastInvoked: false,
1466 }).catch((error) => {
1467 toastManager.add({
1468 type: "error",
1469 title: "Failed to run setup script.",
1470 description: error instanceof Error ? error.message : "An error occurred.",
1471 });
1472 });
1473 }, [
1474 activeProject,
1475 activeThread,
1476 activeThreadId,
1477 pendingPullRequestSetupRequest,
1478 runProjectScript,
1479 ]);
1421 const persistProjectScripts = useCallback(1480 const persistProjectScripts = useCallback(
1422 async (input: {1481 async (input: {
1423 projectId: ProjectId;1482 projectId: ProjectId;
apps/web/src/components/PullRequestThreadDialog.tsx+3-3
...@@ -153,9 +153,9 @@ export function PullRequestThreadDialog({...@@ -153,9 +153,9 @@ export function PullRequestThreadDialog({
153 const validationMessage = !referenceDirty153 const validationMessage = !referenceDirty
154 ? null154 ? null
155 : reference.trim().length === 0155 : reference.trim().length === 0
156 ? "Paste a GitHub pull request URL or enter 123 / #123."156 ? "Paste a GitHub pull request URL, `gh pr checkout 123`, or enter 123 / #123."
157 : parsedReference === null157 : parsedReference === null
158 ? "Use a GitHub pull request URL, 123, or #123."158 ? "Use a GitHub pull request URL, `gh pr checkout 123`, 123, or #123."
159 : null;159 : null;
160 const errorMessage =160 const errorMessage =
161 validationMessage ??161 validationMessage ??
...@@ -191,7 +191,7 @@ export function PullRequestThreadDialog({...@@ -191,7 +191,7 @@ export function PullRequestThreadDialog({
191 <span className="text-xs font-medium text-foreground">Pull request</span>191 <span className="text-xs font-medium text-foreground">Pull request</span>
192 <Input192 <Input
193 ref={referenceInputRef}193 ref={referenceInputRef}
194 placeholder="https://github.com/owner/repo/pull/42 or #42"194 placeholder="https://github.com/owner/repo/pull/42, gh pr checkout 42, or #42"
195 value={reference}195 value={reference}
196 onChange={(event) => {196 onChange={(event) => {
197 setReferenceDirty(true);197 setReferenceDirty(true);
apps/web/src/pullRequestReference.test.ts+14
...@@ -17,6 +17,20 @@ describe("parsePullRequestReference", () => {...@@ -17,6 +17,20 @@ describe("parsePullRequestReference", () => {
17 expect(parsePullRequestReference("#42")).toBe("#42");17 expect(parsePullRequestReference("#42")).toBe("#42");
18 });18 });
1919
20 it("accepts gh pr checkout commands with raw numbers", () => {
21 expect(parsePullRequestReference("gh pr checkout 42")).toBe("42");
22 });
23
24 it("accepts gh pr checkout commands with #number references", () => {
25 expect(parsePullRequestReference("gh pr checkout #42")).toBe("#42");
26 });
27
28 it("accepts gh pr checkout commands with GitHub pull request URLs", () => {
29 expect(
30 parsePullRequestReference("gh pr checkout https://github.com/pingdotgg/t3code/pull/42"),
31 ).toBe("https://github.com/pingdotgg/t3code/pull/42");
32 });
33
20 it("rejects non-pull-request input", () => {34 it("rejects non-pull-request input", () => {
21 expect(parsePullRequestReference("feature/my-branch")).toBeNull();35 expect(parsePullRequestReference("feature/my-branch")).toBeNull();
22 });36 });
apps/web/src/pullRequestReference.ts+11-4
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1const GITHUB_PULL_REQUEST_URL_PATTERN =1const GITHUB_PULL_REQUEST_URL_PATTERN =
2 /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;2 /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
3const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;3const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;
4const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i;
45
5export function parsePullRequestReference(input: string): string | null {6export function parsePullRequestReference(input: string): string | null {
6 const trimmed = input.trim();7 const trimmed = input.trim();
...@@ -8,14 +9,20 @@ export function parsePullRequestReference(input: string): string | null {...@@ -8,14 +9,20 @@ export function parsePullRequestReference(input: string): string | null {
8 return null;9 return null;
9 }10 }
1011
11 const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(trimmed);12 const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
13 const normalizedInput = ghCliCheckoutMatch?.[1]?.trim() ?? trimmed;
14 if (normalizedInput.length === 0) {
15 return null;
16 }
17
18 const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);
12 if (urlMatch?.[1]) {19 if (urlMatch?.[1]) {
13 return trimmed;20 return normalizedInput;
14 }21 }
1522
16 const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(trimmed);23 const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(normalizedInput);
17 if (numberMatch?.[1]) {24 if (numberMatch?.[1]) {
18 return trimmed.startsWith("#") ? trimmed : numberMatch[1];25 return normalizedInput.startsWith("#") ? normalizedInput : numberMatch[1];
19 }26 }
2027
21 return null;28 return null;