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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user