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
3 changes: 3 additions & 0 deletions src/hub/lib/agents/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,9 @@ describe("hub open deep link", () => {
expect(isHubCommitSha("abc123")).toBe(false);
expect(isHubCommitSha("abc123g")).toBe(false);
expect(isHubCommitSha("a".repeat(41))).toBe(false);
expect(isHubCommitSha("abc1234..def5678")).toBe(true);
expect(isHubCommitSha("abc1234...def5678")).toBe(false);
expect(isHubCommitSha("abc1234..")).toBe(false);
expect(commitFlagsProblem({ pr: "acme/app#7", commit: "abc1234" })).toBeUndefined();
expect(commitFlagsProblem({ repo: "acme/app", commit: "abc1234" })).toBeUndefined();
expect(commitFlagsProblem({})).toBeUndefined();
Expand Down
7 changes: 4 additions & 3 deletions src/hub/lib/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ export interface OpenHubOptions {
/** With `pr`: this repo-relative file opens in that PR's review. */
reveal?: string;
/**
* A commit sha (7 to 40 hex characters, a prefix is enough). With `pr` it is selected in that PR's
* review; without it, `repo` names its project and it opens in the Worktrees mode's diff.
* A commit sha (7 to 40 hex characters, a prefix is enough), or `<from>..<to>`: a run of commits with both
* ends included. With `pr` it is selected in that PR's review; without it, `repo` names its project and it
* opens in the Worktrees mode's diff.
*/
commit?: string;
/** With `commit` and no `pr`: the commit's project, a checkout path or its forge path (`owner/repo`). */
Expand Down Expand Up @@ -151,7 +152,7 @@ export function hubArgs(options: OpenHubOptions): string[] {
* The same target as a link: `genesis-tools://hub?session=<parent>&agent=<child>`, with `mode`,
* `pr`, `reveal`, `repo`, `commit`, `tab`, `filter` and `worktree` as further query items (keys of
* `HubRequest.linkKeys` in the app). A link without `worktree` and `reveal` lost them: `--print-url`
* named neither. A commit link: `?pr=owner%2Frepo%2342&commit=<sha>`, or `?repo=<path>&commit=<sha>`.
* named neither. A commit link: `?pr=owner%2Frepo%2342&commit=<sha>` (or `<from>..<to>`), or `?repo=<path>&commit=<sha>`.
*/
export function hubUrl(options: Pick<OpenHubOptions, (typeof HUB_LINK_KEYS)[number]>): string {
// The link itself is built in the router utils, beside its https twin, so the two cannot drift.
Expand Down
9 changes: 6 additions & 3 deletions src/hub/lib/pr-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ export function isHubPrRef(ref: string): boolean {
return /^(?:.*[#!])?\d+$/.test(text) || PR_PAGE.test(text);
}

/** What the hub's `--commit` takes (`HubCommitSha` in Sources/Hub/HubCommitLink.swift): 7 to 40 hex characters, a prefix of the sha. */
/**
* What the hub's `--commit` takes (`HubCommitSpan` in Sources/Parts/Hub/Hub/HubCommitLink.swift): 7 to 40 hex
* characters, a prefix of the sha, or `<from>..<to>`, a run of commits with both ends included.
*/
export function isHubCommitSha(sha: string): boolean {
return /^[0-9a-f]{7,40}$/i.test(sha.trim());
return /^[0-9a-f]{7,40}(?:\.\.[0-9a-f]{7,40})?$/i.test(sha.trim());
}

/** Why `tools hub open`'s `--commit` / `--repo` cannot open as given; undefined when they can or are absent. */
Expand All @@ -27,7 +30,7 @@ export function commitFlagsProblem({
pr?: string;
}): string | undefined {
if (commit !== undefined && !isHubCommitSha(commit)) {
return `--commit takes a sha of 7 to 40 hex characters, got ${commit}`;
return `--commit takes a sha of 7 to 40 hex characters, or <from>..<to>, got ${commit}`;
}

if (repo !== undefined && pr !== undefined) {
Expand Down
12 changes: 6 additions & 6 deletions src/hub/lib/pr/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
type PrSummary,
parseGhPrRows,
parseGlabMrRows,
parsePrRef,
parsePrUrl,
projectRefFromRemote,
spawnRunner,
Expand All @@ -15,6 +14,7 @@ import { GH_LIST_FIELDS } from "@genesiscz/utils/git/origins/prs";
import { type RepoFacts, repoFacts } from "@genesiscz/utils/git/repo-facts";
import { SafeJSON } from "@genesiscz/utils/json";
import { logger } from "@genesiscz/utils/logger";
import { resolveHubPrRef } from "./project-ref";
import { type FindResult, type FoundPr, HubPrError } from "./types";

const log = logger.child({ component: "hub/pr/find" });
Expand Down Expand Up @@ -280,10 +280,10 @@ export async function findPrByRef({
runner?: CommandRunner;
readFacts?: FactsReader;
}): Promise<FoundPr> {
const parsed = parsePrRef(ref);
const parsed = await resolveHubPrRef(ref);

if (!parsed) {
throw new HubPrError("bad-input", `--pr takes a PR/MR URL, <repoPath>#<number> or <number>, got "${ref}"`);
if ("problem" in parsed) {
throw new HubPrError("bad-input", `--pr: ${parsed.problem}`);
}

let project: ProjectRef | null;
Expand Down Expand Up @@ -344,9 +344,9 @@ export async function explicitPrTarget({
ref: string;
readFacts?: FactsReader;
}): Promise<ExplicitTarget | null> {
const parsed = parsePrRef(ref);
const parsed = await resolveHubPrRef(ref);

if (!parsed) {
if ("problem" in parsed) {
return null;
}

Expand Down
133 changes: 130 additions & 3 deletions src/hub/lib/pr/pr.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { describe, expect, test } from "bun:test";
import { createHash, randomUUID } from "node:crypto";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { defaultReviewCommentClient, type ReviewCommentClient } from "@app/github/lib/review-comments";
import type { LocalCheckout } from "@genesiscz/utils/git/local-checkouts";
import type { CommandRunner } from "@genesiscz/utils/git/origins";
import type { RepoFacts } from "@genesiscz/utils/git/repo-facts";
import { SafeJSON } from "@genesiscz/utils/json";
import { Storage } from "@genesiscz/utils/storage";
import { localCheckoutOf } from "../prs";
import { draftDatesFor, stampDraftDates } from "./draft-dates";
import { findBranchPr, findPrByRef } from "./find";
import { githubBackend, githubThread, githubVersions } from "./github";
import { gitlabBackend, gitlabPosition, gitlabThreads, gitlabVersions } from "./gitlab";
import { type FoundPr, forgetThreads, gitlabBaseUrl, type PrBackend, prThreads, readPrThreads } from "./index";
import { resolveHubPrRef } from "./project-ref";

// ─── find ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -222,7 +225,9 @@ describe("findPrByRef", () => {
repoPath: null,
});

await expect(findPrByRef({ ref: "not a ref", runner: refuseRunner })).rejects.toThrow("--pr takes");
await expect(findPrByRef({ ref: "not a ref", runner: refuseRunner })).rejects.toThrow(
"--pr: expected a PR/MR URL"
);
});

test("a PR from a fork says so, and names the fork, for `tools hub pr find --json` and the window", async () => {
Expand Down Expand Up @@ -1289,3 +1294,125 @@ describe("PR versions", () => {
expect(githubVersions({ headSha: "h9", pushes: [] }).map((version) => version.id)).toEqual(["head"]);
});
});

// ─── project refs ─────────────────────────────────────────────────────────────

describe("resolveHubPrRef", () => {
const checkout = (root: string, host: string, path: string, isMain = true): LocalCheckout => ({
root,
commonDir: `${root}/.git`,
branch: "main",
isMain,
remoteUrl: `https://${host}/${path}.git`,
project: { host, path },
});
const source = async () => ({
roots: ["/work"],
checkouts: [
checkout("/work/app-wt", "git.example.com", "group/sub/app", false),
checkout("/work/app", "git.example.com", "group/sub/app"),
checkout("/work/web", "github.com", "acme/web"),
checkout("/work/one/tool", "github.com", "acme/tool"),
checkout("/work/two/tool", "git.example.com", "team/tool"),
],
});

test("a GitLab project path with ! finds its main checkout, whatever the letter case or host prefix", async () => {
expect(await resolveHubPrRef("group/sub/app!12", { source })).toEqual({ path: "/work/app", number: 12 });
expect(await resolveHubPrRef("Group/Sub/App!12", { source })).toEqual({ path: "/work/app", number: 12 });
expect(await resolveHubPrRef("git.example.com/group/sub/app!12", { source })).toEqual({
path: "/work/app",
number: 12,
});
expect(await resolveHubPrRef("app#12", { source })).toEqual({ path: "/work/app", number: 12 });
});

test("a ! ref finds its checkout even when the current folder holds a folder of the same name", async () => {
const here = process.cwd();
const scratch = mkdtempSync(join(tmpdir(), "gt-project-ref-"));
mkdirSync(join(scratch, "app"));
process.chdir(scratch);

try {
expect(await resolveHubPrRef("app!12", { source })).toEqual({ path: "/work/app", number: 12 });
} finally {
process.chdir(here);
rmSync(scratch, { recursive: true, force: true });
}
});

test("a checkout wins over the github.com guess, which stays for a project with none", async () => {
expect(await resolveHubPrRef("acme/web#5", { source })).toEqual({ path: "/work/web", number: 5 });
expect(await resolveHubPrRef("acme/other#5", { source })).toEqual({
url: "https://github.com/acme/other/pull/5",
});
});

test("an unknown merge request or a name of two projects is a problem that says why", async () => {
const missing = await resolveHubPrRef("group/none!3", { source });
expect("problem" in missing && missing.problem).toContain(
"no checkout of group/none under the repo roots (/work)"
);
const twice = await resolveHubPrRef("tool!3", { source });
expect("problem" in twice && twice.problem).toContain("names 2 projects");
});

test("of two clones of one project, the folder named like the project wins over the first by name", async () => {
const clones = async () => ({
roots: ["/work"],
checkouts: [
checkout("/work/aaa/site-v2", "github.com", "acme/site"),
checkout("/work/site", "github.com", "acme/site"),
],
});
expect(await resolveHubPrRef("acme/site#4", { source: clones })).toEqual({ path: "/work/site", number: 4 });
});

test("a page URL opens the same clone a named ref does: the folder named like the project", async () => {
const clones = async () => ({
roots: ["/work"],
checkouts: [
checkout("/work/aaa/site-v2", "github.com", "acme/site"),
checkout("/work/site", "github.com", "acme/site"),
],
});
expect(
await localCheckoutOf(
{ kind: "github", host: "github.com", path: "acme/site", web: "https://github.com/acme/site" },
clones
)
).toBe("/work/site");
});

test("a folder written as a path takes ! as a folder, never as a project name", async () => {
expect(await resolveHubPrRef("./app!12", { source })).toEqual({ path: "./app", number: 12 });
expect(await resolveHubPrRef("~/work/app!12", { source })).toEqual({
path: join(homedir(), "work/app"),
number: 12,
});
expect(await resolveHubPrRef("~/work/app#12", { source })).toEqual({
path: join(homedir(), "work/app"),
number: 12,
});
});

test("a failed checkout scan leaves owner/repo#42 a github.com PR and says why a project is missing", async () => {
const broken = async (): Promise<{ roots: string[]; checkouts: LocalCheckout[] }> => {
throw new Error("config unreadable");
};
expect(await resolveHubPrRef("acme/other#5", { source: broken })).toEqual({
url: "https://github.com/acme/other/pull/5",
});
const missing = await resolveHubPrRef("group/none!3", { source: broken });
expect("problem" in missing && missing.problem).toContain("no checkout of group/none");
});

test("a URL never scans the checkouts", async () => {
const never = async () => {
throw new Error("scanned");
};
expect(await resolveHubPrRef("https://github.com/acme/web/pull/5", { source: never })).toEqual({
url: "https://github.com/acme/web/pull/5",
});
});
});
133 changes: 133 additions & 0 deletions src/hub/lib/pr/project-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { existsSync } from "node:fs";
import { basename } from "node:path";
import { loadConfig } from "@app/browser-extension/lib/config";
import { configuredCheckouts } from "@app/browser-extension/lib/deps";
import { type LocalCheckout, rankCheckouts } from "@genesiscz/utils/git/local-checkouts";
import { parsePrRef } from "@genesiscz/utils/git/origins";
import { logger } from "@genesiscz/utils/logger";
import { expandTilde } from "@genesiscz/utils/paths";

const log = logger.child({ component: "hub/pr/project-ref" });

export type HubPrRef = { url: string } | { path: string; number: number };

export interface CheckoutSource {
roots: string[];
checkouts: LocalCheckout[];
}

export async function liveCheckouts(): Promise<CheckoutSource> {
const config = await loadConfig();
return { roots: config.repoRoots, checkouts: configuredCheckouts(config) };
}

/**
* A PR/MR ref the hub can open, with a project named by its web path looked up among every checkout
* under the repo roots: `group/sub/app!12` (GitLab's form), `owner/repo#42`, `gitlab.example/group/app!12`,
* or only `app!12` when one project has that name. A URL, a folder and a bare number read as `parsePrRef`
* reads them. With no checkout, `owner/repo#42` is still a github.com PR; anything else is a `problem`.
*/
export async function resolveHubPrRef(
ref: string,
{ source = liveCheckouts }: { source?: () => Promise<CheckoutSource> } = {}
): Promise<HubPrRef | { problem: string }> {
const trimmed = ref.trim();
const named = /^([^\s#!]+)([#!])(\d+)$/.exec(trimmed);

const url = /^https?:\/\//i.test(trimmed);

// A folder named like `./app!12` is that folder, as `./app#12` is: `parsePrRef` reads only the `#` form.
if (named && !url && named[2] === "!" && /^[~./]/.test(named[1])) {
return { path: expandTilde(named[1]), number: Number(named[3]) };
}

if (!named || url || /^[~./]/.test(named[1]) || (named[2] === "#" && existsSync(named[1]))) {
const parsed = parsePrRef(trimmed);

// `~/work/app#12` is a folder in the home directory: the checkout readers take `~` literally.
if (parsed && "path" in parsed) {
return { path: expandTilde(parsed.path), number: parsed.number };
}

return (
parsed ?? {
problem: `expected a PR/MR URL, <repoPath>#<number> or <project>!<number>, got "${ref}"`,
}
);
}

const [, name, sign, digits] = named;
let roots: string[] = [];
let checkouts: LocalCheckout[] = [];

// A config or scan failure must not break `owner/repo#42`, which needs no checkout to read as a github.com PR.
try {
({ roots, checkouts } = await source());
} catch (err) {
log.warn({ err, ref }, "hub pr: could not list the local checkouts; reading the ref without them");
}

const found = checkoutOf(name, checkouts);

if ("root" in found) {
log.debug({ ref, root: found.root }, "hub pr: project ref resolved to a checkout");
return { path: found.root, number: Number(digits) };
}

if (found.projects.length > 1) {
return {
problem: `${name} names ${found.projects.length} projects (${found.projects.join(", ")}); use the whole path`,
};
}

const github = sign === "#" ? parsePrRef(trimmed) : null;

if (github && "url" in github) {
log.debug({ ref, url: github.url }, "hub pr: no checkout of the project, read as a github.com PR");
return github;
}

log.debug({ ref, roots, checkouts: checkouts.length }, "hub pr: no checkout of the project");
return {
problem: `no checkout of ${name} under the repo roots (${roots.join(", ")}); open it by its page URL, or clone it there`,
};
}

/**
* The best checkout of the one project `name` names: its whole web path (with or without the host), else
* the end of one (`daf-fe` for `group/daf/daf-fe`). Letter case does not matter, as on the forges.
*/
function checkoutOf(name: string, checkouts: LocalCheckout[]): { root: string } | { projects: string[] } {
const wanted = name
.toLowerCase()
.replace(/\.git$/, "")
.replace(/^\/+|\/+$/g, "");
const key = (checkout: LocalCheckout) => `${checkout.project.host}/${checkout.project.path}`;
const exact = checkouts.filter((checkout) => checkout.project.path === wanted || key(checkout) === wanted);
const pool =
exact.length > 0 ? exact : checkouts.filter((checkout) => checkout.project.path.endsWith(`/${wanted}`));
const projects = [...new Set(pool.map(key))];

if (projects.length !== 1) {
return { projects };
}

const best = preferredCheckout(rankCheckouts({ project: pool[0].project, checkouts: pool }));
return best ? { root: best.root } : { projects };
}

/**
* The checkout a link of one project opens, from its checkouts best first (`rankCheckouts`): a main checkout
* before a worktree, and of two clones the folder named like the project (`app`, not `app-v2`).
*/
export function preferredCheckout(ranked: LocalCheckout[]): LocalCheckout | undefined {
const mains = ranked.filter((checkout) => checkout.isMain);
const pool = mains.length > 0 ? mains : ranked;
return pool.find((checkout) => namedLikeProject(checkout.root, checkout.project.path)) ?? pool[0];
}

/** Whether a checkout's folder carries its project's name (the last segment of the web path), in any case. */
export function namedLikeProject(root: string, web: string): boolean {
const name = web.replace(/\/+$/, "").split("/").pop() ?? "";
return basename(root).toLowerCase() === name.toLowerCase();
}
Loading
Loading