дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
fix(github): find pull requests in every git repository of the project (#1084)
A project folder that holds several git repositories without being one itself showed "No GitHub repository" in Pull Requests: every gh command ran in the project folder. The GitHub remotes now come from each of the project's git repositories, and each remote remembers its repository. gh commands take the repository to run in (the primary one when none is given), and checkout, diff, worktree, merge, push, suggestions and new pull requests use the connected repository. Fixes #812
Цей коміт міститься в:
1 джерело
5e7339e4c2
коміт
63801ba249
18 files changed
+278
-102
No files matched your search
@@ -90,7 +90,7 @@ describe("client", () => {
|
||||
.mockResolvedValue({ status: "ok", data: '{"data":{"repository":null}}' } as never);
|
||||
const { graphql } = await import("./client");
|
||||
await graphql(Q.findRepository, { repoOwner: "o", repoName: "r", cursor: undefined });
|
||||
const req = ghApi.mock.calls[0]![0];
|
||||
const req = ghApi.mock.calls[0]![1];
|
||||
expect(req).toMatchObject({ method: "POST", endpoint: "graphql", paginate: false });
|
||||
const body = JSON.parse(req.body!) as { query: string; variables: Record<string, unknown> };
|
||||
expect(body.variables).toEqual({ repoOwner: "o", repoName: "r" });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* GitHub API access through gh (`commands.ghApi`, which runs `gh api` in the project root
|
||||
* with gh's authentication), like IntelliJ's `GithubApiRequestExecutor`:
|
||||
* GitHub API access through gh (`commands.ghApi`, which runs `gh api` in the connected local
|
||||
* repository with gh's authentication), like IntelliJ's `GithubApiRequestExecutor`:
|
||||
*
|
||||
* const data = await graphql<{ repository: … }>(findRepository, { repoOwner, repoName });
|
||||
* const page = await rest<GHLabelDto[]>("GET", `${repoPath()}/labels?per_page=100`);
|
||||
@@ -39,7 +39,9 @@ export function graphqlData<T>(text: string): T {
|
||||
/** Runs a document from `queries.ts` with `variables` (undefined values are dropped). */
|
||||
export async function graphql<T>(doc: string, variables: Record<string, unknown> = {}): Promise<T> {
|
||||
const body = JSON.stringify({ query: withFragments(doc), variables: definedOnly(variables) });
|
||||
const text = await call(commands.ghApi({ method: "POST", endpoint: "graphql", body, paginate: false }));
|
||||
const text = await call(
|
||||
commands.ghApi(current?.root ?? null, { method: "POST", endpoint: "graphql", body, paginate: false }),
|
||||
);
|
||||
return graphqlData<T>(text);
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ export async function rest<T>(
|
||||
opts: { paginate?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const text = await call(
|
||||
commands.ghApi({
|
||||
commands.ghApi(current?.root ?? null, {
|
||||
method,
|
||||
endpoint,
|
||||
body: body === undefined ? null : JSON.stringify(body),
|
||||
@@ -80,10 +82,12 @@ export function parseRest<T>(text: string, paginated: boolean): T {
|
||||
// ---------------------------------------------------------------------------
|
||||
// The connected repository
|
||||
|
||||
let current: (GHRepositoryCoordinates & { id: string | null }) | null = null;
|
||||
let current: (GHRepositoryCoordinates & { id: string | null; root?: string | null }) | null = null;
|
||||
|
||||
/** Set by `data/repository.ts` when the tool window connects (null: disconnected). */
|
||||
export function setCurrentRepository(repo: (GHRepositoryCoordinates & { id: string | null }) | null) {
|
||||
/** Set by `data/repository.ts` when the tool window connects (null: disconnected); `root`: its local repository. */
|
||||
export function setCurrentRepository(
|
||||
repo: (GHRepositoryCoordinates & { id: string | null; root?: string | null }) | null,
|
||||
) {
|
||||
current = repo;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { HighlightedText, Icon, List, Popup, SearchField, Select, T, fuzzyMatch, listNavigate } from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { nameWithOwner, sameRepository, useGhRepository, type GHRemote } from "../data/repository";
|
||||
import { NO_REMOTES, nameWithOwner, sameRepository, useGhRepository, type GHRemote } from "../data/repository";
|
||||
import { ChooserPopup } from "../ui/ChooserPopup";
|
||||
import {
|
||||
baseText,
|
||||
@@ -91,7 +91,7 @@ type HeadRow = { section: string } | { branch: HeadBranch; text: string; match:
|
||||
/** The head: the repository (several GitHub remotes only), then its branches, local first. */
|
||||
function HeadPopup(props: { anchor: HTMLElement; initialRepo: GHRemote; onClose: () => void }) {
|
||||
const tx = useTx();
|
||||
const remotes = useGhRepository((s) => s.remotes);
|
||||
const remotes = useGhRepository((s) => s.connection?.remotes ?? NO_REMOTES);
|
||||
const branches = useCreatePr((s) => s.branches);
|
||||
const current = useCreatePr((s) => s.direction?.headBranch ?? null);
|
||||
const [repo, setRepo] = useState(props.initialRepo);
|
||||
|
||||
@@ -23,7 +23,13 @@ import * as api from "../api/requests";
|
||||
import type { GHLabel, GHPullRequestRequestedReviewer, GHUser } from "../api/types";
|
||||
import { isTeam } from "../data/model";
|
||||
import { createPullRequest } from "../data/operations";
|
||||
import { loadPullRequestTemplate, requireConnection, sameRepository, useGhRepository } from "../data/repository";
|
||||
import {
|
||||
connectedRemotes,
|
||||
connectedRoot,
|
||||
loadPullRequestTemplate,
|
||||
requireConnection,
|
||||
sameRepository,
|
||||
} from "../data/repository";
|
||||
import type { GHRemote } from "../data/repository";
|
||||
import { refreshCurrentBranchPullRequest } from "../reviewMode/state";
|
||||
import { openPullRequest } from "../toolwindow/open";
|
||||
@@ -121,18 +127,18 @@ let changesSeq = 0;
|
||||
|
||||
/** The repository's root, which the preview's repository-relative paths are relative to. */
|
||||
function repoRoot(): string | null {
|
||||
return useVcs.getState().roots[0] ?? useProject.getState().project?.root ?? null;
|
||||
return connectedRoot() ?? useVcs.getState().roots[0] ?? useProject.getState().project?.root ?? null;
|
||||
}
|
||||
|
||||
const ok = <T>(value: T): Loadable<T> => ({ state: "ok", value });
|
||||
const failed = (e: unknown): Loadable<never> => ({ state: "error", error: errorMessage(e) });
|
||||
|
||||
const remoteNames = () => useGhRepository.getState().remotes.map((r) => r.remote);
|
||||
const remoteNames = () => connectedRemotes().map((r) => r.remote);
|
||||
|
||||
/** The connected repository's GitHub remote (the base). */
|
||||
function baseRemote(): GHRemote {
|
||||
const { coordinates } = requireConnection();
|
||||
const remotes = useGhRepository.getState().remotes;
|
||||
const remotes = connectedRemotes();
|
||||
return remotes.find((r) => sameRepository(r, coordinates)) ?? { ...coordinates, remote: "origin", url: "" };
|
||||
}
|
||||
|
||||
@@ -143,13 +149,13 @@ export async function initCreate(): Promise<void> {
|
||||
try {
|
||||
const { repository } = requireConnection();
|
||||
const [branches, info] = await Promise.all([
|
||||
call(commands.vcsBranches(null)),
|
||||
call(commands.vcsBranchInfo(null)).catch(() => null),
|
||||
call(commands.vcsBranches(connectedRoot())),
|
||||
call(commands.vcsBranchInfo(connectedRoot())).catch(() => null),
|
||||
]);
|
||||
if (e !== generation) return;
|
||||
const direction = defaultDirection({
|
||||
baseRepo: baseRemote(),
|
||||
remotes: useGhRepository.getState().remotes,
|
||||
remotes: connectedRemotes(),
|
||||
branches,
|
||||
currentBranch: info?.head ?? null,
|
||||
defaultBranch: repository.defaultBranchRef?.name,
|
||||
@@ -190,7 +196,7 @@ export function setHead(repo: GHRemote, branch: HeadBranch) {
|
||||
|
||||
/** The Refresh of a failed check: reads the branches again and checks them. */
|
||||
export async function refreshBranchesState(): Promise<void> {
|
||||
const branches = await call(commands.vcsBranches(null)).catch(() => null);
|
||||
const branches = await call(commands.vcsBranches(connectedRoot())).catch(() => null);
|
||||
if (branches) useCreatePr.setState({ branches });
|
||||
reloadBranchesState();
|
||||
}
|
||||
@@ -201,7 +207,7 @@ const tip = (branches: readonly Branch[], name: string) => branches.find((b) =>
|
||||
export async function onRepositoryChanged(): Promise<void> {
|
||||
const s = useCreatePr.getState();
|
||||
if (!s.ready || !s.direction) return;
|
||||
const branches = await call(commands.vcsBranches(null)).catch(() => null);
|
||||
const branches = await call(commands.vcsBranches(connectedRoot())).catch(() => null);
|
||||
if (!branches) return;
|
||||
const { baseBranch, headBranch } = useCreatePr.getState().direction ?? {};
|
||||
const before = useCreatePr.getState().branches;
|
||||
@@ -245,7 +251,7 @@ function reloadBranchesState() {
|
||||
: Promise.resolve(null);
|
||||
existing.catch(() => undefined);
|
||||
const commits = call(
|
||||
commands.vcsLog(null, {
|
||||
commands.vcsLog(connectedRoot(), {
|
||||
branch: null,
|
||||
path: null,
|
||||
author: null,
|
||||
@@ -320,14 +326,14 @@ async function loadChanges() {
|
||||
let changes: CreateChange[];
|
||||
const commit = s.selectedCommit ? list.find((c) => c.id === s.selectedCommit) : null;
|
||||
if (commit) {
|
||||
const files = await call(commands.vcsCommitFiles(null, commit.id));
|
||||
const files = await call(commands.vcsCommitFiles(connectedRoot(), commit.id));
|
||||
changes = files.map((f) => ({ ...f, diff: commitFileDiffData({ ...commit, repo: root }, f) }));
|
||||
} else {
|
||||
const head = list[0]!.id;
|
||||
const baseName = remoteBranchFullName(d.baseBranch);
|
||||
const mergeBase = await call(commands.vcsMergeBase(null, baseName, head));
|
||||
const mergeBase = await call(commands.vcsMergeBase(connectedRoot(), baseName, head));
|
||||
if (!mergeBase) throw new Error(tx`Unrelated branches ${baseName} ${headBranchName(d.headBranch)}`);
|
||||
const files = await call(commands.vcsDiffRevisions(null, mergeBase, head));
|
||||
const files = await call(commands.vcsDiffRevisions(connectedRoot(), mergeBase, head));
|
||||
const baseSide = { rev: mergeBase, label: `${d.baseBranch.name} (${mergeBase.slice(0, 8)})` };
|
||||
const headSide = { rev: head, label: `${headBranchName(d.headBranch)} (${head.slice(0, 8)})` };
|
||||
changes = files.map((f) => ({ ...f, diff: rangeDiffData(f, root, baseSide, headSide) }));
|
||||
@@ -384,7 +390,7 @@ async function pushHead(head: Extract<HeadBranch, { kind: "local" }>, headRepo:
|
||||
remote: target.remote,
|
||||
},
|
||||
`${head.name} → ${remoteBranchFullName(target)}`,
|
||||
[null],
|
||||
[connectedRoot()],
|
||||
);
|
||||
if (!pushed) {
|
||||
const error = lastRemoteFailure();
|
||||
@@ -392,7 +398,7 @@ async function pushHead(head: Extract<HeadBranch, { kind: "local" }>, headRepo:
|
||||
if (!error) return null;
|
||||
throw new Error(tx`Push failed: ${error}`);
|
||||
}
|
||||
const after = await call(commands.vcsBranches(null)).catch(() => null);
|
||||
const after = await call(commands.vcsBranches(connectedRoot())).catch(() => null);
|
||||
if (after) useCreatePr.setState({ branches: after });
|
||||
}
|
||||
return target.name;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { notifications } from "@/workbench/contributions";
|
||||
import { errorMessage } from "../api/client";
|
||||
import type { GHCommitFile, GHPullRequest } from "../api/types";
|
||||
import { loadPullRequestData } from "./pullRequest";
|
||||
import { nameWithOwner, requireConnection } from "./repository";
|
||||
import { connectedRoot, nameWithOwner, requireConnection } from "./repository";
|
||||
|
||||
/** The running operation (checkout, fetch for a diff) with its last output line. */
|
||||
export const useGhBusy = create<{ busy: { title: string; line: string; request: RequestId } | null }>(() => ({
|
||||
@@ -67,7 +67,7 @@ export function cancelBusy() {
|
||||
export async function checkoutPullRequest(number: number): Promise<boolean> {
|
||||
// The command resolves with nothing (`null`), which `withBusy` reports as a failure.
|
||||
const done = await withBusy(`Checking out #${number}…`, async (r, out) => {
|
||||
await call(commands.ghPrCheckout(r, number, out));
|
||||
await call(commands.ghPrCheckout(connectedRoot(), r, number, out));
|
||||
return true;
|
||||
});
|
||||
if (done === null) return false;
|
||||
@@ -87,9 +87,10 @@ export async function prepareDiff(pr: GHPullRequest): Promise<PrDiffRevisions |
|
||||
const key = `${pr.number}:${pr.headRefOid}:${pr.baseRefOid}`;
|
||||
const hit = diffCache.get(key);
|
||||
if (hit) return hit;
|
||||
const repo = nameWithOwner(requireConnection().coordinates);
|
||||
const { coordinates, root } = requireConnection();
|
||||
const repo = nameWithOwner(coordinates);
|
||||
const revs = await withBusy(`Fetching #${pr.number}…`, (r, out) =>
|
||||
call(commands.ghPrPrepareDiff(r, pr.number, pr.headRefOid, pr.baseRefOid, pr.baseRefName, repo, out)),
|
||||
call(commands.ghPrPrepareDiff(root, r, pr.number, pr.headRefOid, pr.baseRefOid, pr.baseRefName, repo, out)),
|
||||
);
|
||||
if (revs) diffCache.set(key, revs);
|
||||
return revs;
|
||||
@@ -185,7 +186,7 @@ export async function openInWorktree(number: number): Promise<void> {
|
||||
if (!revs) return;
|
||||
const path = `${String(revs.root).replace(/[\\/]+$/, "")}_PR_${number}`;
|
||||
const added = await withBusy(`Creating worktree for #${number}…`, async () => {
|
||||
await call(commands.vcsAddWorktree(null, path, pr.headRefOid, null));
|
||||
await call(commands.vcsAddWorktree(revs.root, path, pr.headRefOid, null));
|
||||
return true;
|
||||
});
|
||||
if (added) await executeAction("file.openInNewWindow", path);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ghApi = vi.fn();
|
||||
const ghAvailability = vi.fn();
|
||||
const ok = (data: unknown) => Promise.resolve({ status: "ok", data });
|
||||
|
||||
vi.mock("@/platform/ipc", () => ({
|
||||
isTauri: () => true,
|
||||
call: async (p: Promise<{ status: string; data?: unknown; error?: unknown }>) => {
|
||||
const r = await p;
|
||||
if (r.status !== "ok") throw r.error;
|
||||
return r.data;
|
||||
},
|
||||
commands: {
|
||||
vcsRoots: () => ok([{ root: "/w/android" }, { root: "/w/ios" }, { root: "/w/tools" }]),
|
||||
vcsRemotes: (root: string | null) =>
|
||||
ok(
|
||||
root === "/w/android"
|
||||
? [{ name: "origin", url: "[email protected]:org/android.git" }]
|
||||
: root === "/w/ios"
|
||||
? [{ name: "origin", url: "https://github.com/org/ios.git" }]
|
||||
: [{ name: "origin", url: "https://gitlab.com/org/tools.git" }],
|
||||
),
|
||||
ghAvailability: (repo: string | null) => ghAvailability(repo),
|
||||
ghApi: (repo: string | null, req: { endpoint: string }) => ghApi(repo, req),
|
||||
},
|
||||
}));
|
||||
|
||||
import { rest } from "../api/client";
|
||||
import { connectedRoot, connectedRemotes, connect, refreshConnection, useGhRepository } from "./repository";
|
||||
|
||||
afterEach(() => {
|
||||
ghApi.mockReset();
|
||||
ghAvailability.mockReset();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("a folder holding several git repositories", () => {
|
||||
it("offers the GitHub repositories of each and runs gh in the connected one", async () => {
|
||||
ghAvailability.mockReturnValue(
|
||||
ok({ kind: "ready", repo: { nameWithOwner: "org/android", url: "https://github.com/org/android" }, login: "me" }),
|
||||
);
|
||||
ghApi.mockImplementation((_repo: string | null, req: { endpoint: string }) =>
|
||||
ok(
|
||||
req.endpoint === "user"
|
||||
? JSON.stringify({ id: 1, node_id: "U1", login: "me", avatar_url: "", html_url: "" })
|
||||
: JSON.stringify({ data: { repository: { id: "R1", owner: { login: "org" }, name: "x" } } }),
|
||||
),
|
||||
);
|
||||
await refreshConnection();
|
||||
// gh is asked in a repository, not in the folder that holds them.
|
||||
expect(ghAvailability).toHaveBeenCalledWith("/w/android");
|
||||
const s = useGhRepository.getState();
|
||||
expect(s.remotes.map((r) => [r.owner, r.name, r.root])).toEqual([
|
||||
["org", "android", "/w/android"],
|
||||
["org", "ios", "/w/ios"],
|
||||
]);
|
||||
// Several repositories: the selector is shown.
|
||||
expect(s.connection).toBeNull();
|
||||
|
||||
expect(await connect(s.remotes[1]!)).toBe(true);
|
||||
expect(connectedRoot()).toBe("/w/ios");
|
||||
expect(connectedRemotes().map((r) => r.name)).toEqual(["ios"]);
|
||||
await rest("GET", "user");
|
||||
expect(ghApi).toHaveBeenLastCalledWith("/w/ios", expect.objectContaining({ endpoint: "user" }));
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,13 @@
|
||||
* mentionable users.
|
||||
*
|
||||
* Auto-connect: a project with one GitHub remote connects at once; with several, the
|
||||
* repository chosen last (saved per project) is used, else the selector is shown.
|
||||
* repository chosen last (saved per project) is used, else the selector is shown. The
|
||||
* remotes come from every git repository of the project (a folder holding several
|
||||
* repositories offers each one's), and gh runs in the repository of the connected remote.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import type { GhAvailability } from "@/platform/ipc";
|
||||
import type { GhAvailability, Remote } from "@/platform/ipc";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { errorMessage, setCurrentRepository } from "../api/client";
|
||||
@@ -34,12 +36,18 @@ export interface GHRemote extends GHRepositoryCoordinates {
|
||||
/** Git remote name (`origin`). */
|
||||
remote: string;
|
||||
url: string;
|
||||
/** Root of the project's git repository the remote belongs to (null or absent: the primary one). */
|
||||
root?: string | null;
|
||||
}
|
||||
|
||||
export interface GHConnection {
|
||||
coordinates: GHRepositoryCoordinates;
|
||||
repository: GHRepository;
|
||||
currentUser: GHUser;
|
||||
/** The local repository checkouts, diffs and new pull requests use (null: the primary one). */
|
||||
root: string | null;
|
||||
/** That repository's GitHub remotes. */
|
||||
remotes: GHRemote[];
|
||||
}
|
||||
|
||||
interface RepositoryState {
|
||||
@@ -96,22 +104,51 @@ export const sameRepository = (a: GHRepositoryCoordinates | null | undefined, b:
|
||||
|
||||
export const nameWithOwner = (r: GHRepositoryCoordinates) => `${r.owner}/${r.name}`;
|
||||
|
||||
/** The GitHub remotes among `remotes`, one per repository. */
|
||||
export function githubRemotes(remotes: ReadonlyArray<{ name: string; url: string }>): GHRemote[] {
|
||||
/** The GitHub remotes among `remotes` of the repository at `root`, one per GitHub repository. */
|
||||
export function githubRemotes(
|
||||
remotes: ReadonlyArray<{ name: string; url: string }>,
|
||||
root: string | null = null,
|
||||
): GHRemote[] {
|
||||
const out: GHRemote[] = [];
|
||||
for (const r of remotes) {
|
||||
const c = parseGitHubRemote(r.url);
|
||||
if (c && !out.some((o) => sameRepository(o, c))) out.push({ ...c, remote: r.name, url: r.url });
|
||||
if (c && !out.some((o) => sameRepository(o, c))) out.push({ ...c, remote: r.name, url: r.url, root });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The GitHub repositories of several local repositories' remotes, each once (the first local repository's). */
|
||||
function uniqueRepositories(remotes: readonly GHRemote[]): GHRemote[] {
|
||||
return remotes.filter((r, i) => remotes.findIndex((o) => sameRepository(o, r)) === i);
|
||||
}
|
||||
|
||||
/** The connected local repository's GitHub remotes (none while disconnected). */
|
||||
export const NO_REMOTES: readonly GHRemote[] = [];
|
||||
export const connectedRemotes = (): readonly GHRemote[] => useGhRepository.getState().connection?.remotes ?? NO_REMOTES;
|
||||
|
||||
/** The local repository of the connection, for gh and git commands (null: the primary one). */
|
||||
export const connectedRoot = (): string | null => useGhRepository.getState().connection?.root ?? null;
|
||||
|
||||
function parseNameWithOwner(s: string | null | undefined): GHRepositoryCoordinates | null {
|
||||
const m = s ? /^([^/\s]+)\/([^/\s]+)$/.exec(s) : null;
|
||||
return m ? { owner: m[1]!, name: m[2]! } : null;
|
||||
}
|
||||
|
||||
let checkSeq = 0;
|
||||
/** GitHub remotes of every local repository (a GitHub repository can be a remote of several). */
|
||||
let localRemotes: GHRemote[] = [];
|
||||
|
||||
async function projectGitHubRemotes(): Promise<{ remotes: GHRemote[]; all: Map<string | null, Remote[]> }> {
|
||||
const roots = await call(commands.vcsRoots()).catch(() => []);
|
||||
const dirs: Array<string | null> = roots.length ? roots.map((r) => String(r.root)) : [null];
|
||||
const lists = await Promise.all(
|
||||
dirs.map(async (root) => ({ root, remotes: await call(commands.vcsRemotes(root)).catch(() => [] as Remote[]) })),
|
||||
);
|
||||
return {
|
||||
remotes: lists.flatMap((l) => githubRemotes(l.remotes, l.root)),
|
||||
all: new Map(lists.map((l) => [l.root, l.remotes])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks gh and the remotes, then connects as IntelliJ does on start: the only GitHub
|
||||
@@ -122,36 +159,42 @@ export async function refreshConnection(): Promise<void> {
|
||||
const seq = ++checkSeq;
|
||||
useGhRepository.setState({ checking: true });
|
||||
let availability: GhAvailability;
|
||||
let remotes: GHRemote[] = [];
|
||||
let allRemotes: Array<{ name: string; url: string }> = [];
|
||||
const found = await projectGitHubRemotes();
|
||||
if (seq !== checkSeq) return;
|
||||
const current = useGhRepository.getState().connection?.coordinates ?? null;
|
||||
const saved = parseNameWithOwner(loadSelection(projectRoot()));
|
||||
// gh resolves its repository in a local one; ask in the one likely to be connected.
|
||||
const probe =
|
||||
(
|
||||
found.remotes.find((r) => sameRepository(r, current)) ??
|
||||
found.remotes.find((r) => sameRepository(r, saved)) ??
|
||||
found.remotes[0]
|
||||
)?.root ?? null;
|
||||
try {
|
||||
const [a, r] = await Promise.all([
|
||||
call(commands.ghAvailability()),
|
||||
call(commands.vcsRemotes(null)).catch(() => []),
|
||||
]);
|
||||
availability = a;
|
||||
allRemotes = r;
|
||||
remotes = githubRemotes(r);
|
||||
availability = await call(commands.ghAvailability(probe));
|
||||
} catch (e) {
|
||||
availability = { kind: "error", message: errorMessage(e) };
|
||||
}
|
||||
if (seq !== checkSeq) return;
|
||||
localRemotes = found.remotes;
|
||||
const remotes = uniqueRepositories(localRemotes);
|
||||
const defaultRemote = availability.kind === "ready" ? parseNameWithOwner(availability.repo.nameWithOwner) : null;
|
||||
if (defaultRemote && !remotes.some((r) => sameRepository(r, defaultRemote))) {
|
||||
const own = remoteOfRepository(allRemotes, defaultRemote);
|
||||
remotes.unshift({
|
||||
const own = remoteOfRepository(found.all.get(probe) ?? [], defaultRemote);
|
||||
const remote = {
|
||||
...defaultRemote,
|
||||
remote: own?.name ?? "",
|
||||
url: own?.url ?? (availability.kind === "ready" ? availability.repo.url : ""),
|
||||
});
|
||||
root: probe,
|
||||
};
|
||||
remotes.unshift(remote);
|
||||
localRemotes = [remote, ...localRemotes];
|
||||
}
|
||||
useGhRepository.setState({ availability, remotes, defaultRemote, checking: false });
|
||||
if (availability.kind !== "ready") {
|
||||
disconnect({ forget: false });
|
||||
return;
|
||||
}
|
||||
const current = useGhRepository.getState().connection?.coordinates ?? null;
|
||||
const saved = parseNameWithOwner(loadSelection(projectRoot()));
|
||||
const target =
|
||||
remotes.length === 1
|
||||
? remotes[0]!
|
||||
@@ -163,20 +206,28 @@ export async function refreshConnection(): Promise<void> {
|
||||
|
||||
let connectSeq = 0;
|
||||
|
||||
/** Connects to `repo` (the selector's "View Pull Requests"), remembering the choice. */
|
||||
export async function connect(repo: GHRepositoryCoordinates, opts: { remember?: boolean } = {}): Promise<boolean> {
|
||||
/**
|
||||
* Connects to `repo` (the selector's "View Pull Requests"), remembering the choice; gh and git
|
||||
* then run in `repo.root`, else in the first local repository with it as a remote.
|
||||
*/
|
||||
export async function connect(
|
||||
repo: GHRepositoryCoordinates & { root?: string | null },
|
||||
opts: { remember?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const seq = ++connectSeq;
|
||||
const coordinates = { owner: repo.owner, name: repo.name };
|
||||
const root = repo.root !== undefined ? repo.root : (localRemotes.find((r) => sameRepository(r, repo))?.root ?? null);
|
||||
const remotes = localRemotes.filter((r) => (r.root ?? null) === root);
|
||||
useGhRepository.setState({ connecting: true, connectionError: null });
|
||||
try {
|
||||
const [repository, currentUser] = await Promise.all([api.findRepository(coordinates), api.currentUser()]);
|
||||
if (seq !== connectSeq) return false;
|
||||
if (!repository) throw new Error(`Repository ${nameWithOwner(coordinates)} not found`);
|
||||
const same = sameRepository(useGhRepository.getState().connection?.coordinates, coordinates);
|
||||
if (!same) resetRepositoryData();
|
||||
setCurrentRepository({ ...coordinates, id: repository.id });
|
||||
const previous = useGhRepository.getState().connection;
|
||||
if (!sameRepository(previous?.coordinates, coordinates) || previous?.root !== root) resetRepositoryData();
|
||||
setCurrentRepository({ ...coordinates, id: repository.id, root });
|
||||
useGhRepository.setState((s) => ({
|
||||
connection: { coordinates, repository, currentUser },
|
||||
connection: { coordinates, repository, currentUser, root, remotes },
|
||||
connecting: false,
|
||||
revision: s.revision + 1,
|
||||
}));
|
||||
@@ -237,7 +288,7 @@ export function isCurrentUser(user: { id?: string; login?: string } | null | und
|
||||
export async function reloadRepositoryData(): Promise<void> {
|
||||
const c = useGhRepository.getState().connection;
|
||||
resetRepositoryData();
|
||||
if (c) await connect(c.coordinates);
|
||||
if (c) await connect({ ...c.coordinates, root: c.root });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Spinner, T, showContextMenu, tooltipAttrs, type MenuItem } from "@/ui";
|
||||
import { hasPermission, reviewersStates } from "../data/model";
|
||||
import { openInBrowser } from "../data/local";
|
||||
import { reloadPullRequestData, usePullRequestData } from "../data/pullRequest";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { NO_REMOTES, useGhRepository } from "../data/repository";
|
||||
import { provideGithubHandler } from "../toolwindow/handlers";
|
||||
import { openTimeline } from "../toolwindow/timelineInput";
|
||||
import { StateTag } from "../ui/chips";
|
||||
@@ -85,7 +85,7 @@ function Details({ number, submitRef }: { number: number; submitRef: RefObject<H
|
||||
const viewedState = usePullRequestData(number, "viewedState").value;
|
||||
const threads = usePullRequestData(number, "threads").value;
|
||||
const permission = useGhRepository((s) => s.connection?.repository.viewerPermission);
|
||||
const remotes = useGhRepository((s) => s.remotes);
|
||||
const remotes = useGhRepository((s) => s.connection?.remotes ?? NO_REMOTES);
|
||||
const branch = useVcs((s) => s.branch);
|
||||
const busy = useFlowBusy((s) => s.busy[number] !== undefined);
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ function pr(patch: Partial<GHPullRequest> = {}): GHPullRequest {
|
||||
|
||||
beforeEach(() => {
|
||||
useGhRepository.setState({
|
||||
connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me },
|
||||
connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me, root: null, remotes: [] },
|
||||
});
|
||||
setPullRequestData(7, "pendingReview", () => null);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { mergeCommitMessage, splitCommitMessage, type ReviewerWithState } from "
|
||||
import { checkoutPullRequest, fileDiffData, prepareDiff } from "../data/local";
|
||||
import * as ops from "../data/operations";
|
||||
import { loadPullRequestData } from "../data/pullRequest";
|
||||
import { connectedRoot } from "../data/repository";
|
||||
import { mergeCommitMessageDialog } from "../ui/dialogs";
|
||||
import { reviewersDelta, type MergeMethod } from "./model";
|
||||
|
||||
@@ -97,7 +98,7 @@ export async function showInGitLog(pr: GHPullRequest, headRemote: string | null)
|
||||
const revs = await prepareDiff(pr);
|
||||
if (!revs) return;
|
||||
const tracked = headRemote ? `${headRemote}/${pr.headRefName}` : null;
|
||||
const branches = tracked ? await call(commands.vcsBranches(null)).catch(() => []) : [];
|
||||
const branches = tracked ? await call(commands.vcsBranches(connectedRoot())).catch(() => []) : [];
|
||||
const ref = tracked && branches.some((b) => b.remote && b.name === tracked) ? tracked : revs.head;
|
||||
await executeAction("vcs.showLog", { branch: ref });
|
||||
}
|
||||
@@ -121,7 +122,7 @@ export function resolveConflictsLocally(
|
||||
// Fetches the base branch as well (updating `<remote>/<base>`).
|
||||
if (!(await prepareDiff(pr))) return;
|
||||
if (!isCheckedOut && !(await checkoutPullRequest(number))) return;
|
||||
await mergeIntoCurrent(`${target.baseRemote}/${target.baseRefName}`);
|
||||
await mergeIntoCurrent(`${target.baseRemote}/${target.baseRefName}`, connectedRoot());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,7 +130,7 @@ export function resolveConflictsLocally(
|
||||
export async function isInCurrentHistory(oid: string): Promise<boolean> {
|
||||
try {
|
||||
const commits = await call(
|
||||
commands.vcsLog(null, {
|
||||
commands.vcsLog(connectedRoot(), {
|
||||
branch: null,
|
||||
path: null,
|
||||
author: null,
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
...real.commands,
|
||||
fsProbe: () => Promise.resolve({ status: "error", error: "none" }),
|
||||
vcsFileAt: (_path: string, rev: string) => Promise.resolve({ status: "ok", data: texts[rev] ?? "" }),
|
||||
ghApi: (req: unknown) => ghApi(req),
|
||||
ghApi: (_repo: unknown, req: unknown) => ghApi(req),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -158,6 +158,8 @@ beforeEach(() => {
|
||||
coordinates: { owner: "o", name: "r" },
|
||||
repository: { id: "R1", viewerPermission: "READ" } as GHRepository,
|
||||
currentUser: me,
|
||||
root: null,
|
||||
remotes: [],
|
||||
},
|
||||
});
|
||||
setPullRequestData(
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("ListPanel", () => {
|
||||
localStorage.clear();
|
||||
useProject.setState({ project: { root: "/p", name: "p" } as never });
|
||||
queries = [];
|
||||
vi.spyOn(ipc.commands, "ghApi").mockImplementation(async (req) => {
|
||||
vi.spyOn(ipc.commands, "ghApi").mockImplementation(async (_repo, req) => {
|
||||
const body = JSON.parse(req.body!) as { variables: { query: string } };
|
||||
queries.push(body.variables.query);
|
||||
const page = pages.shift() ?? [];
|
||||
@@ -205,7 +205,7 @@ describe("ListPanel", () => {
|
||||
useGhRepository.setState({
|
||||
availability: { kind: "ready", repo: {} as never, login: "me" },
|
||||
remotes: [{ owner: "org", name: "app", remote: "origin", url: "" }],
|
||||
connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me },
|
||||
connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me, root: null, remotes: [] },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
ghApi: (req: unknown) => ghApi(req),
|
||||
ghApi: (_repo: unknown, req: unknown) => ghApi(req),
|
||||
vcsFileAt: () => ok(HEAD),
|
||||
vcsDiffHunks: (a: string, b: string) => Promise.resolve(diffHunks(a, b)),
|
||||
vcsConfigGet: (_repo: string | null, key: string) => ok(config[key] ?? null),
|
||||
@@ -295,6 +295,8 @@ beforeEach(() => {
|
||||
coordinates: { owner: "o", name: "r" },
|
||||
repository: { id: "R1", viewerPermission: "READ" } as GHRepository,
|
||||
currentUser: me,
|
||||
root: null,
|
||||
remotes: [],
|
||||
},
|
||||
});
|
||||
useReviewModeSettings.setState({ enabled: true, threads: "unresolved" });
|
||||
|
||||
@@ -13,11 +13,12 @@ import type { GHPullRequest, GHPullRequestReviewThread } from "../../api/types";
|
||||
import { withBusy } from "../../data/local";
|
||||
import { resolveThread } from "../../data/operations";
|
||||
import { usePullRequestData } from "../../data/pullRequest";
|
||||
import { connectedRoot } from "../../data/repository";
|
||||
import { buildSuggestionPatch, type SuggestionTarget } from "../model";
|
||||
|
||||
/** The connected pull requests' local repository (the project's primary one when unknown). */
|
||||
async function repositoryRoot(): Promise<string> {
|
||||
const roots = await call(commands.vcsRoots());
|
||||
const root = roots[0]?.root;
|
||||
const root = connectedRoot() ?? (await call(commands.vcsRoots()))[0]?.root;
|
||||
if (!root) throw new Error("The project is not a Git repository");
|
||||
return String(root).replace(/[\\/]+$/, "");
|
||||
}
|
||||
@@ -36,7 +37,7 @@ export async function applySuggestion(
|
||||
const abs = `${root}/${target.path}`;
|
||||
const text = await call(commands.fsReadText(abs));
|
||||
const { patch, start, lines } = buildSuggestionPatch(text, target, suggestion);
|
||||
const outcome = await call(commands.vcsApplyPatch(null, patch));
|
||||
const outcome = await call(commands.vcsApplyPatch(connectedRoot(), patch));
|
||||
const files = outcome.conflicts.join(", ");
|
||||
if (files) throw new Error(tx`Conflicts in ${files}`);
|
||||
if (commitMessage) {
|
||||
@@ -48,7 +49,7 @@ export async function applySuggestion(
|
||||
.map(({ start, lines, oldStart, oldLines }) => ({ start, lines, oldStart, oldLines }));
|
||||
if (!hunks.length) throw new Error(tx`${target.path}: nothing to commit`);
|
||||
await call(
|
||||
commands.vcsCommit(null, commitMessage, {
|
||||
commands.vcsCommit(connectedRoot(), commitMessage, {
|
||||
amend: false,
|
||||
signOff: false,
|
||||
author: null,
|
||||
@@ -75,7 +76,7 @@ function branchInfo(version: number) {
|
||||
branchCache = {
|
||||
at: Date.now(),
|
||||
version,
|
||||
value: call(commands.vcsBranchInfo(null)).then(
|
||||
value: call(commands.vcsBranchInfo(connectedRoot())).then(
|
||||
(b) => ({ head: b.head, upstream: b.upstream, commit: b.headCommit }),
|
||||
() => ({ head: null, upstream: null, commit: null }),
|
||||
),
|
||||
|
||||
@@ -150,7 +150,13 @@ describe("tool window", () => {
|
||||
useGhRepository.setState({
|
||||
availability: { kind: "ready", repo: {} as never, login: "me" },
|
||||
remotes: [{ owner: "org", name: "app", remote: "origin", url: "" }],
|
||||
connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me },
|
||||
connection: {
|
||||
coordinates: { owner: "org", name: "app" },
|
||||
repository,
|
||||
currentUser: me,
|
||||
root: null,
|
||||
remotes: [],
|
||||
},
|
||||
});
|
||||
render(
|
||||
<>
|
||||
@@ -161,7 +167,7 @@ describe("tool window", () => {
|
||||
});
|
||||
expect(screen.getByRole("tab", { name: /app/ })).toBeTruthy();
|
||||
expect(screen.getByRole("textbox", { name: "Search pull requests" })).toBeTruthy();
|
||||
const body = JSON.parse(ghApi.mock.calls[0]![0].body!) as { variables: { query: string; pageSize: number } };
|
||||
const body = JSON.parse(ghApi.mock.calls[0]![1].body!) as { variables: { query: string; pageSize: number } };
|
||||
// First run: every open pull request.
|
||||
expect(body.variables).toMatchObject({ query: "type:pr repo:org/app is:open", pageSize: 50 });
|
||||
await act(async () => openPullRequestTab(4));
|
||||
|
||||
Reference in new issue
Заблокувати користувача