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
130 changes: 129 additions & 1 deletion apps/server/src/pullRequest/GiteaPullRequestApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ layer("GiteaPullRequestApi", (it) => {
it.effect("walks later pages until involvement filtering fills the requested slice", () =>
Effect.gen(function* () {
mockedRequest
.mockReturnValueOnce(Effect.succeed(response([])))
.mockReturnValueOnce(
Effect.succeed(
response(
Expand All @@ -468,7 +469,7 @@ layer("GiteaPullRequestApi", (it) => {
expect(page.items.map((item) => item.number)).toEqual([51]);
assert.strictEqual(page.consumed, 51);
assert.isFalse(page.truncated);
expect(callAt(1).path).toContain("page=2");
expect(callAt(2).path).toContain("page=2");
}),
);

Expand Down Expand Up @@ -1856,6 +1857,133 @@ layer("GiteaPullRequestApi", (it) => {
}),
);

it.effect("includes requested native teams in reviewer candidates and sends their names", () =>
Effect.gen(function* () {
mockedRequest.mockImplementation((input) => {
if (input.path === "/repos/acme/web/pulls/7")
return Effect.succeed(
response(
rawPullRequest(7, {
requested_reviewers_teams: [
{ id: 41, name: "maintainers", organization: { username: "acme" } },
],
}),
),
);
if (input.path.startsWith("/repos/acme/web/reviewers?"))
return Effect.succeed(response([{ id: 2, login: "reviewer" }]));
if (input.path === "/repos/acme/web/teams")
return Effect.succeed(
response([{ id: 41, name: "maintainers", organization: { username: "acme" } }]),
);
if (input.path === "/repos/acme/web/pulls/7/requested_reviewers")
return Effect.succeed(response({}));
return Effect.die(`unexpected request: ${input.path}`);
});
const api = yield* GiteaPullRequestApi.make;
const candidates = yield* api.listReviewerCandidates({
host: "forge.example.test",
repository: "acme/web",
number: 7,
});

expect(candidates).toEqual({
candidates: [
expect.objectContaining({ id: "reviewer", kind: "user", isRequested: true }),
expect.objectContaining({
id: "maintainers",
kind: "team",
login: "maintainers",
name: "acme",
isRequested: true,
}),
],
truncated: false,
});
yield* api.setReviewerRequest({
host: "forge.example.test",
repository: "acme/web",
number: 7,
requested: true,
reviewers: [candidates.candidates[1]!],
});
const request = callAt(3);
expect(decodeJson(request.body ?? "{}")).toEqual({
reviewers: [],
team_reviewers: ["maintainers"],
});
}),
);

it.effect("treats a native repository team 405 as a personal repository", () =>
Effect.gen(function* () {
mockedRequest.mockImplementation((input) => {
if (input.path === "/repos/acme/web/pulls/7")
return Effect.succeed(response(rawPullRequest(7)));
if (input.path.startsWith("/repos/acme/web/reviewers?"))
return Effect.succeed(response([{ id: 2, login: "reviewer" }]));
if (input.path === "/repos/acme/web/teams")
return Effect.fail(
new GiteaApi.GiteaApiError({
operation: "listTeamReviewerCandidates",
reason: "failed",
detail: "Gitea returned HTTP 405.",
status: 405,
}),
);
return Effect.die(`unexpected request: ${input.path}`);
});
const api = yield* GiteaPullRequestApi.make;
const candidates = yield* api.listReviewerCandidates({
host: "forge.example.test",
repository: "acme/web",
number: 7,
});

expect(candidates.candidates).toEqual([
expect.objectContaining({ id: "reviewer", kind: "user" }),
]);
}),
);

it.effect("includes pull requests requested from a viewer team in reviewing listings", () =>
Effect.gen(function* () {
mockedRequest.mockImplementation((input) => {
if (input.path === "/user/teams?page=1&limit=50")
return Effect.succeed(response([{ id: 4, name: "first" }], { "x-total-count": "2" }));
if (input.path === "/user/teams?page=2&limit=50")
return Effect.succeed(
response([{ id: 9, name: "maintainers" }], { "x-total-count": "2" }),
);
if (input.path.startsWith("/repos/acme/web/pulls?"))
return Effect.succeed(
response(
[
rawPullRequest(7, {
requested_reviewers: [],
requested_reviewers_teams: [{ id: 9, name: "maintainers" }],
}),
],
{ "x-total-count": "1" },
),
);
return Effect.die(`unexpected request: ${input.path}`);
});
const api = yield* GiteaPullRequestApi.make;
const page = yield* api.listPullRequests({
host: "forge.example.test",
repository: "acme/web",
state: "open",
involvement: "reviewing",
viewer: "viewer",
limit: 10,
});

expect(page.items.map((pullRequest) => pullRequest.number)).toEqual([7]);
assert.strictEqual(mockedRequest.mock.calls.length, 3);
}),
);

it.effect("arms and cancels Gitea auto-merge through the native merge route", () =>
Effect.gen(function* () {
mockedRequest
Expand Down
141 changes: 122 additions & 19 deletions apps/server/src/pullRequest/GiteaPullRequestApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ const RawLabel = Schema.Struct({
color: Schema.optional(Schema.NullOr(Schema.String)),
description: Schema.optional(Schema.NullOr(Schema.String)),
});
const RawTeam = Schema.Struct({
id: Schema.optional(Schema.Int),
name: Schema.optional(Schema.String),
organization: Schema.optional(
Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.String) })),
),
});
const RawPullRequest = Schema.Struct({
number: Schema.Int,
title: Schema.String,
Expand All @@ -115,6 +122,7 @@ const RawPullRequest = Schema.Struct({
base: RawBranch,
head: RawBranch,
requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUser))),
requested_reviewers_teams: Schema.optional(Schema.NullOr(Schema.Array(RawTeam))),
labels: Schema.optional(Schema.NullOr(Schema.Array(RawLabel))),
merge_base: Schema.optional(Schema.String),
});
Expand Down Expand Up @@ -206,12 +214,14 @@ type RawCommitStatus = NonNullable<(typeof RawCombinedStatus.Type)["statuses"]>[

const decodeRow = Schema.decodeUnknownOption(RawPullRequest);
const decodeUser = Schema.decodeUnknownOption(RawUser);
const decodeTeam = Schema.decodeUnknownOption(RawTeam);
const decodeComment = Schema.decodeUnknownOption(RawComment);
const decodeReview = Schema.decodeUnknownOption(RawReview);
const decodeReviewComment = Schema.decodeUnknownOption(RawReviewComment);
const decodeCommit = Schema.decodeUnknownOption(RawCommit);
const decodeLabel = Schema.decodeUnknownOption(RawLabel);
const decodeReaction = Schema.decodeUnknownOption(RawGiteaReaction);
const isGiteaApiError = Schema.is(GiteaApi.GiteaApiError);
const encodeObject = Schema.encodeSync(
Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
);
Expand Down Expand Up @@ -239,6 +249,8 @@ export interface GiteaPullRequest {
readonly mergedAt: string | null;
readonly closedAt: string | null;
readonly reviewRequestLogins: ReadonlyArray<string>;
readonly reviewRequestTeamIDs: ReadonlyArray<number>;
readonly reviewRequestTeamNames: ReadonlyArray<string>;
readonly reviewers: ReadonlyArray<PullRequestActor>;
readonly labels: ReadonlyArray<PullRequestLabel>;
readonly commentCount: number;
Expand Down Expand Up @@ -327,6 +339,13 @@ function pullRequest(value: RawPullRequest): GiteaPullRequest | null {
mergedAt: iso(value.merged_at),
closedAt: iso(value.closed_at),
reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
reviewRequestTeamIDs: (value.requested_reviewers_teams ?? []).flatMap((team) =>
team.id === undefined ? [] : [team.id],
),
reviewRequestTeamNames: (value.requested_reviewers_teams ?? []).flatMap((team) => {
const name = team.name?.trim();
return name ? [name] : [];
}),
reviewers,
labels: (value.labels ?? []).flatMap((label) => {
const name = label.name?.trim();
Expand All @@ -349,13 +368,15 @@ function matchesPullRequest(
state: PullRequestListState,
involvement: PullRequestInvolvement,
viewer: string,
viewerTeamIDs: ReadonlySet<number> = new Set(),
): boolean {
if (state !== "all" && value.state !== state) return false;
if (involvement === "authored" && value.author?.login.toLowerCase() !== viewer.toLowerCase())
return false;
if (
involvement === "reviewing" &&
!value.reviewRequestLogins.some((login) => login.toLowerCase() === viewer.toLowerCase())
!value.reviewRequestLogins.some((login) => login.toLowerCase() === viewer.toLowerCase()) &&
!value.reviewRequestTeamIDs.some((id) => viewerTeamIDs.has(id))
)
return false;
return true;
Expand Down Expand Up @@ -859,6 +880,8 @@ export const make = Effect.gen(function* () {
let rowsSkipped = 0;
let consumed = 0;
const collected: Array<GiteaPullRequest> = [];
const viewerTeamIDs =
input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set<number>();

while (page <= MAX_PAGINATION_PAGES) {
const result = yield* readUnknownPage({
Expand Down Expand Up @@ -897,7 +920,15 @@ export const make = Effect.gen(function* () {
for (const [index, pullRequest] of hydrated.entries()) {
consumed += 1;
if (pullRequest === null) continue;
if (!matchesPullRequest(pullRequest, input.state, input.involvement, input.viewer))
if (
!matchesPullRequest(
pullRequest,
input.state,
input.involvement,
input.viewer,
viewerTeamIDs,
)
)
continue;
collected.push(pullRequest);
if (collected.length === wanted) {
Expand Down Expand Up @@ -951,6 +982,39 @@ export const make = Effect.gen(function* () {
"1 minute",
);

const getViewerTeamIDs = yield* Effect.cachedWithTTL(
Effect.suspend(() =>
Effect.gen(function* () {
const teamIDs = new Set<number>();
let path = query("/user/teams", { page: 1, limit: PAGE_SIZE });
let rowsSeen = 0;
for (let page = 1; page <= MAX_PAGINATION_PAGES; page += 1) {
const response = yield* gitea
.request({ operation: "getViewerTeams", method: "GET", path })
.pipe(Effect.mapError((error) => failure("getViewerTeams", error)));
const teams = yield* decode("getViewerTeams", Schema.Array(RawTeam), response);
for (const team of teams) if (team.id !== undefined) teamIDs.add(team.id);
rowsSeen += teams.length;
const next = nextPagePath({
path,
page,
pageRows: teams.length,
rowsSeen,
headers: response.headers,
});
if (next === null) return teamIDs;
path = next;
}
return yield* new GiteaPullRequestApiError({
operation: "getViewerTeams",
reason: "failed",
detail: "Gitea viewer team pagination exceeded the safe page limit.",
});
}),
),
"1 minute",
);

const readUnknownSlice = Effect.fn("GiteaPullRequestApi.readUnknownSlice")(function* (input: {
operation: string;
host: string;
Expand Down Expand Up @@ -1019,6 +1083,8 @@ export const make = Effect.gen(function* () {
let rowsSkipped = 0;
let consumed = 0;
const collected: Array<GiteaPullRequest> = [];
const viewerTeamIDs =
input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set<number>();
while (page <= MAX_PAGINATION_PAGES) {
const result = yield* readUnknownPage({
operation: "listPullRequests",
Expand All @@ -1043,7 +1109,8 @@ export const make = Effect.gen(function* () {
if (Option.isNone(decoded)) continue;
const pr = pullRequest(decoded.value);
if (pr === null) continue;
if (!matchesPullRequest(pr, input.state, input.involvement, input.viewer)) continue;
if (!matchesPullRequest(pr, input.state, input.involvement, input.viewer, viewerTeamIDs))
continue;
collected.push(pr);
if (collected.length === wanted) {
if (page === MAX_PAGINATION_PAGES && next !== null) {
Expand Down Expand Up @@ -1936,26 +2003,62 @@ export const make = Effect.gen(function* () {
}),
limit: PAGE_SIZE,
}),
readUnknownArray({
operation: "listTeamReviewerCandidates",
...input,
path: `${basePath(input.repository)}/teams`,
}).pipe(
Effect.catchTag("GiteaPullRequestApiError", (error: GiteaPullRequestApiError) =>
isGiteaApiError(error.cause) && error.cause.status === 405
? Effect.succeed([])
: Effect.fail(error),
),
),
],
{ concurrency: 2 },
{ concurrency: 3 },
).pipe(
Effect.map(([pr, result]) => {
Effect.map(([pr, result, teamRows]) => {
const requested = new Set(pr.reviewRequestLogins.map((login) => login.toLowerCase()));
const requestedTeams = new Set(
pr.reviewRequestTeamNames.map((name) => name.toLowerCase()),
);
return {
candidates: result.rows.flatMap((row) => {
const raw = decodeUser(row);
if (Option.isNone(raw)) return [];
const mapped = actor(raw.value);
if (mapped === null || mapped.login === pr.author?.login) return [];
return [
{
...mapped,
id: mapped.login,
kind: "user" as const,
isRequested: requested.has(mapped.login.toLowerCase()),
},
];
}),
candidates: [
...result.rows.flatMap((row) => {
const raw = decodeUser(row);
if (Option.isNone(raw)) return [];
const mapped = actor(raw.value);
if (
mapped === null ||
mapped.login.toLowerCase() === pr.author?.login.toLowerCase()
)
return [];
return [
{
...mapped,
id: mapped.login,
kind: "user" as const,
isRequested: requested.has(mapped.login.toLowerCase()),
},
];
}),
...teamRows.flatMap((row) => {
const raw = decodeTeam(row);
if (Option.isNone(raw)) return [];
const name = raw.value.name?.trim();
if (!name) return [];
return [
{
id: name,
kind: "team" as const,
login: name,
name: raw.value.organization?.username?.trim() || null,
avatarUrl: null,
isRequested: requestedTeams.has(name.toLowerCase()),
},
];
}),
],
truncated: result.truncated,
};
}),
Expand Down
Loading