Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq
url: pullRequest.url,
author: pullRequest.author,
headBranch: pullRequest.headBranch,
headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner,
baseBranch: pullRequest.baseBranch,
state: pullRequest.state,
isDraft: pullRequest.isDraft,
Expand All @@ -94,6 +95,9 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq
reviewRequestLogins: pullRequest.reviewRequestLogins,
// Azure keeps labels on work items rather than on the pull request.
labels: [],
...(pullRequest.isCrossRepository === undefined
? {}
: { isCrossRepository: pullRequest.isCrossRepository }),
};
}

Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/pullRequest/GitHubPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2519,7 +2519,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
expect(detail.body).toBe("Core body");
expect(activity.author?.login).toBe("octocat");
expect(callAt(0).args.at(-1)).toBe(
"number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest",
"number,title,url,author,headRefName,baseRefName,isCrossRepository,headRepository,headRepositoryOwner,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,headRefOid,autoMergeRequest",
);
expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits");
}),
Expand Down
63 changes: 63 additions & 0 deletions apps/server/src/pullRequest/GitLabPullRequestCli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, assert, expect, it, vi } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { ChildProcessSpawner } from "effect/unstable/process";

import * as GitLabCli from "../sourceControl/GitLabCli.ts";
Expand Down Expand Up @@ -126,6 +127,44 @@ layer("GitLabPullRequestCli.layer", (it) => {
}),
);

it.effect("uses the requested repository for a standard same-project REST row", () =>
Effect.gen(function* () {
// Standard GitLab REST rows commonly carry only the numeric project ids. Equal ids prove
// this is the requested project; the request path supplies its qualified repository name.
mockedExecute.mockReturnValueOnce(
Effect.succeed(
output(
yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))([
{
iid: 7,
title: "Merge request 7",
web_url: "https://gitlab.com/acme/web/-/merge_requests/7",
source_branch: "feat/page",
target_branch: "main",
source_project_id: 100,
target_project_id: 100,
created_at: "2026-07-01T00:00:00Z",
updated_at: "2026-07-02T00:00:00Z",
},
]),
),
),
);
const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;

const batch = yield* cli.listMergeRequests({
cwd: "/w",
repository: "acme/web",
state: "open",
involvement: "all",
viewer: "bilal",
limit: 10,
});

expect(batch.items[0]?.headRepositoryNameWithOwner).toBe("acme/web");
}),
);

it.effect("walks pages at a fixed size, because GitLab pages by offset", () =>
Effect.gen(function* () {
mockedExecute
Expand Down Expand Up @@ -869,6 +908,30 @@ layer("GitLabPullRequestCli.layer", (it) => {
}),
);

it.effect("uses the requested repository for a standard same-project detail row", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(
Effect.succeed(
output(
mergeRequestJson({
source_project_id: 100,
target_project_id: 100,
}),
),
),
);
const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;

const detail = yield* cli.getMergeRequestDetail({
cwd: "/w",
repository: "acme/web",
number: 7,
});

expect(detail.headRepositoryNameWithOwner).toBe("acme/web");
}),
);

it.effect("fails the read when GitLab returns something unreadable", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}')));
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/pullRequest/GitLabPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ export const make = Effect.gen(function* () {
cursorAdvance: input.cursorAdvance,
});
}
const decoded = decodeMergeRequestListJson(raw);
const decoded = decodeMergeRequestListJson(raw, input.repository);
if (!Result.isSuccess(decoded)) {
return Effect.fail(
new GitLabMergeRequestReadError({
Expand Down Expand Up @@ -914,7 +914,7 @@ export const make = Effect.gen(function* () {
])}`,
}).pipe(
Effect.flatMap((result) => {
const decoded = decodeMergeRequestDetailJson(result.stdout.trim());
const decoded = decodeMergeRequestDetailJson(result.stdout.trim(), input.repository);
return Result.isSuccess(decoded)
? Effect.succeed(decoded.success)
: Effect.fail(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ describe("getChangeRequest base freshness", () => {
url: "https://gitlab.com/acme/web/-/merge_requests/7",
author: null,
headBranch: "feat/page",
headRepositoryNameWithOwner: null,
baseBranch: "main",
state: "open" as const,
isDraft: false,
Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ function pullRequest(overrides: Record<string, unknown> = {}): Record<string, un
creationDate: "2026-07-01T00:00:00Z",
url: REST_URL,
repository: { name: "web", project: { name: "platform" } },
// Azure's explicit null fork marker proves this source branch is in the target repository.
forkSource: null,
...overrides,
};
}
Expand All @@ -50,6 +52,7 @@ describe("decodePullRequestListJson", () => {
author: { login: "bilal@acme.dev", name: "Bilal Hassan" },
// Azure prefixes its refs, which no other host does.
headBranch: "feat/page",
headRepositoryNameWithOwner: "platform/web",
baseBranch: "main",
state: "open",
isDraft: false,
Expand Down Expand Up @@ -144,6 +147,30 @@ describe("decodePullRequestJson", () => {
]);
});

it("uses Azure's fork source identity and leaves deleted sources unknown", () => {
const fork = expectSuccess(
decodePullRequestJson(
asJson(
pullRequest({
forkSource: {
repository: { name: "web", project: { name: "contributor" } },
},
}),
),
),
);
expect(fork).toMatchObject({
headRepositoryNameWithOwner: "contributor/web",
isCrossRepository: true,
});

const deleted = expectSuccess(
decodePullRequestJson(asJson(pullRequest({ forkSource: { repository: null } }))),
);
expect(deleted?.headRepositoryNameWithOwner).toBeNull();
expect(deleted?.isCrossRepository).toBeUndefined();
});

it("reads auto-complete from whoever armed it, and its absence as nobody", () => {
const armed = expectSuccess(
decodePullRequestJson(
Expand Down
47 changes: 36 additions & 11 deletions apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson";

import {
azureDevOpsOrganizationBaseFromRestApiUrl,
azureDevOpsHeadRepositoryNameWithOwner,
azureDevOpsRepositoryNameWithOwner,
azureDevOpsPullRequestWebUrl,
} from "../sourceControl/azureDevOpsPullRequests.ts";

Expand All @@ -30,6 +32,18 @@ const RawIdentitySchema = Schema.Struct({
imageUrl: Schema.optional(Schema.NullOr(Schema.String)),
});

const RawRepositorySchema = Schema.Struct({
name: Schema.optional(Schema.NullOr(Schema.String)),
webUrl: Schema.optional(Schema.NullOr(Schema.String)),
project: Schema.optional(
Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })),
),
});

const RawForkSourceSchema = Schema.Struct({
repository: Schema.optional(Schema.NullOr(RawRepositorySchema)),
});

const RawPullRequestSchema = Schema.Struct({
pullRequestId: Schema.Int,
title: Schema.String,
Expand Down Expand Up @@ -61,17 +75,9 @@ const RawPullRequestSchema = Schema.Struct({
creationDate: TrimmedNonEmptyString,
closedDate: Schema.optional(Schema.NullOr(Schema.String)),
url: Schema.optional(Schema.NullOr(Schema.String)),
repository: Schema.optional(
Schema.NullOr(
Schema.Struct({
name: Schema.optional(Schema.NullOr(Schema.String)),
webUrl: Schema.optional(Schema.NullOr(Schema.String)),
project: Schema.optional(
Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })),
),
}),
),
),
repository: Schema.optional(Schema.NullOr(RawRepositorySchema)),
/** Non-null only when Azure identifies a pull request source fork. */
forkSource: Schema.optional(Schema.NullOr(RawForkSourceSchema)),
_links: Schema.optional(
Schema.NullOr(
Schema.Struct({
Expand Down Expand Up @@ -123,6 +129,10 @@ export interface AzureDevOpsPullRequest {
readonly url: string;
readonly author: PullRequestActor | null;
readonly headBranch: string;
/** The qualified source repository, or null if Azure did not resolve it. */
readonly headRepositoryNameWithOwner: string | null;
/** Derived from the source and target repository identities when both are known. */
readonly isCrossRepository?: boolean;
readonly baseBranch: string;
readonly state: PullRequestState;
readonly isDraft: boolean;
Expand Down Expand Up @@ -154,6 +164,10 @@ function normalizeRefName(refName: string): string {
return refName.trim().replace(/^refs\/heads\//, "");
}

function normalizeRepositoryIdentity(value: string): string {
return value.trim().toLowerCase();
}

/** A login has to compare against `az account show`, which reports an email. */
function toActor(raw: Schema.Schema.Type<typeof RawIdentitySchema> | null | undefined) {
const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName);
Expand Down Expand Up @@ -224,6 +238,15 @@ function toAutoMergeMethod(
function toPullRequest(
raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
): AzureDevOpsPullRequest | null {
const headRepositoryNameWithOwner = azureDevOpsHeadRepositoryNameWithOwner(raw);
const targetRepositoryNameWithOwner = azureDevOpsRepositoryNameWithOwner(raw.repository);
const isCrossRepository =
headRepositoryNameWithOwner !== undefined &&
headRepositoryNameWithOwner !== null &&
targetRepositoryNameWithOwner !== null
? normalizeRepositoryIdentity(headRepositoryNameWithOwner) !==
normalizeRepositoryIdentity(targetRepositoryNameWithOwner)
: undefined;
const autoMergeMethod = toAutoMergeMethod(raw);
const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => {
const actor = toActor(reviewer);
Expand All @@ -249,6 +272,8 @@ function toPullRequest(
url,
author: toActor(raw.createdBy),
headBranch,
headRepositoryNameWithOwner: headRepositoryNameWithOwner ?? null,
...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
baseBranch,
state: toState(raw),
isDraft: raw.isDraft ?? false,
Expand Down
28 changes: 28 additions & 0 deletions apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,34 @@ describe("decodePullRequestPageJson", () => {
});

describe("decodePullRequestJson", () => {
it("keeps a deleted source repository unresolved on detail responses", () => {
const decoded = expectSuccess(
decodePullRequestJson(
JSON.stringify(
pullRequest({
source: { branch: { name: "feat/deleted" }, repository: null },
}),
),
),
);

expect(decoded.headRepositoryNameWithOwner).toBeNull();
});

it("does not treat a bare source repository name as a qualified identity", () => {
const decoded = expectSuccess(
decodePullRequestJson(
JSON.stringify(
pullRequest({
source: { branch: { name: "feat/unqualified" }, repository: { full_name: "web" } },
}),
),
),
);

expect(decoded.headRepositoryNameWithOwner).toBeNull();
});

it("reads reviewers as review requests", () => {
const decoded = expectSuccess(
decodePullRequestJson(
Expand Down
20 changes: 17 additions & 3 deletions apps/server/src/pullRequest/bitbucketPullRequestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ function trimmed(value: string | null | undefined): string | null {
return text.length > 0 ? text : null;
}

function qualifiedRepository(value: string | null | undefined): string | null {
const repository = trimmed(value);
return repository?.includes("/") ? repository : null;
}

/**
* Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from
* every host against each other as plain strings, so they are normalized to the same `Z` form
Expand Down Expand Up @@ -293,7 +298,7 @@ function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): Bi
url: raw.links.html.href,
author: toActor(raw.author),
headBranch: raw.source.branch.name,
headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null,
headRepositoryNameWithOwner: qualifiedRepository(raw.source.repository?.full_name),
baseBranch: raw.destination.branch.name,
state: toState(raw),
isDraft: raw.draft ?? false,
Expand Down Expand Up @@ -328,10 +333,15 @@ export interface BitbucketPage<A> {
readonly next: string | null;
}

export interface BitbucketPullRequestPage extends BitbucketPage<BitbucketPullRequest> {
/** Rows before decoding; a skipped row cannot silently certify a complete relationship read. */
readonly rawCount: number;
}

/** Malformed entries are skipped rather than failing the page, as on the other hosts. */
export function decodePullRequestPageJson(
raw: string,
): Result.Result<BitbucketPage<BitbucketPullRequest>, DecodeFailure> {
): Result.Result<BitbucketPullRequestPage, DecodeFailure> {
const decoded = decodePage(raw);
if (!Result.isSuccess(decoded)) {
return Result.fail(decoded.failure);
Expand All @@ -343,7 +353,11 @@ export function decodePullRequestPageJson(
items.push(toPullRequest(item.value));
}
}
return Result.succeed({ items, next: trimmed(decoded.success.next) });
return Result.succeed({
items,
next: trimmed(decoded.success.next),
rawCount: decoded.success.values.length,
});
}

export function decodePullRequestJson(
Expand Down
Loading