diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index a057ed204..ad843af85 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -191,7 +191,7 @@ export function PullRequestThreadDialog({ Pull request { setReferenceDirty(true); diff --git a/apps/web/src/components/merge-conflicts/MergeConflictShell.logic.ts b/apps/web/src/components/merge-conflicts/MergeConflictShell.logic.ts index d9c1ce1bf..23575dbd2 100644 --- a/apps/web/src/components/merge-conflicts/MergeConflictShell.logic.ts +++ b/apps/web/src/components/merge-conflicts/MergeConflictShell.logic.ts @@ -76,9 +76,9 @@ export function buildConflictRecommendation(input: { candidateId: null, recommendedAction: null, tone: "neutral", - title: "Resolve a pull request link to start.", + title: "Resolve a pull request to start.", detail: - "Paste a GitHub pull request URL to inspect mergeability, pull candidate resolutions, and stage a human-readable handoff note.", + "Paste a GitHub pull request URL or enter 123 / #123 to inspect mergeability, pull candidate resolutions, and stage a human-readable handoff note.", }; } diff --git a/apps/web/src/components/merge-conflicts/MergeConflictShell.tsx b/apps/web/src/components/merge-conflicts/MergeConflictShell.tsx index f8bdd8e90..c0747c149 100644 --- a/apps/web/src/components/merge-conflicts/MergeConflictShell.tsx +++ b/apps/web/src/components/merge-conflicts/MergeConflictShell.tsx @@ -38,6 +38,7 @@ import { import { cn } from "~/lib/utils"; import { ensureNativeApi } from "~/nativeApi"; import { parsePullRequestReference } from "~/pullRequestReference"; +import { findProjectMatchingPullRequestReference } from "~/pullRequestProjectMatch"; import type { Project } from "~/types"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { toastManager } from "~/components/ui/toast"; @@ -409,6 +410,11 @@ export function MergeConflictShell({ (debouncerState) => ({ isPending: debouncerState.isPending }), ); + const matchedProjectForReference = useMemo( + () => findProjectMatchingPullRequestReference(projects, reference), + [projects, reference], + ); + const matchedProjectIdForReference = matchedProjectForReference?.id ?? null; const parsedReference = parsePullRequestReference(reference); const parsedDebouncedReference = parsePullRequestReference(debouncedReference); const resolvedPullRequestQuery = useQuery( @@ -493,8 +499,13 @@ export function MergeConflictShell({ }); useEffect(() => { - setReference(""); - setReferenceDirty(false); + if (matchedProjectIdForReference && matchedProjectIdForReference !== project.id) { + onProjectChange(matchedProjectIdForReference); + return; + } + }, [matchedProjectIdForReference, onProjectChange, project.id]); + + useEffect(() => { setPreparedWorkspace(null); setSelectedCandidateId(null); setInspectorOpen(false); @@ -547,9 +558,9 @@ export function MergeConflictShell({ const validationMessage = !referenceDirty ? null : reference.trim().length === 0 - ? "Paste a GitHub pull request URL." + ? "Paste a GitHub pull request URL or enter 123 / #123." : parsedReference === null - ? "Use a GitHub pull request URL." + ? "Use a GitHub pull request URL, 123, or #123." : null; const resolveErrorMessage = validationMessage ?? @@ -570,10 +581,10 @@ export function MergeConflictShell({ : null; const steps = [ { - title: "Resolve pull request link", + title: "Resolve pull request", detail: resolvedPullRequest ? `PR #${resolvedPullRequest.number} is resolved against ${projectLabel(project)}.` - : "Paste a GitHub pull request URL to fetch metadata and conflict status.", + : "Paste a GitHub pull request URL or enter 123 / #123 to fetch metadata and conflict status.", status: resolvedPullRequest ? ("done" as const) : isResolvingPullRequest @@ -674,8 +685,8 @@ export function MergeConflictShell({ - Resolve conflicts from a GitHub PR link, then let OK Code guide the safest - next action. + Resolve conflicts from a GitHub PR URL or PR number. A full URL auto-matches + the repository before OK Code prepares the workspace. } @@ -706,9 +717,9 @@ export function MergeConflictShell({ {isResolvingPullRequest ? ( @@ -877,11 +892,11 @@ export function MergeConflictShell({
-

Paste a pull request link

+

Enter a pull request

- This panel is intentionally narrow: it resolves one GitHub PR link, checks - whether conflicts exist, and walks you through the safest conflict resolution - path. + Paste a GitHub PR URL to auto-match the repository, or use `#42` / `42` against + the selected repo. This panel resolves one PR, checks whether conflicts exist, + and walks you through the safest conflict resolution path.

diff --git a/apps/web/src/pullRequestProjectMatch.test.ts b/apps/web/src/pullRequestProjectMatch.test.ts new file mode 100644 index 000000000..f3bfe994f --- /dev/null +++ b/apps/web/src/pullRequestProjectMatch.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { findProjectMatchingPullRequestReference } from "./pullRequestProjectMatch"; +import type { Project } from "./types"; + +function makeProject(overrides: Partial): Project { + return { + id: "project-1" as Project["id"], + name: "Demo Repo", + cwd: "/Users/buns/projects/demo-repo", + model: "gpt-5.4", + expanded: false, + scripts: [], + ...overrides, + }; +} + +describe("findProjectMatchingPullRequestReference", () => { + it("matches pull request URLs against the project name", () => { + const projects = [ + makeProject({ id: "project-1" as Project["id"], name: "Psi Claw" }), + makeProject({ id: "project-2" as Project["id"], name: "Another Repo" }), + ]; + + expect( + findProjectMatchingPullRequestReference( + projects, + "https://github.com/OpenKnots/psi-claw/pull/137", + )?.id, + ).toBe("project-1"); + }); + + it("falls back to the cwd basename when the project name does not match", () => { + const projects = [ + makeProject({ + id: "project-1" as Project["id"], + name: "Workspace", + cwd: "/Users/buns/Documents/GitHub/PsiClaw/psi-claw", + }), + ]; + + expect( + findProjectMatchingPullRequestReference( + projects, + "https://github.com/OpenKnots/psi-claw/pull/137", + )?.id, + ).toBe("project-1"); + }); + + it("returns null for numeric pull request references", () => { + const projects = [makeProject({ id: "project-1" as Project["id"] })]; + + expect(findProjectMatchingPullRequestReference(projects, "#137")).toBeNull(); + expect(findProjectMatchingPullRequestReference(projects, "137")).toBeNull(); + }); + + it("returns null when no local project matches the URL repository", () => { + const projects = [makeProject({ id: "project-1" as Project["id"], name: "okcode" })]; + + expect( + findProjectMatchingPullRequestReference( + projects, + "https://github.com/OpenKnots/psi-claw/pull/137", + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/pullRequestProjectMatch.ts b/apps/web/src/pullRequestProjectMatch.ts new file mode 100644 index 000000000..e36c42729 --- /dev/null +++ b/apps/web/src/pullRequestProjectMatch.ts @@ -0,0 +1,44 @@ +import type { Project } from "./types"; +import { parsePullRequestReferenceParts } from "./pullRequestReference"; + +function lastPathSegment(input: string): string { + const segments = input.split(/[\\/]/).filter((segment) => segment.length > 0); + return segments.at(-1) ?? ""; +} + +function normalizeRepositorySlug(input: string): string { + return input + .trim() + .replace(/\.git$/i, "") + .replace(/[_\s]+/g, "-") + .replace(/-+/g, "-") + .toLowerCase(); +} + +function projectRepositoryCandidates(project: Project): string[] { + const candidates = [project.name, lastPathSegment(project.cwd)] + .map(normalizeRepositorySlug) + .filter((candidate) => candidate.length > 0); + + return [...new Set(candidates)]; +} + +export function findProjectMatchingPullRequestReference( + projects: readonly Project[], + reference: string, +): Project | null { + const parsed = parsePullRequestReferenceParts(reference); + if (parsed?.kind !== "url" || !parsed.repo) { + return null; + } + + const targetRepository = normalizeRepositorySlug(parsed.repo); + if (targetRepository.length === 0) { + return null; + } + + return ( + projects.find((project) => projectRepositoryCandidates(project).includes(targetRepository)) ?? + null + ); +} diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 46748a399..37c1c795f 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parsePullRequestReference } from "./pullRequestReference"; +import { parsePullRequestReference, parsePullRequestReferenceParts } from "./pullRequestReference"; describe("parsePullRequestReference", () => { it("accepts GitHub pull request URLs", () => { @@ -9,6 +9,18 @@ describe("parsePullRequestReference", () => { ); }); + it("extracts repository metadata from GitHub pull request URLs", () => { + expect( + parsePullRequestReferenceParts("https://github.com/pingdotgg/okcode/pull/42/files"), + ).toEqual({ + kind: "url", + reference: "https://github.com/pingdotgg/okcode/pull/42/files", + number: "42", + owner: "pingdotgg", + repo: "okcode", + }); + }); + it("accepts raw numbers", () => { expect(parsePullRequestReference("42")).toBe("42"); }); diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index ecaf916b7..8bcc67a53 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -1,22 +1,46 @@ const GITHUB_PULL_REQUEST_URL_PATTERN = - /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; + /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i; const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; -export function parsePullRequestReference(input: string): string | null { +export interface ParsedPullRequestReference { + kind: "url" | "number"; + reference: string; + number: string; + owner: string | null; + repo: string | null; +} + +export function parsePullRequestReferenceParts(input: string): ParsedPullRequestReference | null { const trimmed = input.trim(); if (trimmed.length === 0) { return null; } const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(trimmed); - if (urlMatch?.[1]) { - return trimmed; + if (urlMatch?.[3]) { + return { + kind: "url", + reference: trimmed, + number: urlMatch[3], + owner: urlMatch[1] ?? null, + repo: urlMatch[2] ?? null, + }; } const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(trimmed); if (numberMatch?.[1]) { - return trimmed.startsWith("#") ? trimmed : numberMatch[1]; + return { + kind: "number", + reference: trimmed.startsWith("#") ? trimmed : numberMatch[1], + number: numberMatch[1], + owner: null, + repo: null, + }; } return null; } + +export function parsePullRequestReference(input: string): string | null { + return parsePullRequestReferenceParts(input)?.reference ?? null; +}