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
2 changes: 1 addition & 1 deletion apps/web/src/components/PullRequestThreadDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export function PullRequestThreadDialog({
<span className="text-xs font-medium text-foreground">Pull request</span>
<Input
ref={referenceInputRef}
placeholder="https://github.com/owner/repo/pull/42 or #42"
placeholder="https://github.com/owner/repo/pull/42, #42, or 42"
value={reference}
onChange={(event) => {
setReferenceDirty(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
};
}

Expand Down
43 changes: 29 additions & 14 deletions apps/web/src/components/merge-conflicts/MergeConflictShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 ??
Expand All @@ -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
Expand Down Expand Up @@ -674,8 +685,8 @@ export function MergeConflictShell({
<InfoIcon className="size-3.5" />
</TooltipTrigger>
<TooltipPopup side="right" className="max-w-56">
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.
</TooltipPopup>
</Tooltip>
}
Expand Down Expand Up @@ -706,9 +717,9 @@ export function MergeConflictShell({
</label>

<label className="mt-3 block space-y-1.5">
<span className="text-xs font-medium text-foreground">Pull request link</span>
<span className="text-xs font-medium text-foreground">Pull request</span>
<Input
placeholder="https://github.com/owner/repo/pull/42"
placeholder="https://github.com/owner/repo/pull/42, #42, or 42"
value={reference}
onChange={(event) => {
setReferenceDirty(true);
Expand All @@ -722,6 +733,10 @@ export function MergeConflictShell({
}
}}
/>
<p className="mt-1 text-[11px] text-muted-foreground">
A GitHub PR URL auto-selects the matching repo. Use `#42` or `42` against the
selected repo.
</p>
</label>

{isResolvingPullRequest ? (
Expand Down Expand Up @@ -877,11 +892,11 @@ export function MergeConflictShell({
<div className="flex h-full items-center justify-center px-6">
<div className="max-w-lg space-y-3 text-center">
<LinkIcon className="mx-auto size-8 text-muted-foreground/30" />
<p className="font-medium text-sm text-foreground">Paste a pull request link</p>
<p className="font-medium text-sm text-foreground">Enter a pull request</p>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
</div>
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/pullRequestProjectMatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";

import { findProjectMatchingPullRequestReference } from "./pullRequestProjectMatch";
import type { Project } from "./types";

function makeProject(overrides: Partial<Project>): 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();
});
});
44 changes: 44 additions & 0 deletions apps/web/src/pullRequestProjectMatch.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
14 changes: 13 additions & 1 deletion apps/web/src/pullRequestReference.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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");
});
Expand Down
34 changes: 29 additions & 5 deletions apps/web/src/pullRequestReference.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading