From b12a1bd99f47976e22f3ebb3ba03be06e9bf9767 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Sun, 28 Jun 2026 14:31:11 -0400 Subject: [PATCH 1/2] feat(quick-create): import GitHub issues and PRs --- DEVLOG.md | 24 +++ src/components/quick-create.tsx | 216 +++++++++++++++++++- src/components/ui/anchored-popover.tsx | 3 + src/components/ui/combobox.tsx | 6 +- src/lib/github-ref.ts | 37 ++++ src/server/routers/github.ts | 26 ++- src/server/services/github/resource-sync.ts | 38 +++- src/server/services/mcp.ts | 16 +- tests/e2e/issue-flow.spec.ts | 6 +- tests/e2e/mobile-smoke.spec.ts | 5 +- tests/unit/github-support.test.ts | 28 +++ tests/unit/quick-create-modes.test.ts | 32 +++ 12 files changed, 407 insertions(+), 30 deletions(-) create mode 100644 src/lib/github-ref.ts diff --git a/DEVLOG.md b/DEVLOG.md index cfae7c59..e1391a79 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,30 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-06-28 — AXI-90 QuickCreate GitHub issue/PR import + +Enhanced the ⇧C QuickCreate issue overlay so a pasted GitHub issue/PR URL (or +`owner/repo#123`) becomes an import flow instead of creating a Forge issue whose +title is the URL. The overlay now checks repo linkability, previews the resolved +GitHub resource, shows issue vs PR state inline, switches the primary action to +Import, and calls `github.importIssue` with the canonical preview URL. Existing +project and resolved label chips are carried through to the import where possible. + +Backend import path now accepts either `url` or `repoFullName + number`, plus an +optional `resourceType`; issue-number shorthands auto-resolve to PRs when GitHub +marks the issue response as `pull_request`. Imported PRs reuse the existing +`issueCreateInputFromGitHub` path, creating a normal Forge issue with a SOURCE +link to the PR. + +Gates: `pnpm typecheck` clean; `pnpm lint` clean (pre-existing native-select +warnings only); targeted `vitest` for QuickCreate/GitHub support passed (11/11); +full suite passed when run with the repo `.env` and `OPENAI_API_KEY` unset +(1032 pass / 1 skipped). Targeted Playwright coverage for `issue-flow` and +`mobile-smoke` passed locally (8/8) after updating the Combobox ARIA contract +and status-picker E2E interactions to match the themed Combobox replacement for +native selects. Initial full-suite attempt without the repo `.env` failed on +missing `DATABASE_URL`, then was rerun with env loaded. + ## 2026-06-27 — Subject-label resolver (audit log + webhook deliveries) Closed the deferral from the gap sweep: raw `subjectId.slice(0,8)` on the diff --git a/src/components/quick-create.tsx b/src/components/quick-create.tsx index 527a97a2..40222afb 100644 --- a/src/components/quick-create.tsx +++ b/src/components/quick-create.tsx @@ -10,10 +10,21 @@ import { } from "react"; import { createPortal } from "react-dom"; import { useRouter, usePathname } from "next/navigation"; -import { Calendar, ChevronDown, CornerDownLeft, Plus, X } from "lucide-react"; +import { + AlertCircle, + Calendar, + ChevronDown, + CornerDownLeft, + Github, + GitPullRequest, + Loader2, + Plus, + X, +} from "lucide-react"; import { toast } from "sonner"; import { ArtifactType, NotificationSeverity } from "@prisma/client"; import { cn } from "@/lib/utils"; +import { parseGitHubIssueOrPrRef } from "@/lib/github-ref"; import { MOTION } from "@/lib/motion"; import { Kbd } from "@/components/ui/kbd"; import { useHotkey } from "@/lib/keyboard"; @@ -350,11 +361,44 @@ export function QuickCreate() { { enabled: open && !!contextIssueId }, ); + // Pasting a GitHub issue/PR URL (or owner/repo#123) into Issue mode turns + // the capture bar into an import path instead of creating a literal URL-title + // Forge issue. Sub-issue/comment contexts stay local to the current issue. + const githubImportRef = useMemo( + () => (mode.kind === "issue" ? parseGitHubIssueOrPrRef(text) : null), + [mode.kind, text], + ); + const githubImportRepo = githubImportRef?.repoFullName ?? null; + const githubLinkability = trpc.github.linkability.useQuery( + { repoFullName: githubImportRepo ?? "" }, + { + enabled: open && !!githubImportRepo && !!githubImportRef?.number, + staleTime: 15_000, + retry: false, + }, + ); + const githubReady = githubLinkability.data?.status === "ready" ? githubLinkability.data : null; + const githubPreviewInput = githubImportRef?.url + ? { url: githubImportRef.url, mappingId: githubReady?.mappingId } + : { + repoFullName: githubImportRepo ?? "", + number: githubImportRef?.number ?? 0, + mappingId: githubReady?.mappingId, + }; + const githubPreview = trpc.github.preview.useQuery(githubPreviewInput, { + enabled: !!githubReady && !!githubImportRef?.number, + staleTime: 15_000, + retry: false, + }); + // ----- mutations -------------------------------------------------- const createIssue = trpc.issue.create.useMutation({ onError: (err) => toast.error(err.message), }); + const importGitHubIssue = trpc.github.importIssue.useMutation({ + onError: (err) => toast.error(err.message), + }); const createComment = trpc.comment.create.useMutation({ onError: (err) => toast.error(err.message), }); @@ -390,6 +434,7 @@ export function QuickCreate() { const busy = createIssue.isPending || + importGitHubIssue.isPending || createComment.isPending || createCycle.isPending || createInitiative.isPending || @@ -671,6 +716,45 @@ export function QuickCreate() { try { switch (mode.kind) { case "issue": { + if (githubImportRef) { + if (githubLinkability.isLoading) { + toast.info("Checking GitHub access…"); + return; + } + if (!githubReady) { + toast.error("Connect this GitHub repository before importing it."); + return; + } + if (githubPreview.isLoading) { + toast.info("Loading GitHub preview…"); + return; + } + if (githubPreview.isError || !githubPreview.data) { + toast.error(githubPreview.error?.message ?? "Could not load GitHub preview."); + return; + } + const explicitLabelIds = (labels ?? []) + .filter((label) => + committed.some( + (c) => c.kind === "label" && c.name.toLowerCase() === label.name.toLowerCase(), + ), + ) + .map((label) => label.id); + const result = await importGitHubIssue.mutateAsync({ + mappingId: githubReady.mappingId, + url: githubPreview.data.url, + projectId: projectId || null, + labelIds: explicitLabelIds, + }); + await utils.issue.list.invalidate(); + done(result.created ? "Imported GitHub issue." : "Opened existing imported issue."); + if (secondary) { + const base = ws ? `/w/${ws.slug}` : ""; + router.push(`${base}/issues/${result.issueId}`); + } + return; + } + // Resolve title + commands. Most commands have already been // committed into chips (priority/project synced onto the native // pickers, the rest in `committed`), but the operator may have a @@ -1321,13 +1405,26 @@ export function QuickCreate() { MOTION.fast, )} > - {busy ? "…" : "Create"} + {busy ? "…" : githubImportRef ? "Import" : "Create"} + {githubImportRef && ( + + )} + {/* Add pills (mouse path) — issue / sub-issue only. Each primes the matching slash so it shares the keyboard value picker. */} {isIssueLike && ( @@ -1527,6 +1624,121 @@ export function QuickCreate() { ); } +function GitHubImportStatus({ + checking, + linkabilityError, + linkabilityStatus, + previewLoading, + previewError, + snapshot, + fallbackRepo, + fallbackNumber, +}: { + checking: boolean; + linkabilityError: string | null; + linkabilityStatus: string | null; + previewLoading: boolean; + previewError: string | null; + snapshot: + | { + resourceType: string; + title: string; + state: string; + url: string; + repoFullName: string; + number: number; + } + | null; + fallbackRepo: string; + fallbackNumber: number; +}) { + const lineClass = "flex items-center gap-1.5 text-meta"; + const warningClass = `${lineClass} text-warning`; + const mutedClass = `${lineClass} text-muted-foreground`; + + if (checking) { + return ( +
+

+ + Checking access to {fallbackRepo}… +

+
+ ); + } + + if (linkabilityError) { + return ( +
+

+ + {linkabilityError} +

+
+ ); + } + + if (linkabilityStatus && linkabilityStatus !== "ready") { + return ( +
+

+ + Connect {fallbackRepo} in GitHub settings before importing. +

+
+ ); + } + + if (previewLoading) { + return ( +
+

+ + Loading {fallbackRepo}#{fallbackNumber}… +

+
+ ); + } + + if (previewError) { + return ( +
+

+ + {previewError} +

+
+ ); + } + + if (!snapshot) return null; + const isPr = snapshot.resourceType === "PULL_REQUEST"; + return ( +
+
+ {isPr ? ( + + ) : ( + + )} +
+

+ {snapshot.title} +

+
+ + {snapshot.repoFullName}#{snapshot.number} + + {isPr ? "pull request" : "issue"} + {snapshot.state || "unknown"} + Press Enter to import +
+
+
+
+ ); +} + type SwitchableMode = | "issue" | "cycle" diff --git a/src/components/ui/anchored-popover.tsx b/src/components/ui/anchored-popover.tsx index 77498dc4..c352599a 100644 --- a/src/components/ui/anchored-popover.tsx +++ b/src/components/ui/anchored-popover.tsx @@ -37,6 +37,7 @@ export function AnchoredPopover({ minSpaceBelow = 220, className, role = "menu", + id, ariaLabel, dismissOnOutsideClick = true, dismissOnEscape = true, @@ -58,6 +59,7 @@ export function AnchoredPopover({ minSpaceBelow?: number; className?: string; role?: string; + id?: string; ariaLabel?: string; dismissOnOutsideClick?: boolean; dismissOnEscape?: boolean; @@ -140,6 +142,7 @@ export function AnchoredPopover({ return createPortal(
(null); const inputRef = useRef(null); + const listboxId = useId(); const isAsync = !!onQueryChange; const showSearch = searchable || isAsync; @@ -134,9 +135,11 @@ export function Combobox({