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;
Comment on lines +874 to +875

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid org-qualified heads when creating upstream PRs

When origin is an organization-owned fork and a GitHub upstream exists, selecting upstreamRepository here makes isCrossRepository true; later preferredHeadSelector becomes org:branch and runPrStep sends it to gh pr create --head with the new --repo upstream context. I checked the GitHub CLI manual for gh pr create, which says --head supports <user>:<branch> but using an organization as that owner is unsupported, so Create PR now fails for org-owned forks even though user-owned forks work.

Useful? React with 👍 / 👎.

const isCrossRepository =
remoteRepository.repositoryNameWithOwner !== null &&
originRepository.repositoryNameWithOwner !== null
targetRepository.repositoryNameWithOwner !== null
? remoteRepository.repositoryNameWithOwner.toLowerCase() !==
originRepository.repositoryNameWithOwner.toLowerCase()
targetRepository.repositoryNameWithOwner.toLowerCase()
Comment on lines 879 to +880

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use upstream refs for PR range generation

When this comparison reclassifies a fork branch relative to upstream, PR creation is now aimed at the upstream repo, but runPrStep still computes the generated PR title/body via resolveBaseRangeRef(cwd, baseBranch), whose fallback remote is always the primary remote (origin whenever present). In a normal fork where origin/main is stale or missing while upstream/main is the actual base, the generated PR content is based on the wrong commit range and can include unrelated upstream commits; thread the selected target remote into the base range resolution as well.

Useful? React with 👍 / 👎.

Comment on lines 876 to +880

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't rely on owner-qualified PR list heads

This new comparison makes conventional fork checkouts probe owner:branch first, but I checked the gh pr list manual and its --head option explicitly says <owner>:<branch> syntax is not supported (https://cli.github.com/manual/gh_pr_list). The code then falls back to a plain branch query after filtering the CLI's limited results locally, so when another fork has the same branch name and appears first (especially in findOpenPr with limit: 1), the existing upstream PR is missed and create/status can behave as if no PR exists.

Useful? React with 👍 / 👎.

: 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