Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
26 changes: 26 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

> 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. Follow-up CI fix updated `sprints-roadmap.spec.ts` to drive the
new `DatePicker` buttons instead of removed native date inputs; local rerun
passed (2/2). 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
Expand Down
216 changes: 214 additions & 2 deletions src/components/quick-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -390,6 +434,7 @@ export function QuickCreate() {

const busy =
createIssue.isPending ||
importGitHubIssue.isPending ||
createComment.isPending ||
createCycle.isPending ||
createInitiative.isPending ||
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1321,13 +1405,26 @@ export function QuickCreate() {
MOTION.fast,
)}
>
{busy ? "…" : "Create"}
{busy ? "…" : githubImportRef ? "Import" : "Create"}
<Kbd className="border-ember-foreground/30 bg-ember-foreground/10 text-ember-foreground/80">
</Kbd>
</button>
</div>

{githubImportRef && (
<GitHubImportStatus
checking={githubLinkability.isLoading}
linkabilityError={githubLinkability.error?.message ?? null}
linkabilityStatus={githubLinkability.data?.status ?? null}
previewLoading={githubPreview.isLoading}
previewError={githubPreview.error?.message ?? null}
snapshot={githubPreview.data ?? null}
fallbackRepo={githubImportRef.repoFullName}
fallbackNumber={githubImportRef.number}
/>
)}

{/* Add pills (mouse path) — issue / sub-issue only. Each primes
the matching slash so it shares the keyboard value picker. */}
{isIssueLike && (
Expand Down Expand Up @@ -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 (
<div className="border-t border-border/60 bg-card/30 px-4 py-2">
<p className={mutedClass}>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Checking access to <span className="font-mono">{fallbackRepo}</span>…
</p>
</div>
);
}

if (linkabilityError) {
return (
<div className="border-t border-border/60 bg-card/30 px-4 py-2">
<p className={warningClass}>
<AlertCircle className="h-3.5 w-3.5" />
{linkabilityError}
</p>
</div>
);
}

if (linkabilityStatus && linkabilityStatus !== "ready") {
return (
<div className="border-t border-border/60 bg-card/30 px-4 py-2">
<p className={warningClass}>
<AlertCircle className="h-3.5 w-3.5" />
Connect <span className="font-mono">{fallbackRepo}</span> in GitHub settings before importing.
</p>
</div>
);
}

if (previewLoading) {
return (
<div className="border-t border-border/60 bg-card/30 px-4 py-2">
<p className={mutedClass}>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading <span className="font-mono">{fallbackRepo}#{fallbackNumber}</span>…
</p>
</div>
);
}

if (previewError) {
return (
<div className="border-t border-border/60 bg-card/30 px-4 py-2">
<p className={warningClass}>
<AlertCircle className="h-3.5 w-3.5" />
{previewError}
</p>
</div>
);
}

if (!snapshot) return null;
const isPr = snapshot.resourceType === "PULL_REQUEST";
return (
<div className="border-t border-border/60 bg-card/30 px-4 py-2">
<div className="flex items-start gap-2 rounded-md border border-border bg-background/70 p-2">
{isPr ? (
<GitPullRequest className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Github className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-[0.8125rem] font-medium" title={snapshot.title}>
{snapshot.title}
</p>
<div className="mt-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
<span className="font-mono">
{snapshot.repoFullName}#{snapshot.number}
</span>
<span>{isPr ? "pull request" : "issue"}</span>
<span>{snapshot.state || "unknown"}</span>
<span className="ml-auto">Press Enter to import</span>
</div>
</div>
</div>
</div>
);
}

type SwitchableMode =
| "issue"
| "cycle"
Expand Down
3 changes: 3 additions & 0 deletions src/components/ui/anchored-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function AnchoredPopover({
minSpaceBelow = 220,
className,
role = "menu",
id,
ariaLabel,
dismissOnOutsideClick = true,
dismissOnEscape = true,
Expand All @@ -58,6 +59,7 @@ export function AnchoredPopover({
minSpaceBelow?: number;
className?: string;
role?: string;
id?: string;
ariaLabel?: string;
dismissOnOutsideClick?: boolean;
dismissOnEscape?: boolean;
Expand Down Expand Up @@ -140,6 +142,7 @@ export function AnchoredPopover({
return createPortal(
<div
ref={popoverRef}
id={id}
role={role}
aria-label={ariaLabel}
onMouseEnter={onMouseEnter}
Expand Down
6 changes: 5 additions & 1 deletion src/components/ui/combobox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { Check, ChevronDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { AnchoredPopover } from "@/components/ui/anchored-popover";
Expand Down Expand Up @@ -79,6 +79,7 @@ export function Combobox({
const [active, setActive] = useState(0);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listboxId = useId();

const isAsync = !!onQueryChange;
const showSearch = searchable || isAsync;
Expand Down Expand Up @@ -134,9 +135,11 @@ export function Combobox({
<button
ref={triggerRef}
type="button"
role="combobox"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
aria-label={ariaLabel}
onClick={() => setOpen((v) => !v)}
className={cn(
Expand Down Expand Up @@ -167,6 +170,7 @@ export function Combobox({
align={align}
matchWidth={matchTriggerWidth}
role="listbox"
id={listboxId}
ariaLabel={ariaLabel}
className={cn(
"min-w-[11rem] rounded-md border border-border bg-popover shadow-md",
Expand Down
Loading
Loading