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
+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()
);
}