Auto-attach pasted GitHub PR/issue URLs in the composer (#1006)

* Auto-attach pasted GitHub PR/issue URLs in the composer

Paste a GitHub PR or issue link into any composer and it shows up
as an attachment pill within a debounce tick when the URL matches
the workspace's remote. On the new-workspace screen, an attached PR
also surfaces an inline "Check out PR #N?" hint next to the picker;
one click promotes it to the checkout target through the same
syncPickerPrAttachment flow the dropdown uses.

Unifies the new-workspace picker and composer attachment picker on
one shared GitHub search hook and cache key, so both consumers (and
the auto-attach lookup) share the React Query cache.

* Fix oxfmt formatting in new-workspace-screen import
This commit is contained in:
Mohamed Boudra
2026-05-14 17:52:46 +08:00
committed by GitHub
parent 8296c797ff
commit 2e8bced707
12 changed files with 1147 additions and 130 deletions
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { extractGithubRefs, normalizeGithubRemote, parseGithubRef } from "./github-refs";
const httpsRemote = "https://github.com/getpaseo/paseo.git";
const sshRemote = "git@github.com:getpaseo/paseo.git";
describe("normalizeGithubRemote", () => {
it.each([
["https://github.com/getpaseo/paseo", { owner: "getpaseo", repo: "paseo", host: "github.com" }],
[
"https://github.com/getpaseo/paseo.git",
{ owner: "getpaseo", repo: "paseo", host: "github.com" },
],
["git@github.com:getpaseo/paseo.git", { owner: "getpaseo", repo: "paseo", host: "github.com" }],
[
"ssh://git@github.com/getpaseo/paseo.git",
{ owner: "getpaseo", repo: "paseo", host: "github.com" },
],
])("extracts GitHub identity from %s", (remoteUrl, expected) => {
expect(normalizeGithubRemote(remoteUrl)).toEqual(expected);
});
it("returns null for non-GitHub remotes and empty input", () => {
expect(normalizeGithubRemote("git@gitlab.com:getpaseo/paseo.git")).toBeNull();
expect(normalizeGithubRemote(null)).toBeNull();
});
});
describe("parseGithubRef", () => {
it.each([
"https://github.com/getpaseo/paseo/pull/994",
"https://github.com/getpaseo/paseo/pull/994/",
"https://github.com/getpaseo/paseo/pull/994/files",
"https://github.com/getpaseo/paseo/pull/994?diff=split",
"https://github.com/getpaseo/paseo/pull/994#discussion_r123",
])("parses a matching pull request URL: %s", (text) => {
expect(parseGithubRef(text, httpsRemote)).toEqual({
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
});
});
it("parses a matching issue URL", () => {
expect(parseGithubRef("https://github.com/getpaseo/paseo/issues/456", httpsRemote)).toEqual({
kind: "issues",
number: 456,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/issues/456",
});
});
it("matches HTTPS pasted URLs against an SSH remote", () => {
expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", sshRemote)).toEqual({
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
});
});
it("ignores URLs for another owner or repo", () => {
expect(parseGithubRef("https://github.com/other/paseo/pull/994", httpsRemote)).toBeNull();
expect(parseGithubRef("https://github.com/getpaseo/other/pull/994", httpsRemote)).toBeNull();
});
it("returns null for non-GitHub remotes and empty text", () => {
expect(
parseGithubRef(
"https://github.com/getpaseo/paseo/pull/994",
"git@gitlab.com:getpaseo/paseo.git",
),
).toBeNull();
expect(parseGithubRef("", httpsRemote)).toBeNull();
expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", null)).toBeNull();
});
it("finds URLs embedded in text and markdown links", () => {
expect(
parseGithubRef(
"See:\n[the PR](https://github.com/getpaseo/paseo/pull/994/files).",
httpsRemote,
),
).toEqual({
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
});
});
});
describe("extractGithubRefs", () => {
it("returns every matching ref deduped by kind and number", () => {
const text = [
"https://github.com/getpaseo/paseo/pull/994",
"https://github.com/getpaseo/paseo/issues/456#issuecomment-1",
"https://github.com/getpaseo/paseo/pull/994/files",
"https://github.com/other/paseo/issues/1",
].join("\n");
expect(extractGithubRefs(text, httpsRemote)).toEqual([
{
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
},
{
kind: "issues",
number: 456,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/issues/456",
},
]);
});
it("returns an empty array for empty text or null remote", () => {
expect(extractGithubRefs("", httpsRemote)).toEqual([]);
expect(extractGithubRefs("https://github.com/getpaseo/paseo/pull/994", null)).toEqual([]);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { parseGitHubRemoteUrl } from "@server/shared/git-remote";
export type GithubRefKind = "pull" | "issues";
export interface GithubRemote {
owner: string;
repo: string;
host: "github.com";
}
export interface GithubRef {
kind: GithubRefKind;
number: number;
owner: string;
repo: string;
url: string;
}
interface ParsedGithubUrl {
kind: GithubRefKind;
number: number;
owner: string;
repo: string;
}
const GITHUB_REF_URL_PATTERN =
/https?:\/\/github\.com\/([^/\s<>)\]]+)\/([^/\s<>)\]]+)\/(pull|issues)\/(\d+)(?:[/?#][^\s<>)\]]*)?/giu;
export function normalizeGithubRemote(remoteUrl: string | null | undefined): GithubRemote | null {
const trimmed = remoteUrl?.trim();
if (!trimmed) {
return null;
}
const identity = parseGitHubRemoteUrl(trimmed);
if (!identity) {
return null;
}
return {
owner: identity.owner,
repo: identity.name,
host: "github.com",
};
}
export function parseGithubRef(
text: string | null | undefined,
remoteUrl: string | null | undefined,
): GithubRef | null {
return extractGithubRefs(text, remoteUrl)[0] ?? null;
}
export function extractGithubRefs(
text: string | null | undefined,
remoteUrl: string | null | undefined,
): GithubRef[] {
const remote = normalizeGithubRemote(remoteUrl);
const body = text?.trim();
if (!remote || !body) {
return [];
}
const refs: GithubRef[] = [];
const seen = new Set<string>();
for (const match of body.matchAll(GITHUB_REF_URL_PATTERN)) {
const parsed = parseGithubUrlMatch(match);
if (!parsed || !matchesRemote(parsed, remote)) {
continue;
}
const dedupeKey = `${parsed.kind}:${parsed.number}`;
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
refs.push({
kind: parsed.kind,
number: parsed.number,
owner: remote.owner,
repo: remote.repo,
url: `https://github.com/${remote.owner}/${remote.repo}/${parsed.kind}/${parsed.number}`,
});
}
return refs;
}
function parseGithubUrlMatch(match: RegExpMatchArray): ParsedGithubUrl | null {
const owner = match[1];
const repo = match[2];
const kind = match[3];
const numberText = match[4];
if (!owner || !repo || !isGithubRefKind(kind) || !numberText) {
return null;
}
const number = Number.parseInt(numberText, 10);
if (!Number.isSafeInteger(number) || number <= 0) {
return null;
}
return { owner, repo, kind, number };
}
function isGithubRefKind(value: string): value is GithubRefKind {
return value === "pull" || value === "issues";
}
function matchesRemote(parsed: ParsedGithubUrl, remote: GithubRemote): boolean {
return (
parsed.owner.toLowerCase() === remote.owner.toLowerCase() &&
parsed.repo.toLowerCase() === remote.repo.toLowerCase()
);
}