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
82 changes: 82 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3679,6 +3679,88 @@ 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("does not use a remote branch when the 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 } = 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",
},
},
});

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

expect(error._tag).toBe("GitPullRequestMaterializationError");
}),
);

it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
27 changes: 21 additions & 6 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,12 +776,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.
return;
}

Expand Down
32 changes: 26 additions & 6 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
}),
);

it.effect("reads the conversation through the REST API, pinned to a version", () =>
it.effect("reads the conversation through the Azure DevOps extension, pinned to a version", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(
Effect.succeed(
Expand All @@ -499,14 +499,34 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {

const comments = yield* cli.listThreads({
cwd: "/w",
threadsUrl: "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads",
route: {
organization: "https://dev.azure.com/acme",
project: "-platform tools",
repository: "-web repo",
pullRequestId: 42,
},
});

assert.strictEqual(comments.length, 1);
expect(argsOfCall(0)).toContain("rest");
expect(argsOfCall(0)).toContain(
"https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads?api-version=7.1",
);
assert.deepStrictEqual(argsOfCall(0), [
"devops",
"invoke",
"--org",
"https://dev.azure.com/acme",
"--area",
"git",
"--resource",
"pullRequestThreads",
"--route-parameters",
"project=-platform tools",
"repositoryId=-web repo",
"pullRequestId=42",
"--api-version",
"7.1",
"--only-show-errors",
"--output",
"json",
]);
}),
);

Expand Down
24 changes: 17 additions & 7 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
decodeThreadsJson,
decodeViewerJson,
type AzureDevOpsPullRequest,
type AzureDevOpsThreadsRoute,
} from "./azureDevOpsPullRequestJson.ts";
import type { ProviderListCursor } from "./PullRequestProvider.ts";

Expand Down Expand Up @@ -146,10 +147,10 @@ export class AzureDevOpsPullRequestCli extends Context.Service<
readonly number: number;
}) => Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError>;

/** Threads are not reachable through `az repos pr`, so they come from the REST API. */
/** Uses `az devops invoke` so thread reads share the extension's authentication. */
readonly listThreads: (input: {
readonly cwd: string;
readonly threadsUrl: string;
readonly route: AzureDevOpsThreadsRoute;
}) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>;

readonly runPullRequestAction: (input: {
Expand Down Expand Up @@ -433,11 +434,20 @@ export const make = Effect.gen(function* () {
executeJson({
cwd: input.cwd,
args: [
"rest",
"--method",
"get",
"--url",
`${input.threadsUrl}?api-version=${REST_API_VERSION}`,
"devops",
"invoke",
"--org",
input.route.organization,
"--area",
"git",
"--resource",
"pullRequestThreads",
"--route-parameters",
`project=${input.route.project}`,
`repositoryId=${input.route.repository}`,
`pullRequestId=${input.route.pullRequestId}`,
"--api-version",
REST_API_VERSION,
],
}).pipe(
Effect.flatMap((result) => {
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,9 @@ export const make = Effect.gen(function* () {
cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
Effect.mapError(fail("getChangeRequestActivity")),
Effect.flatMap((pullRequest) =>
(pullRequest.threadsUrl === null
(pullRequest.threads === null
? Effect.succeed({ comments: [], truncated: true })
: cli.listThreads({ cwd: input.cwd, threadsUrl: pullRequest.threadsUrl }).pipe(
: cli.listThreads({ cwd: input.cwd, route: pullRequest.threads }).pipe(
Effect.map((comments) => ({ comments, truncated: false })),
Effect.orElseSucceed(() => ({ comments: [], truncated: true })),
)
Expand Down
13 changes: 8 additions & 5 deletions apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,15 @@ describe("decodePullRequestJson", () => {
it("works out where the conversation lives from what Azure returned", () => {
const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest())));

expect(detail?.threadsUrl).toBe(
"https://dev.azure.com/acme/platform/_apis/git/repositories/web/pullRequests/42/threads",
);
expect(detail?.threads).toEqual({
organization: "https://dev.azure.com/acme",
project: "platform",
repository: "web",
pullRequestId: 42,
});
});

it("reports no conversation url when Azure said too little to build one", () => {
it("reports no conversation route when Azure said too little to build one", () => {
// A web link places the pull request, but without the REST url and repository there is
// nothing to hang a threads collection off.
const detail = expectSuccess(
Expand All @@ -184,7 +187,7 @@ describe("decodePullRequestJson", () => {
),
);

expect(detail?.threadsUrl).toBeNull();
expect(detail?.threads).toBeNull();
});

it("returns nothing when Azure gave no way to place the pull request at all", () => {
Expand Down
25 changes: 17 additions & 8 deletions apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ const RawViewerSchema = Schema.Struct({
),
});

export interface AzureDevOpsThreadsRoute {
readonly organization: string;
readonly project: string;
readonly repository: string;
readonly pullRequestId: number;
}

export interface AzureDevOpsPullRequest {
readonly number: number;
readonly title: string;
Expand All @@ -129,7 +136,7 @@ export interface AzureDevOpsPullRequest {
readonly reviewRequestLogins: ReadonlyArray<string>;
readonly reviewers: ReadonlyArray<PullRequestActor>;
/** Where this pull request's threads live, when Azure said enough to work it out. */
readonly threadsUrl: string | null;
readonly threads: AzureDevOpsThreadsRoute | null;
Comment thread
Kieren-Foenander marked this conversation as resolved.
/** Whether Azure is set to complete this on its own once its policies pass. */
readonly autoMergeEnabled: boolean;
}
Expand Down Expand Up @@ -177,15 +184,17 @@ 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 route 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.
*/
function toThreadsUrl(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): string | null {
const base = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url);
function toThreadsRoute(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
): AzureDevOpsThreadsRoute | null {
const organization = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url);
const project = trimmed(raw.repository?.project?.name);
const repository = trimmed(raw.repository?.name);
if (base === null || project === null || repository === null) return null;
return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`;
if (organization === null || project === null || repository === null) return null;
return { organization, project, repository, pullRequestId: raw.pullRequestId };
}

/**
Expand Down Expand Up @@ -230,7 +239,7 @@ function toPullRequest(
body: raw.description ?? "",
reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
reviewers,
threadsUrl: toThreadsUrl(raw),
threads: toThreadsRoute(raw),
autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null,
};
}
Expand Down
Loading
Loading