Skip to content
85 changes: 85 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4384,6 +4384,91 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("prepares a worktree PR thread on a host that publishes no pull request head ref", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
yield* runGit(repoDir, ["checkout", "-b", "feature/pr-no-pull-ref"]);
NodeFS.writeFileSync(NodePath.join(repoDir, "azure.txt"), "azure\n");
yield* runGit(repoDir, ["add", "azure.txt"]);
yield* runGit(repoDir, ["commit", "-m", "PR branch with no pull ref"]);
yield* runGit(repoDir, ["push", "origin", "feature/pr-no-pull-ref"]);
const headCommit = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
yield* runGit(repoDir, ["checkout", "main"]);
yield* runGit(repoDir, ["branch", "-D", "feature/pr-no-pull-ref"]);

const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 26855,
title: "PR with no pull ref",
url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/26855",
baseRefName: "main",
headRefName: "feature/pr-no-pull-ref",
state: "open",
isCrossRepository: false,
},
},
});

const result = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "26855",
mode: "worktree",
});

expect(result.worktreePath).not.toBeNull();
expect(result.isOnPullRequestHead).toBe(true);
expect(
(yield* runGit(result.worktreePath as string, ["rev-parse", "HEAD"])).stdout.trim(),
).toBe(headCommit);
}),
);

it.effect("fails closed when a fork pull request repository is unknown", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-head"]);
NodeFS.writeFileSync(NodePath.join(repoDir, "unrelated.txt"), "unrelated\n");
yield* runGit(repoDir, ["add", "unrelated.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Unrelated same-named branch"]);
yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-head"]);
yield* runGit(repoDir, ["checkout", "main"]);
yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-head"]);

const { manager, ghCalls } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 26856,
title: "PR with unknown head repository",
url: "https://github.com/pingdotgg/codething-mvp/pull/26856",
baseRefName: "main",
headRefName: "feature/ambiguous-head",
state: "open",
isCrossRepository: true,
},
},
});

const error = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "26856",
mode: "worktree",
}).pipe(Effect.flip);

expect(error._tag).toBe("GitPullRequestMaterializationError");
expect(ghCalls.some((call) => call.startsWith("repo view "))).toBe(false);
expect(ghCalls).not.toContain("pr checkout 26856 --force");
}),
);

it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
48 changes: 40 additions & 8 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,12 +832,27 @@ export const make = Effect.gen(function* () {
) {
const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";

if (repositoryNameWithOwner.length === 0) {
yield* gitCore.fetchPullRequestBranch({
cwd,
prNumber: pullRequest.number,
branch: localBranch,
});
if (repositoryNameWithOwner.length === 0 && pullRequest.isCrossRepository === false) {
yield* gitCore
.fetchPullRequestBranch({
cwd,
prNumber: pullRequest.number,
branch: localBranch,
})
.pipe(
// Azure DevOps publishes no pull-request head ref for same-repository PRs.
Effect.catch(() =>
Effect.gen(function* () {
const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd);
yield* gitCore.fetchRemoteBranch({
cwd,
remoteName,
remoteBranch: pullRequest.headBranch,
localBranch,
});
}),
),
);
Comment thread
Kieren-Foenander marked this conversation as resolved.
Comment thread
Kieren-Foenander marked this conversation as resolved.
return;
}

Expand Down Expand Up @@ -876,8 +891,24 @@ export const make = Effect.gen(function* () {
cwd: string,
pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
localBranch = pullRequest.headBranch,
) =>
materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe(
) => {
const headRepository = resolveHeadRepositoryNameWithOwner(pullRequest);
if (pullRequest.isCrossRepository === true && headRepository === null) {
return Effect.fail(
new GitPullRequestMaterializationError({
cwd,
pullRequestNumber: pullRequest.number,
headRepository,
headBranch: pullRequest.headBranch,
localBranch,
cause: new Error(
"Cross-repository pull request is missing its head repository identity.",
),
}),
);
}

return materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe(
Effect.catch((primaryCause) =>
gitCore
.fetchPullRequestBranch({
Expand All @@ -904,6 +935,7 @@ export const make = Effect.gen(function* () {
),
),
);
};
const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
const canonicalizeExistingPath = (value: string) =>
fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value));
Expand Down
16 changes: 12 additions & 4 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
}),
);

it.effect("reads the conversation through the REST API, pinned to a version", () =>
it.effect("reads the authenticated conversation through the REST API, pinned to a version", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(
Effect.succeed(
Expand All @@ -543,10 +543,18 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
});

assert.strictEqual(comments.length, 1);
expect(argsOfCall(0)).toContain("rest");
expect(argsOfCall(0)).toContain(
assert.deepStrictEqual(argsOfCall(0), [
"rest",
"--method",
"get",
"--url",
"https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads?api-version=7.1",
);
"--resource",
"499b84ac-1321-427f-aa17-267ca6975798",
"--only-show-errors",
"--output",
"json",
]);
}),
);

Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export type AzureDevOpsPullRequestCliError =

/** The version every REST call below is pinned to, so a new default cannot reshape a response. */
const REST_API_VERSION = "7.1";
const AZURE_DEVOPS_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798";
const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;

export class AzureDevOpsPullRequestCli extends Context.Service<
Expand Down Expand Up @@ -455,6 +456,8 @@ export const make = Effect.gen(function* () {
"get",
"--url",
`${input.threadsUrl}?api-version=${REST_API_VERSION}`,
"--resource",
AZURE_DEVOPS_RESOURCE_ID,
],
}).pipe(
Effect.flatMap((result) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili

/**
* The REST collection a pull request's threads hang from. Built from what Azure returned rather
* than from the local remote, whose shape differs between the modern, legacy and SSH forms.
* the local remote, whose shape differs between the modern, legacy and SSH forms.
*/
function toThreadsUrl(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): string | null {
const base = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url);
Expand Down
Loading
Loading