Skip to content
Open
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
65 changes: 65 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
"open",
"--limit",
String(input.limit ?? 1),
...(input.repository ? ["--repo", input.repository] : []),
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
Expand Down Expand Up @@ -1077,6 +1078,70 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
20_000,
);

it.effect(
"status detects fork PRs opened against the conventional upstream remote",
() =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const forkDir = yield* createBareRemote();
const upstreamDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", forkDir]);
yield* runGit(repoDir, ["remote", "add", "upstream", upstreamDir]);
yield* runGit(repoDir, ["checkout", "-b", "feature/upstream-pr"]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/upstream-pr"]);
yield* configureVisibleRemoteUrlWithLocalRewrite(
repoDir,
"origin",
"git@github.com:contributor/t3code.git",
forkDir,
);
yield* configureVisibleRemoteUrlWithLocalRewrite(
repoDir,
"upstream",
"git@github.com:T3Tools/t3code.git",
upstreamDir,
);

const { manager, ghCalls } = yield* makeManager({
ghScenario: {
prListSequence: [
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{
number: 1701,
title: "Fix fork PR detection",
url: "https://github.com/T3Tools/t3code/pull/1701",
baseRefName: "main",
headRefName: "feature/upstream-pr",
state: "OPEN",
updatedAt: "2026-07-11T12:00:00Z",
isCrossRepository: true,
headRepository: { nameWithOwner: "contributor/t3code" },
headRepositoryOwner: { login: "contributor" },
},
]),
],
},
});

const status = yield* manager.status({ cwd: repoDir });

expect(status.pr).toEqual({
number: 1701,
title: "Fix fork PR detection",
url: "https://github.com/T3Tools/t3code/pull/1701",
baseRef: "main",
headRef: "feature/upstream-pr",
state: "open",
});
expect(ghCalls).toContain(
"pr list --head contributor:feature/upstream-pr --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
);
}),
20_000,
);

it.effect(
"status ignores synthetic local branch aliases when the upstream remote name contains slashes",
() =>
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -862,19 +862,22 @@ export const make = Effect.gen(function* () {
const shouldProbeLocalBranchSelector =
headBranchFromUpstream.length === 0 || headBranch === details.branch;

const [remoteRepository, originRepository] = yield* Effect.all(
const [remoteRepository, upstreamRepository, originRepository] = yield* Effect.all(
[
resolveRemoteRepositoryContext(cwd, remoteName),
resolveRemoteRepositoryContext(cwd, "upstream"),
resolveRemoteRepositoryContext(cwd, "origin"),
],
{ concurrency: "unbounded" },
);

const targetRepository =
upstreamRepository.repositoryNameWithOwner !== null ? upstreamRepository : originRepository;
const isCrossRepository =
remoteRepository.repositoryNameWithOwner !== null &&
originRepository.repositoryNameWithOwner !== null
targetRepository.repositoryNameWithOwner !== null
? remoteRepository.repositoryNameWithOwner.toLowerCase() !==
originRepository.repositoryNameWithOwner.toLowerCase()
targetRepository.repositoryNameWithOwner.toLowerCase()
: remoteName !== null &&
remoteName !== "origin" &&
remoteRepository.repositoryNameWithOwner !== null;
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ describe("GitHubCli.layer", () => {
const result = yield* gh.listOpenPullRequests({
cwd: "/repo",
headSelector: "feature/pr-list",
repository: "T3Tools/t3code",
});

assert.deepStrictEqual(result, [
Expand All @@ -205,6 +206,26 @@ describe("GitHubCli.layer", () => {
state: "open",
},
]);
expect(mockRun).toHaveBeenCalledWith({
operation: "GitHubCli.execute",
command: "gh",
args: [
"pr",
"list",
"--head",
"feature/pr-list",
"--state",
"open",
"--limit",
"1",
"--repo",
"T3Tools/t3code",
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
cwd: "/repo",
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
);

Expand Down
26 changes: 24 additions & 2 deletions apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,13 @@ export class GitHubCli extends Context.Service<
readonly cwd: string;
readonly headSelector: string;
readonly limit?: number;
readonly repository?: string;
}) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;

readonly getPullRequest: (input: {
readonly cwd: string;
readonly reference: string;
readonly repository?: string;
}) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;

readonly getRepositoryCloneUrls: (input: {
Expand All @@ -233,16 +235,19 @@ export class GitHubCli extends Context.Service<
readonly headSelector: string;
readonly title: string;
readonly bodyFile: string;
readonly repository?: string;
}) => Effect.Effect<void, GitHubCliError>;

readonly getDefaultBranch: (input: {
readonly cwd: string;
readonly repository?: string;
}) => Effect.Effect<string | null, GitHubCliError>;

readonly checkoutPullRequest: (input: {
readonly cwd: string;
readonly reference: string;
readonly force?: boolean;
readonly repository?: string;
}) => Effect.Effect<void, GitHubCliError>;
}
>()("t3/sourceControl/GitHubCli") {}
Expand Down Expand Up @@ -331,6 +336,7 @@ export const make = Effect.gen(function* () {
"open",
"--limit",
String(input.limit ?? 1),
...(input.repository ? ["--repo", input.repository] : []),
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
Expand Down Expand Up @@ -365,6 +371,7 @@ export const make = Effect.gen(function* () {
"pr",
"view",
input.reference,
...(input.repository ? ["--repo", input.repository] : []),
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
Expand Down Expand Up @@ -433,12 +440,21 @@ export const make = Effect.gen(function* () {
input.title,
"--body-file",
input.bodyFile,
...(input.repository ? ["--repo", input.repository] : []),
],
}).pipe(Effect.asVoid),
getDefaultBranch: (input) =>
execute({
cwd: input.cwd,
args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
args: [
"repo",
"view",
...(input.repository ? [input.repository] : []),
"--json",
"defaultBranchRef",
"--jq",
".defaultBranchRef.name",
],
}).pipe(
Effect.map((value) => {
const trimmed = value.stdout.trim();
Expand All @@ -448,7 +464,13 @@ export const make = Effect.gen(function* () {
checkoutPullRequest: (input) =>
execute({
cwd: input.cwd,
args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
args: [
"pr",
"checkout",
input.reference,
...(input.force ? ["--force"] : []),
...(input.repository ? ["--repo", input.repository] : []),
],
}).pipe(Effect.asVoid),
});
});
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ it.effect("uses gh json listing for non-open change request state queries", () =

const changeRequests = yield* provider.listChangeRequests({
cwd: "/repo",
context: {
provider: { kind: "github", name: "github.com", baseUrl: "https://github.com" },
remoteName: "upstream",
remoteUrl: "git@github.com:T3Tools/t3code.git",
},
headSelector: "feature/merged",
state: "all",
limit: 10,
Expand All @@ -149,6 +154,8 @@ it.effect("uses gh json listing for non-open change request state queries", () =
"all",
"--limit",
"10",
"--repo",
"T3Tools/t3code",
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
]);
Expand All @@ -161,6 +168,31 @@ it.effect("uses gh json listing for non-open change request state queries", () =
}),
);

it.effect("targets the upstream repository when listing open pull requests for a fork", () =>
Effect.gen(function* () {
let repository: string | undefined;
const provider = yield* makeProvider({
listOpenPullRequests: (input) => {
repository = input.repository;
return Effect.succeed([]);
},
});

yield* provider.listChangeRequests({
cwd: "/repo",
context: {
provider: { kind: "github", name: "github.com", baseUrl: "https://github.com" },
remoteName: "upstream",
remoteUrl: "https://github.com/T3Tools/t3code.git",
},
headSelector: "contributor:feature/fork-pr",
state: "open",
});

assert.strictEqual(repository, "T3Tools/t3code");
}),
);

it.effect("treats empty non-open change request listing output as no results", () =>
Effect.gen(function* () {
const provider = yield* makeProvider({
Expand Down
Loading
Loading