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
@@ -1,10 +1,7 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import {
deriveAutoPickerItemFromAttachments,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
return {
@@ -20,7 +17,9 @@ function makePrItem(number: number, title: string, headRefName = "feature/x"): G
};
}
function prAttachment(item: GitHubSearchItem): UserComposerAttachment {
function prAttachment(
item: GitHubSearchItem,
): Extract<UserComposerAttachment, { kind: "github_pr" }> {
return { kind: "github_pr", item };
}
@@ -98,34 +97,53 @@ describe("syncPickerPrAttachment", () => {
});
});
describe("deriveAutoPickerItemFromAttachments", () => {
it("returns null when there are no attachments", () => {
expect(deriveAutoPickerItemFromAttachments([])).toBeNull();
describe("findCheckoutHintPrAttachment", () => {
it("returns the first attached PR that is not selected or dismissed", () => {
const first = prAttachment(makePrItem(101, "A"));
const second = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44), first, second],
selectedItem: null,
dismissedPrNumbers: new Set(),
}),
).toBe(first);
});
it("returns the PR when exactly one is attached", () => {
const pr = makePrItem(923, "Nix overridable npm deps hash");
expect(deriveAutoPickerItemFromAttachments([prAttachment(pr)])).toEqual({
kind: "github-pr",
item: pr,
});
it("skips the selected PR and offers the next attached PR", () => {
const selected = prAttachment(makePrItem(101, "A"));
const next = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [selected, next],
selectedItem: { kind: "github-pr", item: selected.item },
dismissedPrNumbers: new Set(),
}),
).toBe(next);
});
it("returns null when multiple PRs are attached", () => {
const a = makePrItem(101, "A");
const b = makePrItem(202, "B");
expect(deriveAutoPickerItemFromAttachments([prAttachment(a), prAttachment(b)])).toBeNull();
it("skips dismissed PRs and ignores issues", () => {
const dismissed = prAttachment(makePrItem(101, "A"));
const next = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44), dismissed, next],
selectedItem: null,
dismissedPrNumbers: new Set([101]),
}),
).toBe(next);
});
it("ignores non-PR attachments", () => {
expect(deriveAutoPickerItemFromAttachments([issueAttachment(44)])).toBeNull();
});
it("returns the lone PR even when other non-PR attachments are present", () => {
const pr = makePrItem(923, "Nix overridable npm deps hash");
expect(deriveAutoPickerItemFromAttachments([issueAttachment(44), prAttachment(pr)])).toEqual({
kind: "github-pr",
item: pr,
});
it("returns null when only issues qualify", () => {
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44)],
selectedItem: null,
dismissedPrNumbers: new Set(),
}),
).toBeNull();
});
});