diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 2b89fa03e514..2cd4b76f1df5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -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( @@ -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"); }), ); @@ -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 diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 364ad6cefcf5..2871ab9e15b0 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -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, @@ -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), }); @@ -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)), ); @@ -239,6 +249,8 @@ export interface GiteaPullRequest { readonly mergedAt: string | null; readonly closedAt: string | null; readonly reviewRequestLogins: ReadonlyArray; + readonly reviewRequestTeamIDs: ReadonlyArray; + readonly reviewRequestTeamNames: ReadonlyArray; readonly reviewers: ReadonlyArray; readonly labels: ReadonlyArray; readonly commentCount: number; @@ -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(); @@ -349,13 +368,15 @@ function matchesPullRequest( state: PullRequestListState, involvement: PullRequestInvolvement, viewer: string, + viewerTeamIDs: ReadonlySet = 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; @@ -859,6 +880,8 @@ export const make = Effect.gen(function* () { let rowsSkipped = 0; let consumed = 0; const collected: Array = []; + const viewerTeamIDs = + input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set(); while (page <= MAX_PAGINATION_PAGES) { const result = yield* readUnknownPage({ @@ -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) { @@ -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(); + 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; @@ -1019,6 +1083,8 @@ export const make = Effect.gen(function* () { let rowsSkipped = 0; let consumed = 0; const collected: Array = []; + const viewerTeamIDs = + input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set(); while (page <= MAX_PAGINATION_PAGES) { const result = yield* readUnknownPage({ operation: "listPullRequests", @@ -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) { @@ -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, }; }), diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index 1d9de88ff71d..0c0d2e7c8d1c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -31,6 +31,8 @@ const trackedPullRequest: GiteaPullRequest = { mergedAt: null, closedAt: null, reviewRequestLogins: [], + reviewRequestTeamIDs: [], + reviewRequestTeamNames: [], reviewers: [], labels: [], commentCount: 0,