authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 03:53:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 03:53:40-07:00
log6c142934188fa83bcc1d9cb44b425ced82d66468
tree52509d92f79976be3fb4bc1f8e77268a58f21f5e
parent90ca6a775843d226937b396e0af18e9664448508
signature Signed by PGP key B5690EEEBB952194

fix(github): requested teams now show in pull request reviewers (#19375)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

5 files changed, 106 insertions(+), 29 deletions(-)

packages/source-control-github/src/server/GitHubPullRequestApi.test.ts+15-1
......@@ -3691,7 +3691,14 @@ layer("GitHubPullRequestApi.layer", (it) => {
36913691 reviewRequests: {
36923692 nodes: [
36933693 { requestedReviewer: { login: "reviewer" } },
3694 { requestedReviewer: { slug: "maintainers", name: "Maintainers" } },
3694 {
3695 requestedReviewer: {
3696 slug: "maintainers",
3697 combinedSlug: "octo/maintainers",
3698 name: "Maintainers",
3699 avatarUrl: "https://avatars.githubusercontent.com/t/1",
3700 },
3701 },
36953702 ],
36963703 },
36973704 labels: { nodes: [{ name: "bug", color: "ff0000" }] },
......@@ -3740,6 +3747,13 @@ layer("GitHubPullRequestApi.layer", (it) => {
37403747 expect(detail.comparison).toBeNull();
37413748 expect(detail.reviewRequestLogins).toEqual(["reviewer"]);
37423749 expect(detail.hasTeamReviewRequest).toBe(true);
3750 expect(detail.reviewRequestTeams).toEqual([
3751 {
3752 login: "octo/maintainers",
3753 name: "Maintainers",
3754 avatarUrl: "https://avatars.githubusercontent.com/t/1",
3755 },
3756 ]);
37433757 expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]);
37443758 expect(detail.checks).toHaveLength(2);
37453759 expect(detail.checksState).toBe("failing");
packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts+1
......@@ -14,6 +14,7 @@ import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullReque
1414import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
1515
1616const coreFields = {
17 reviewRequestTeams: [],
1718 checksTruncated: false,
1819 comparison: { behindBy: 0, viewerCanUpdate: true },
1920 viewerAccess: {
packages/source-control-github/src/server/GitHubPullRequestProvider.ts+8-5
......@@ -380,11 +380,14 @@ export const make = Effect.gen(function* () {
380380 Effect.map((pullRequest): ProviderChangeRequestDetail => ({
381381 ...pullRequest,
382382 author: withAvatar(pullRequest.author, new Map<string, string>(), input.host),
383 reviewers: pullRequest.reviewRequestLogins.map((login) => ({
384 login,
385 name: null,
386 avatarUrl: null,
387 })),
383 reviewers: [
384 ...pullRequest.reviewRequestLogins.map((login) => ({
385 login,
386 name: null,
387 avatarUrl: null,
388 })),
389 ...pullRequest.reviewRequestTeams,
390 ],
388391 mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities,
389392 viewerPermissions: gitHubViewerPermissions({
390393 ...pullRequest.viewerAccess,
packages/source-control-github/src/server/gitHubPullRequestJson.test.ts+36-6
......@@ -720,15 +720,44 @@ describe("review thread decoding", () => {
720720 expect(result.reviewers).toHaveLength(1);
721721 });
722722
723 it("skips a team request, which names nobody to show", () => {
723 it("lists a requested team by org/slug, apart from a user who shares its slug", () => {
724 const result = expectSuccess(
725 decodeReviewThreadsJson(
726 reviewJson({
727 // Recorded from pingdotgg/t3code#12329, where CODEOWNERS asked the team.
728 requested: [
729 {
730 combinedSlug: "pingdotgg/ping-devs",
731 name: "Ping Devs",
732 avatarUrl: "https://avatars.githubusercontent.com/t/6692390?s=400&v=4",
733 },
734 ],
735 reviewed: [
736 { __typename: "User", login: "ping-devs", avatarUrl: "https://avatars/u.png" },
737 ],
738 }),
739 ),
740 );
741
742 expect(result.reviewers).toEqual([
743 {
744 login: "pingdotgg/ping-devs",
745 name: "Ping Devs",
746 avatarUrl: "https://avatars.githubusercontent.com/t/6692390?s=400&v=4",
747 },
748 { login: "ping-devs", name: null, avatarUrl: "https://avatars/u.png" },
749 ]);
750 });
751
752 it("skips a request whose reviewer GitHub does not show", () => {
724753 const result = expectSuccess(decodeReviewThreadsJson(reviewJson({ requested: [null] })));
725754
726755 expect(result.reviewers).toEqual([]);
727756 });
728757
729 it("keeps the conversation when a request is from a team, which has no login", () => {
730 // GraphQL answers with an empty object for a union member the query has no fragment for.
731 // Failing on it would take the whole response down, comments included.
758 it("keeps the conversation when a reviewer has neither a login nor a team slug", () => {
759 // GraphQL answers with an empty object for a union member the query has no fragment for,
760 // such as a mannequin. Failing on it would take the whole response down, comments included.
732761 const result = expectSuccess(
733762 decodeReviewThreadsJson(
734763 reviewJson({ requested: [{}, { login: "julius", avatarUrl: "https://avatars/j.png" }] }),
......@@ -1466,15 +1495,16 @@ describe("reviewer candidate decoding", () => {
14661495 decodeReviewerCandidatesJson(
14671496 candidatesJson({
14681497 assignable: [{ login: "octocat" }],
1469 requested: [{ slug: "reviewers", name: "Reviewers" }],
1498 requested: [{ slug: "reviewers", combinedSlug: "acme/reviewers", name: "Reviewers" }],
14701499 }),
14711500 ),
14721501 );
14731502 expect(list.candidates).toEqual([
14741503 {
1504 // Asked by slug, shown by the same `org/slug` as its reviewer row.
14751505 id: "reviewers",
14761506 kind: "team",
1477 login: "reviewers",
1507 login: "acme/reviewers",
14781508 name: "Reviewers",
14791509 avatarUrl: null,
14801510 isRequested: true,
packages/source-control-github/src/server/gitHubPullRequestJson.ts+46-17
......@@ -46,9 +46,9 @@ const RawActorSchema = Schema.Struct({
4646 __typename: Schema.optional(Schema.String),
4747 is_bot: Schema.optional(Schema.Boolean),
4848 /**
49 * Optional because a review can be requested from a team or a mannequin, which the query has
50 * no fragment for and GraphQL answers with an empty object. A reviewer with no login names
51 * nobody to show, and must not fail the response the conversation travels in.
49 * Optional because a requested team goes by its slug instead, and a mannequin, which no query
50 * has a fragment for, arrives as an empty object. A reviewer with neither names nobody to show,
51 * and must not fail the response the conversation travels in.
5252 */
5353 login: Schema.optional(Schema.String),
5454 /** The node id, which is how a listing's authors are resolved to avatars in one request. */
......@@ -58,6 +58,16 @@ const RawActorSchema = Schema.Struct({
5858 avatarUrl: Schema.optional(Schema.NullOr(Schema.String)),
5959});
6060
61/**
62 * A team answers with a slug where a user answers with a login. `combinedSlug` is the team's
63 * `org/slug`, which is what a reviewer row shows for it.
64 */
65const RawRequestedReviewerSchema = Schema.Struct({
66 ...RawActorSchema.fields,
67 slug: Schema.optional(Schema.NullOr(Schema.String)),
68 combinedSlug: Schema.optional(Schema.NullOr(Schema.String)),
69});
70
6171const RawLabelSchema = Schema.Struct({
6272 name: Schema.String,
6373 color: Schema.optional(Schema.NullOr(Schema.String)),
......@@ -66,7 +76,9 @@ const RawLabelSchema = Schema.Struct({
6676const RawReviewRequestSchema = Schema.Struct({
6777 login: Schema.optional(Schema.NullOr(Schema.String)),
6878 slug: Schema.optional(Schema.NullOr(Schema.String)),
79 combinedSlug: Schema.optional(Schema.NullOr(Schema.String)),
6980 name: Schema.optional(Schema.NullOr(Schema.String)),
81 avatarUrl: Schema.optional(Schema.NullOr(Schema.String)),
7082});
7183
7284/** One reviewer's most recent review: the state is all the verdict needs, the author is for who. */
......@@ -618,8 +630,8 @@ const RawReviewThreadsSchema = Schema.Struct({
618630 Schema.Struct({
619631 nodes: Schema.Array(
620632 Schema.Struct({
621 // Null for a team, which is a request nobody in particular owns.
622 requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)),
633 // Null where GitHub does not name who was asked.
634 requestedReviewer: Schema.optional(Schema.NullOr(RawRequestedReviewerSchema)),
623635 }),
624636 ),
625637 }),
......@@ -835,7 +847,7 @@ export const pullRequestCoreGraphQlQuery = (host: string) => {
835847 viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch
836848 baseRef { compare(headRef: $headRef) { behindBy } }
837849 reviewRequests(first: 100) {
838 nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug name } } }
850 nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug combinedSlug name avatarUrl } } }
839851 }
840852 labels(first: 100) { nodes { name color } }
841853 commits(last: 1) {
......@@ -1006,8 +1018,8 @@ function pullRequestRowSelection(includeStacks: boolean, includeStats: boolean):
10061018 *
10071019 * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons:
10081020 * that field holds only requests still outstanding, so anyone who has already reviewed drops off
1009 * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person
1010 * or an app, and both are asked for by name because they are different GraphQL types.
1021 * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person,
1022 * an app, or a team, and each is asked for by name because they are different GraphQL types.
10111023 *
10121024 * `viewerCanUpdate` and `viewerDidAuthor` ride along here for the same reason: they belong to the
10131025 * pull request this query is already standing on, so what the reader may do with it arrives with
......@@ -1052,6 +1064,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin
10521064 requestedReviewer {
10531065 ... on User { login name avatarUrl }
10541066 ... on Bot { __typename login avatarUrl }
1067 ... on Team { combinedSlug name avatarUrl }
10551068 }
10561069 }
10571070 }
......@@ -1413,6 +1426,22 @@ function toActor(raw: Schema.Schema.Type<typeof RawActorSchema> | null | undefin
14131426 };
14141427}
14151428
1429/**
1430 * A requested team as a reviewer row. It goes by `org/slug`, the handle GitHub mentions it by.
1431 * No GitHub login contains a slash, so a team never shares a row or a verdict with a user.
1432 */
1433function toRequestedTeam(
1434 raw:
1435 | Pick<Schema.Schema.Type<typeof RawReviewRequestSchema>, "combinedSlug" | "name" | "avatarUrl">
1436 | null
1437 | undefined,
1438): PullRequestActor | null {
1439 const login = trimmed(raw?.combinedSlug);
1440 return login === null
1441 ? null
1442 : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatarUrl) };
1443}
1444
14161445function toCommitActor(
14171446 raw: NonNullable<Schema.Schema.Type<typeof RawCommitSchema>["authors"]>[number],
14181447): PullRequestActor | null {
......@@ -2302,6 +2331,8 @@ export function decodePullRequestWatchFingerprintsJson(
23022331}
23032332
23042333export interface GitHubPullRequestCore extends GitHubPullRequestDetail {
2334 /** Teams asked for a review. `reviewRequestLogins` holds the people. */
2335 readonly reviewRequestTeams: ReadonlyArray<PullRequestActor>;
23052336 readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess;
23062337 readonly comparison: GitHubBaseComparison | null;
23072338 readonly checksTruncated: boolean;
......@@ -2316,6 +2347,9 @@ export function decodePullRequestCoreJson(
23162347 const pr = repository.pullRequest;
23172348 const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
23182349 return Result.succeed({
2350 reviewRequestTeams: pr.reviewRequests.nodes.flatMap(
2351 ({ requestedReviewer }) => toRequestedTeam(requestedReviewer) ?? [],
2352 ),
23192353 ...toDetail({
23202354 ...pr,
23212355 reviewRequests: pr.reviewRequests.nodes.flatMap(({ requestedReviewer }) =>
......@@ -2772,7 +2806,7 @@ export function decodeReviewThreadsJson(
27722806 ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer),
27732807 ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author),
27742808 ]) {
2775 const actor = toActor(raw);
2809 const actor = toActor(raw) ?? toRequestedTeam(raw);
27762810 // Keyed by login, so someone who was asked and then answered appears once.
27772811 if (actor !== null && !reviewers.has(actor.login)) reviewers.set(actor.login, actor);
27782812 }
......@@ -2932,7 +2966,7 @@ export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name:
29322966 nodes {
29332967 requestedReviewer {
29342968 ... on User { login name avatarUrl }
2935 ... on Team { slug name avatarUrl }
2969 ... on Team { slug combinedSlug name avatarUrl }
29362970 ... on Bot { login avatarUrl }
29372971 }
29382972 }
......@@ -2941,12 +2975,6 @@ export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name:
29412975 }
29422976}`;
29432977
2944/** A team answers with a slug where a user answers with a login, and nothing else differs. */
2945const RawRequestedReviewerSchema = Schema.Struct({
2946 ...RawActorSchema.fields,
2947 slug: Schema.optional(Schema.NullOr(Schema.String)),
2948});
2949
29502978const RawReviewerCandidatesSchema = Schema.Struct({
29512979 data: Schema.Struct({
29522980 repository: Schema.Struct({
......@@ -3004,7 +3032,8 @@ export function decodeReviewerCandidatesJson(
30043032 candidates.set(`${slug === null ? "user" : "team"} ${id}`, {
30053033 id,
30063034 kind: slug === null ? "user" : "team",
3007 login: id,
3035 // The same `org/slug` the reviewer row shows, so taking the request back finds that row.
3036 login: slug === null ? id : (trimmed(raw?.combinedSlug) ?? id),
30083037 name: trimmed(raw?.name),
30093038 avatarUrl: trimmed(raw?.avatarUrl),
30103039 isRequested: true,