authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 19:25:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 02:25:21+00:00
log9ebbeda5a03fd8fce7be147b65ea396e50721c68
tree6ba7db2383a9ee37f10d119b9e5b4892a0b2b86d
parent0fbb94248581aaefda36e4f8a40ec2c6455c779a
signature Signed by PGP key B5690EEEBB952194

feat(web): apply and remove labels from the pull request tab (#9313)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

19 files changed, 1025 insertions(+), 114 deletions(-)

apps/server/src/auth/RpcAuthorization.ts+2
...@@ -75,6 +75,8 @@ export const RPC_REQUIRED_SCOPES = {...@@ -75,6 +75,8 @@ export const RPC_REQUIRED_SCOPES = {
75 // write like every other one.75 // write like every other one.
76 [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,76 [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,
77 [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope,77 [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope,
78 [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,
79 [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope,
78 [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,80 [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
79 [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,81 [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
80 [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,82 [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+64-2
...@@ -2726,7 +2726,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2726,7 +2726,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
2726 // One request, because both answers hang off the same repository object.2726 // One request, because both answers hang off the same repository object.
2727 assert.strictEqual(mockedExecute.mock.calls.length, 1);2727 assert.strictEqual(mockedExecute.mock.calls.length, 1);
2728 expect(callAt(0).args).toContain("number=7");2728 expect(callAt(0).args).toContain("number=7");
2729 expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true });2729 expect(access).toEqual({
2730 canWrite: false,
2731 canTriage: false,
2732 canUpdate: true,
2733 didAuthor: true,
2734 });
2730 }),2735 }),
2731 );2736 );
27322737
...@@ -2889,7 +2894,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2889,7 +2894,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
2889 });2894 });
28902895
2891 assert.strictEqual(mockedExecute.mock.calls.length, 2);2896 assert.strictEqual(mockedExecute.mock.calls.length, 2);
2892 expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true });2897 expect(access).toEqual({
2898 canWrite: false,
2899 canTriage: false,
2900 canUpdate: true,
2901 didAuthor: true,
2902 });
2893 yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z"));2903 yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z"));
2894 }),2904 }),
2895 );2905 );
...@@ -3029,4 +3039,56 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3029,4 +3039,56 @@ layer("GitHubPullRequestCli.layer", (it) => {
3029 ]);3039 ]);
3030 }),3040 }),
3031 );3041 );
3042
3043 it.effect("puts labels on by posting to the issue's own collection, all at once", () =>
3044 Effect.gen(function* () {
3045 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
3046 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3047
3048 yield* cli.setLabels({
3049 cwd: "/w",
3050 repository: "acme/web",
3051 host: "github.com",
3052 number: 7,
3053 labels: ["bug", "size:XL"],
3054 applied: true,
3055 });
3056
3057 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3058 const call = callAt(0);
3059 expect(call.args).toEqual([
3060 "api",
3061 "--method",
3062 "POST",
3063 "--hostname",
3064 "github.com",
3065 "repos/acme/web/issues/7/labels",
3066 "--input",
3067 "-",
3068 ]);
3069 // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting the raw gh request body.
3070 expect(JSON.parse(call.stdin ?? "")).toEqual({ labels: ["bug", "size:XL"] });
3071 }),
3072 );
3073
3074 it.effect("takes labels off one at a time, naming each in the path encoded", () =>
3075 Effect.gen(function* () {
3076 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
3077 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3078
3079 yield* cli.setLabels({
3080 cwd: "/w",
3081 repository: "acme/web",
3082 host: "github.com",
3083 number: 7,
3084 labels: ["good first issue", "area/web"],
3085 applied: false,
3086 });
3087
3088 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3089 expect(callAt(0).args).toContain("repos/acme/web/issues/7/labels/good%20first%20issue");
3090 expect(callAt(0).args).toContain("DELETE");
3091 expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb");
3092 }),
3093 );
3032});3094});
apps/server/src/pullRequest/GitHubPullRequestCli.ts+72
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 type PullRequestReviewVerdict,18 type PullRequestReviewVerdict,
19 type PullRequestReviewerCandidateList,19 type PullRequestReviewerCandidateList,
20 type PullRequestReviewerKind,20 type PullRequestReviewerKind,
21 type PullRequestLabelCandidateList,
21 type PullRequestThreadCommentsResult,22 type PullRequestThreadCommentsResult,
22 type PullRequestUpdateMethod,23 type PullRequestUpdateMethod,
23} from "@t3tools/contracts";24} from "@t3tools/contracts";
...@@ -42,6 +43,9 @@ import {...@@ -42,6 +43,9 @@ import {
42 decodeReactionSubjectScopeJson,43 decodeReactionSubjectScopeJson,
43 decodeRepositoryAccessJson,44 decodeRepositoryAccessJson,
44 decodeReviewerCandidatesJson,45 decodeReviewerCandidatesJson,
46 decodeLabelCandidatesJson,
47 buildLabelRequestJson,
48 LABEL_CANDIDATES_GRAPHQL_QUERY,
45 decodeReviewDismissalsJson,49 decodeReviewDismissalsJson,
46 decodeReviewThreadCommentsJson,50 decodeReviewThreadCommentsJson,
47 decodeReviewThreadsJson,51 decodeReviewThreadsJson,
...@@ -597,6 +601,24 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -597,6 +601,24 @@ export class GitHubPullRequestCli extends Context.Service<
597 readonly requested: boolean;601 readonly requested: boolean;
598 }) => Effect.Effect<void, GitHubPullRequestCliError>;602 }) => Effect.Effect<void, GitHubPullRequestCliError>;
599603
604 /** The repository's labels, and which of them this pull request already wears. */
605 readonly listLabelCandidates: (input: {
606 readonly cwd: string;
607 readonly repository: string;
608 readonly host: string;
609 readonly number: number;
610 }) => Effect.Effect<PullRequestLabelCandidateList, GitHubPullRequestCliError>;
611
612 readonly setLabels: (input: {
613 readonly cwd: string;
614 readonly repository: string;
615 readonly host: string;
616 readonly number: number;
617 readonly labels: ReadonlyArray<string>;
618 /** False takes each label off; true adds each to whatever is already there. */
619 readonly applied: boolean;
620 }) => Effect.Effect<void, GitHubPullRequestCliError>;
621
600 readonly runPullRequestAction: (input: {622 readonly runPullRequestAction: (input: {
601 readonly cwd: string;623 readonly cwd: string;
602 readonly repository: string;624 readonly repository: string;
...@@ -1977,6 +1999,56 @@ export const make = Effect.gen(function* () {...@@ -1977,6 +1999,56 @@ export const make = Effect.gen(function* () {
1977 .pipe(Effect.asVoid);1999 .pipe(Effect.asVoid);
1978 },2000 },
19792001
2002 listLabelCandidates: (input) => {
2003 const { owner, name } = parseRepositorySelector(input.repository);
2004 return graphqlRead({
2005 cwd: input.cwd,
2006 host: input.host,
2007 operation: "listLabelCandidates",
2008 allowReserve: true,
2009 variables: [
2010 ["-f", `owner=${owner}`],
2011 ["-f", `name=${name}`],
2012 ["-F", `number=${input.number}`],
2013 ],
2014 query: LABEL_CANDIDATES_GRAPHQL_QUERY,
2015 decode: decodeLabelCandidatesJson,
2016 });
2017 },
2018
2019 setLabels: (input) => {
2020 const { owner, name } = parseRepositorySelector(input.repository);
2021 // A pull request is an issue to the labels API. Adding posts a list and leaves what was
2022 // already there; taking off is one delete per label, since the endpoint names one in its
2023 // path. The name goes into the path encoded, because a label may carry a space or a slash.
2024 const issue = `repos/${owner}/${name}/issues/${input.number}/labels`;
2025 if (input.applied) {
2026 return github
2027 .execute({
2028 cwd: input.cwd,
2029 args: ["api", "--method", "POST", "--hostname", input.host, issue, "--input", "-"],
2030 stdin: buildLabelRequestJson(input.labels),
2031 })
2032 .pipe(Effect.asVoid);
2033 }
2034 return Effect.forEach(
2035 input.labels,
2036 (label) =>
2037 github.execute({
2038 cwd: input.cwd,
2039 args: [
2040 "api",
2041 "--method",
2042 "DELETE",
2043 "--hostname",
2044 input.host,
2045 `${issue}/${encodeURIComponent(label)}`,
2046 ],
2047 }),
2048 { concurrency: 1, discard: true },
2049 );
2050 },
2051
1980 runPullRequestAction: (input) => {2052 runPullRequestAction: (input) => {
1981 if (input.action === "revert") {2053 if (input.action === "revert") {
1982 return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe(2054 return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe(
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+52-11
...@@ -47,7 +47,14 @@ it.effect("uses one narrow read for a linked pull request summary", () =>...@@ -47,7 +47,14 @@ it.effect("uses one narrow read for a linked pull request summary", () =>
4747
48describe("gitHubViewerPermissions", () => {48describe("gitHubViewerPermissions", () => {
49 it("offers everything to a viewer who can write to the repository", () => {49 it("offers everything to a viewer who can write to the repository", () => {
50 expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({50 expect(
51 gitHubViewerPermissions({
52 canWrite: true,
53 canTriage: true,
54 canUpdate: true,
55 didAuthor: false,
56 }),
57 ).toEqual({
51 // Arming a merge for later is the merge, so it travels with it.58 // Arming a merge for later is the merge, so it travels with it.
52 actions: [59 actions: [
53 "merge",60 "merge",
...@@ -64,6 +71,7 @@ describe("gitHubViewerPermissions", () => {...@@ -64,6 +71,7 @@ describe("gitHubViewerPermissions", () => {
64 resolve: true,71 resolve: true,
65 verdicts: ["comment", "approve", "request-changes"],72 verdicts: ["comment", "approve", "request-changes"],
66 requestReviewers: true,73 requestReviewers: true,
74 labels: true,
67 });75 });
68 });76 });
6977
...@@ -71,7 +79,12 @@ describe("gitHubViewerPermissions", () => {...@@ -71,7 +79,12 @@ describe("gitHubViewerPermissions", () => {
71 // Every open-source pull request somebody else opened: GitHub says no to all five actions79 // Every open-source pull request somebody else opened: GitHub says no to all five actions
72 // and to resolving, and yes to commenting and to every verdict.80 // and to resolving, and yes to commenting and to every verdict.
73 expect(81 expect(
74 gitHubViewerPermissions({ canWrite: false, canUpdate: false, didAuthor: false }),82 gitHubViewerPermissions({
83 canWrite: false,
84 canTriage: false,
85 canUpdate: false,
86 didAuthor: false,
87 }),
75 ).toEqual({88 ).toEqual({
76 actions: [],89 actions: [],
77 comment: true,90 comment: true,
...@@ -79,11 +92,31 @@ describe("gitHubViewerPermissions", () => {...@@ -79,11 +92,31 @@ describe("gitHubViewerPermissions", () => {
79 verdicts: ["comment", "approve", "request-changes"],92 verdicts: ["comment", "approve", "request-changes"],
80 // Asking somebody else to review is the one thing read access never stretches to.93 // Asking somebody else to review is the one thing read access never stretches to.
81 requestReviewers: false,94 requestReviewers: false,
95 labels: false,
96 });
97 });
98
99 it("lets a triager label without letting them merge or ask for a review", () => {
100 const permissions = gitHubViewerPermissions({
101 canWrite: false,
102 canTriage: true,
103 canUpdate: false,
104 didAuthor: false,
82 });105 });
106 expect(permissions.labels).toBe(true);
107 expect(permissions.requestReviewers).toBe(false);
108 expect(permissions.actions).toEqual([]);
83 });109 });
84110
85 it("keeps an author's own pull request theirs to close, with read access and no more", () => {111 it("keeps an author's own pull request theirs to close, with read access and no more", () => {
86 expect(gitHubViewerPermissions({ canWrite: false, canUpdate: true, didAuthor: true })).toEqual({112 expect(
113 gitHubViewerPermissions({
114 canWrite: false,
115 canTriage: false,
116 canUpdate: true,
117 didAuthor: true,
118 }),
119 ).toEqual({
87 // Merging is the one thing writing is needed for, now or later; the rest an author may do.120 // Merging is the one thing writing is needed for, now or later; the rest an author may do.
88 actions: ["ready", "draft", "close", "reopen"],121 actions: ["ready", "draft", "close", "reopen"],
89 comment: true,122 comment: true,
...@@ -91,6 +124,7 @@ describe("gitHubViewerPermissions", () => {...@@ -91,6 +124,7 @@ describe("gitHubViewerPermissions", () => {
91 // GitHub refuses an author's approval of their own change, so the page does not offer one.124 // GitHub refuses an author's approval of their own change, so the page does not offer one.
92 verdicts: ["comment"],125 verdicts: ["comment"],
93 requestReviewers: false,126 requestReviewers: false,
127 labels: false,
94 });128 });
95 });129 });
96130
...@@ -110,6 +144,7 @@ describe("gitHubViewerPermissions", () => {...@@ -110,6 +144,7 @@ describe("gitHubViewerPermissions", () => {
110 resolve: false,144 resolve: false,
111 verdicts: ["comment", "approve", "request-changes"],145 verdicts: ["comment", "approve", "request-changes"],
112 requestReviewers: false,146 requestReviewers: false,
147 labels: false,
113 });148 });
114 expect(detail.workflowApprovalsRequired).toBeUndefined();149 expect(detail.workflowApprovalsRequired).toBeUndefined();
115 expect(detail.checks).toContainEqual({150 expect(detail.checks).toContainEqual({
...@@ -158,7 +193,12 @@ describe("gitHubViewerPermissions", () => {...@@ -158,7 +193,12 @@ describe("gitHubViewerPermissions", () => {
158 mergeCapabilities: { merge: true, squash: true, rebase: true },193 mergeCapabilities: { merge: true, squash: true, rebase: true },
159 }),194 }),
160 getViewerAccess: () =>195 getViewerAccess: () =>
161 Effect.succeed({ canWrite: false, canUpdate: true, didAuthor: false }),196 Effect.succeed({
197 canWrite: false,
198 canTriage: false,
199 canUpdate: true,
200 didAuthor: false,
201 }),
162 }),202 }),
163 ),203 ),
164 ),204 ),
...@@ -254,7 +294,7 @@ describe("gitHubViewerPermissions", () => {...@@ -254,7 +294,7 @@ describe("gitHubViewerPermissions", () => {
254 mergeCapabilities: { merge: true, squash: true, rebase: true },294 mergeCapabilities: { merge: true, squash: true, rebase: true },
255 }),295 }),
256 getViewerAccess: () =>296 getViewerAccess: () =>
257 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),297 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
258 }),298 }),
259 ),299 ),
260 ),300 ),
...@@ -318,7 +358,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals",...@@ -318,7 +358,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals",
318 mergeCapabilities: { merge: true, squash: true, rebase: true },358 mergeCapabilities: { merge: true, squash: true, rebase: true },
319 }),359 }),
320 getViewerAccess: () =>360 getViewerAccess: () =>
321 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),361 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
322 }),362 }),
323 ),363 ),
324 ),364 ),
...@@ -365,7 +405,7 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>...@@ -365,7 +405,7 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>
365 mergeCapabilities: { merge: true, squash: true, rebase: true },405 mergeCapabilities: { merge: true, squash: true, rebase: true },
366 }),406 }),
367 getViewerAccess: () =>407 getViewerAccess: () =>
368 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),408 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
369 }),409 }),
370 ),410 ),
371 ),411 ),
...@@ -404,7 +444,7 @@ it.effect("propagates workflow discovery rate limits", () =>...@@ -404,7 +444,7 @@ it.effect("propagates workflow discovery rate limits", () =>
404 mergeCapabilities: { merge: true, squash: true, rebase: true },444 mergeCapabilities: { merge: true, squash: true, rebase: true },
405 }),445 }),
406 getViewerAccess: () =>446 getViewerAccess: () =>
407 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),447 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
408 }),448 }),
409 ),449 ),
410 ),450 ),
...@@ -420,7 +460,8 @@ describe("getViewerPermissions", () => {...@@ -420,7 +460,8 @@ describe("getViewerPermissions", () => {
420 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({460 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
421 getPullRequestDetail: () => Effect.succeed(openDetail),461 getPullRequestDetail: () => Effect.succeed(openDetail),
422 getPullRequestBaseComparison: () => comparison,462 getPullRequestBaseComparison: () => comparison,
423 getViewerAccess: () => Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),463 getViewerAccess: () =>
464 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
424 });465 });
425466
426 it.effect("offers update-branch when the comparison grants it", () =>467 it.effect("offers update-branch when the comparison grants it", () =>
...@@ -466,7 +507,7 @@ describe("getViewerPermissions", () => {...@@ -466,7 +507,7 @@ describe("getViewerPermissions", () => {
466 getViewerAccess: (input) =>507 getViewerAccess: (input) =>
467 Effect.sync(() => {508 Effect.sync(() => {
468 viewerAllowReserve = input.allowReserve;509 viewerAllowReserve = input.allowReserve;
469 return { canWrite: true, canUpdate: true, didAuthor: false };510 return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false };
470 }),511 }),
471 }),512 }),
472 ),513 ),
...@@ -501,7 +542,7 @@ describe("getViewerPermissions", () => {...@@ -501,7 +542,7 @@ describe("getViewerPermissions", () => {
501 }),542 }),
502 ),543 ),
503 getViewerAccess: () =>544 getViewerAccess: () =>
504 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),545 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
505 }),546 }),
506 ),547 ),
507 ),548 ),
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+18
...@@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = {...@@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = {
47 },47 },
48 reviewers: { request: true, listCandidates: true },48 reviewers: { request: true, listCandidates: true },
49 edit: { changeRequest: true, comment: true },49 edit: { changeRequest: true, comment: true },
50 labels: true,
50};51};
5152
52/**53/**
...@@ -93,6 +94,8 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest...@@ -93,6 +94,8 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest
93 verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts,94 verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts,
94 requestReviewers: access.canWrite,95 requestReviewers: access.canWrite,
95 ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}),96 ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}),
97 // Triage is the one role that labels without writing, which is what triage is for.
98 labels: access.canTriage,
96 };99 };
97}100}
98101
...@@ -526,6 +529,21 @@ export const make = Effect.gen(function* () {...@@ -526,6 +529,21 @@ export const make = Effect.gen(function* () {
526 })529 })
527 .pipe(Effect.mapError(fail("setReviewerRequest"))),530 .pipe(Effect.mapError(fail("setReviewerRequest"))),
528531
532 listLabelCandidates: (input) =>
533 cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))),
534
535 setLabels: (input) =>
536 cli
537 .setLabels({
538 cwd: input.cwd,
539 repository: input.repository,
540 host: input.host,
541 number: input.number,
542 labels: input.labels,
543 applied: input.applied,
544 })
545 .pipe(Effect.mapError(fail("setLabels"))),
546
529 runAction: (input) =>547 runAction: (input) =>
530 cli548 cli
531 .runPullRequestAction({549 .runPullRequestAction({
apps/server/src/pullRequest/PullRequestProvider.ts+18
...@@ -26,6 +26,7 @@ import type {...@@ -26,6 +26,7 @@ import type {
26 PullRequestReviewVerdict,26 PullRequestReviewVerdict,
27 PullRequestReviewerCandidateList,27 PullRequestReviewerCandidateList,
28 PullRequestReviewerKind,28 PullRequestReviewerKind,
29 PullRequestLabelCandidateList,
29 PullRequestState,30 PullRequestState,
30 PullRequestUpdateMethod,31 PullRequestUpdateMethod,
31 PullRequestViewerPermissions,32 PullRequestViewerPermissions,
...@@ -469,6 +470,23 @@ export interface PullRequestProviderApi {...@@ -469,6 +470,23 @@ export interface PullRequestProviderApi {
469 },470 },
470 ) => Effect.Effect<void, PullRequestProviderError>;471 ) => Effect.Effect<void, PullRequestProviderError>;
471472
473 /**
474 * The repository's labels, with the ones already on the change request marked. Present with
475 * `setLabels` only where `capabilities.labels` is true; the service refuses both without it.
476 */
477 readonly listLabelCandidates?: (
478 input: ProviderRepositoryRef & { readonly number: number },
479 ) => Effect.Effect<PullRequestLabelCandidateList, PullRequestProviderError>;
480
481 /** Puts labels on the change request, or takes them off. One call for both directions. */
482 readonly setLabels?: (
483 input: ProviderRepositoryRef & {
484 readonly number: number;
485 readonly labels: ReadonlyArray<string>;
486 readonly applied: boolean;
487 },
488 ) => Effect.Effect<void, PullRequestProviderError>;
489
472 /** Only called when `capabilities.review.reply` is true. */490 /** Only called when `capabilities.review.reply` is true. */
473 readonly replyToThread: (491 readonly replyToThread: (
474 input: ProviderRepositoryRef & {492 input: ProviderRepositoryRef & {
apps/server/src/pullRequest/PullRequestService.test.ts+119
...@@ -2284,6 +2284,125 @@ it.effect("hands the host's own candidate list back, and asks for it with the ch...@@ -2284,6 +2284,125 @@ it.effect("hands the host's own candidate list back, and asks for it with the ch
2284 }),2284 }),
2285);2285);
22862286
2287it.effect("refuses a label change on a host that has not said it takes one", () =>
2288 Effect.gen(function* () {
2289 let changed = false;
2290 const service = yield* makeService({
2291 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2292 providers: [
2293 fakeProvider("github", {
2294 // The method is there; the capability that would let it be called is not.
2295 setLabels: () => {
2296 changed = true;
2297 return Effect.void;
2298 },
2299 }),
2300 ],
2301 });
2302
2303 const error = yield* Effect.flip(
2304 service.setLabels({
2305 projectId: "p1" as ProjectId,
2306 repository: "acme/web",
2307 number: 1,
2308 labels: ["bug"],
2309 applied: true,
2310 }),
2311 );
2312
2313 assert.strictEqual(error._tag, "PullRequestOperationError");
2314 assert.include(error.message, "cannot change the labels");
2315 assert.isFalse(changed);
2316 }),
2317);
2318
2319it.effect("refuses a label change this viewer may not make, and says what access it takes", () =>
2320 Effect.gen(function* () {
2321 let changed = false;
2322 const service = yield* makeService({
2323 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2324 providers: [
2325 fakeProvider("github", {
2326 capabilities: { ...fakeProvider("github").capabilities, labels: true },
2327 getViewerPermissions: () =>
2328 Effect.succeed({
2329 actions: [],
2330 comment: true,
2331 resolve: false,
2332 verdicts: ["comment", "approve", "request-changes"],
2333 requestReviewers: false,
2334 labels: false,
2335 }),
2336 listLabelCandidates: () => Effect.die("must not be called"),
2337 setLabels: () => {
2338 changed = true;
2339 return Effect.void;
2340 },
2341 }),
2342 ],
2343 });
2344
2345 const listError = yield* Effect.flip(
2346 service.labelCandidates({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }),
2347 );
2348 assert.include(listError.message, "You need triage access on this repository");
2349
2350 const error = yield* Effect.flip(
2351 service.setLabels({
2352 projectId: "p1" as ProjectId,
2353 repository: "acme/web",
2354 number: 1,
2355 labels: ["bug"],
2356 applied: true,
2357 }),
2358 );
2359 assert.include(error.message, "You need triage access on this repository");
2360 assert.isFalse(changed);
2361 }),
2362);
2363
2364it.effect("hands a label change to the host, and reads the labels back for the menu", () =>
2365 Effect.gen(function* () {
2366 let received: { labels: ReadonlyArray<string>; applied: boolean } | null = null;
2367 const service = yield* makeService({
2368 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2369 providers: [
2370 fakeProvider("github", {
2371 capabilities: { ...fakeProvider("github").capabilities, labels: true },
2372 listLabelCandidates: () =>
2373 Effect.succeed({
2374 candidates: [{ name: "bug", color: null, description: null, isApplied: false }],
2375 truncated: false,
2376 }),
2377 setLabels: (input) => {
2378 received = { labels: input.labels, applied: input.applied };
2379 return Effect.void;
2380 },
2381 }),
2382 ],
2383 });
2384
2385 const list = yield* service.labelCandidates({
2386 projectId: "p1" as ProjectId,
2387 repository: "acme/web",
2388 number: 4,
2389 });
2390 assert.deepStrictEqual(
2391 list.candidates.map((label) => label.name),
2392 ["bug"],
2393 );
2394
2395 yield* service.setLabels({
2396 projectId: "p1" as ProjectId,
2397 repository: "acme/web",
2398 number: 4,
2399 labels: ["bug"],
2400 applied: false,
2401 });
2402 assert.deepStrictEqual(received, { labels: ["bug"], applied: false });
2403 }),
2404);
2405
2287it.effect("answers a repeated listing from cache, and concurrent readers share one request", () =>2406it.effect("answers a repeated listing from cache, and concurrent readers share one request", () =>
2288 Effect.gen(function* () {2407 Effect.gen(function* () {
2289 let hostCalls = 0;2408 let hostCalls = 0;
apps/server/src/pullRequest/PullRequestService.ts+87
...@@ -38,6 +38,8 @@ import {...@@ -38,6 +38,8 @@ import {
38 type PullRequestReviewVerdict,38 type PullRequestReviewVerdict,
39 type PullRequestReviewerCandidateList,39 type PullRequestReviewerCandidateList,
40 type PullRequestReviewerRequestInput,40 type PullRequestReviewerRequestInput,
41 type PullRequestLabelCandidateList,
42 type PullRequestLabelChangeInput,
41 type PullRequestSubmitReviewInput,43 type PullRequestSubmitReviewInput,
42 type PullRequestSummary,44 type PullRequestSummary,
43 type PullRequestThreadReplyInput,45 type PullRequestThreadReplyInput,
...@@ -170,6 +172,12 @@ export class PullRequestService extends Context.Service<...@@ -170,6 +172,12 @@ export class PullRequestService extends Context.Service<
170 readonly requestReviewers: (172 readonly requestReviewers: (
171 input: PullRequestReviewerRequestInput,173 input: PullRequestReviewerRequestInput,
172 ) => Effect.Effect<void, PullRequestError>;174 ) => Effect.Effect<void, PullRequestError>;
175 readonly labelCandidates: (
176 input: PullRequestRef,
177 ) => Effect.Effect<PullRequestLabelCandidateList, PullRequestError>;
178 readonly setLabels: (
179 input: PullRequestLabelChangeInput,
180 ) => Effect.Effect<void, PullRequestError>;
173 readonly invalidate: (input: PullRequestInvalidateInput) => Effect.Effect<void>;181 readonly invalidate: (input: PullRequestInvalidateInput) => Effect.Effect<void>;
174 }182 }
175>()("t3/pullRequest/PullRequestService") {}183>()("t3/pullRequest/PullRequestService") {}
...@@ -213,6 +221,7 @@ const ACTION_ACCESS_REFUSALS: Record<PullRequestAction, string> = {...@@ -213,6 +221,7 @@ const ACTION_ACCESS_REFUSALS: Record<PullRequestAction, string> = {
213 * sentence is only ever the answer where a host said no.221 * sentence is only ever the answer where a host said no.
214 */222 */
215const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review.";223const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review.";
224const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to change its labels.";
216225
217/** A project this page can read: its remote is on a host with an implementation. */226/** A project this page can read: its remote is on a host with an implementation. */
218interface SupportedProject {227interface SupportedProject {
...@@ -473,6 +482,10 @@ function withRateLimitBackoff(...@@ -473,6 +482,10 @@ function withRateLimitBackoff(
473 submitReview: interactive("submitReview", api.submitReview),482 submitReview: interactive("submitReview", api.submitReview),
474 listReviewerCandidates: interactive("listReviewerCandidates", api.listReviewerCandidates),483 listReviewerCandidates: interactive("listReviewerCandidates", api.listReviewerCandidates),
475 setReviewerRequest: interactive("setReviewerRequest", api.setReviewerRequest),484 setReviewerRequest: interactive("setReviewerRequest", api.setReviewerRequest),
485 ...(api.listLabelCandidates === undefined
486 ? {}
487 : { listLabelCandidates: interactive("listLabelCandidates", api.listLabelCandidates) }),
488 ...(api.setLabels === undefined ? {} : { setLabels: interactive("setLabels", api.setLabels) }),
476 replyToThread: interactive("replyToThread", api.replyToThread),489 replyToThread: interactive("replyToThread", api.replyToThread),
477 setReaction: interactive("setReaction", api.setReaction),490 setReaction: interactive("setReaction", api.setReaction),
478 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),491 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),
...@@ -1843,6 +1856,78 @@ export const make = Effect.gen(function* () {...@@ -1843,6 +1856,78 @@ export const make = Effect.gen(function* () {
1843 }),1856 }),
1844 );1857 );
18451858
1859 /**
1860 * The labels, like the reviewer candidates, are wanted only by somebody about to change them,
1861 * so the same permission guards the list and the change.
1862 */
1863 const labelCandidates: PullRequestService["Service"]["labelCandidates"] = (input) =>
1864 requireProject(input).pipe(
1865 Effect.flatMap((project): Effect.Effect<PullRequestLabelCandidateList, PullRequestError> => {
1866 const list = project.api.listLabelCandidates;
1867 if (project.api.capabilities.labels !== true || list === undefined) {
1868 return Effect.fail(
1869 new PullRequestOperationError({
1870 operation: "labelCandidates",
1871 detail: "This host cannot change the labels on a change request.",
1872 }),
1873 );
1874 }
1875 return viewerPermissionsOf(project, input, "labelCandidates").pipe(
1876 Effect.flatMap(
1877 (viewer): Effect.Effect<PullRequestLabelCandidateList, PullRequestError> =>
1878 viewer.labels === false
1879 ? Effect.fail(
1880 new PullRequestOperationError({
1881 operation: "labelCandidates",
1882 detail: LABEL_CHANGE_REFUSAL,
1883 }),
1884 )
1885 : list({
1886 cwd: project.project.workspaceRoot,
1887 repository: project.repository,
1888 host: project.host,
1889 number: input.number,
1890 }).pipe(Effect.mapError(toPullRequestError("labelCandidates"))),
1891 ),
1892 );
1893 }),
1894 );
1895
1896 const setLabels: PullRequestService["Service"]["setLabels"] = (input) =>
1897 requireProject(input).pipe(
1898 Effect.flatMap((project): Effect.Effect<void, PullRequestError> => {
1899 const change = project.api.setLabels;
1900 if (project.api.capabilities.labels !== true || change === undefined) {
1901 return Effect.fail(
1902 new PullRequestOperationError({
1903 operation: "setLabels",
1904 detail: "This host cannot change the labels on a change request.",
1905 }),
1906 );
1907 }
1908 return viewerPermissionsOf(project, input, "setLabels").pipe(
1909 Effect.flatMap(
1910 (viewer): Effect.Effect<void, PullRequestError> =>
1911 viewer.labels === false
1912 ? Effect.fail(
1913 new PullRequestOperationError({
1914 operation: "setLabels",
1915 detail: LABEL_CHANGE_REFUSAL,
1916 }),
1917 )
1918 : change({
1919 cwd: project.project.workspaceRoot,
1920 repository: project.repository,
1921 host: project.host,
1922 number: input.number,
1923 labels: input.labels,
1924 applied: input.applied,
1925 }).pipe(Effect.mapError(toPullRequestError("setLabels"))),
1926 ),
1927 );
1928 }),
1929 );
1930
1846 /**1931 /**
1847 * The line counts for rows already on the page, which the listing left out because on GitHub1932 * The line counts for rows already on the page, which the listing left out because on GitHub
1848 * they cost more than everything else on the row put together.1933 * they cost more than everything else on the row put together.
...@@ -2336,6 +2421,8 @@ export const make = Effect.gen(function* () {...@@ -2336,6 +2421,8 @@ export const make = Effect.gen(function* () {
2336 // The candidate list is deliberately read fresh per menu-open, so it stays uncached.2421 // The candidate list is deliberately read fresh per menu-open, so it stays uncached.
2337 reviewerCandidates,2422 reviewerCandidates,
2338 requestReviewers: invalidatedByMutation(requestReviewers),2423 requestReviewers: invalidatedByMutation(requestReviewers),
2424 labelCandidates,
2425 setLabels: invalidatedByMutation(setLabels),
2339 invalidate,2426 invalidate,
2340 });2427 });
2341});2428});
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+72-2
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 decodePullRequestListJson,11 decodePullRequestListJson,
12 decodePullRequestNodeIdJson,12 decodePullRequestNodeIdJson,
13 decodePullRequestSearchJson,13 decodePullRequestSearchJson,
14 decodeLabelCandidatesJson,
14 decodeRepositoryAccessJson,15 decodeRepositoryAccessJson,
15 decodeReviewerCandidatesJson,16 decodeReviewerCandidatesJson,
16 decodeReviewThreadCommentsJson,17 decodeReviewThreadCommentsJson,
...@@ -832,7 +833,7 @@ describe("viewer permission decoding", () => {...@@ -832,7 +833,7 @@ describe("viewer permission decoding", () => {
832 }),833 }),
833 ),834 ),
834 ),835 ),
835 ).toEqual({ canWrite: false, canUpdate: true, didAuthor: true });836 ).toEqual({ canWrite: false, canTriage: false, canUpdate: true, didAuthor: true });
836 });837 });
837838
838 it("says no to a passer-by on a repository they can only read", () => {839 it("says no to a passer-by on a repository they can only read", () => {
...@@ -845,7 +846,7 @@ describe("viewer permission decoding", () => {...@@ -845,7 +846,7 @@ describe("viewer permission decoding", () => {
845 }),846 }),
846 ),847 ),
847 ),848 ),
848 ).toEqual({ canWrite: false, canUpdate: false, didAuthor: false });849 ).toEqual({ canWrite: false, canTriage: false, canUpdate: false, didAuthor: false });
849 });850 });
850851
851 it("reads silence as permission, but not as authorship", () => {852 it("reads silence as permission, but not as authorship", () => {
...@@ -854,10 +855,79 @@ describe("viewer permission decoding", () => {...@@ -854,10 +855,79 @@ describe("viewer permission decoding", () => {
854 // and claiming it for someone who did not is how an author's own rules get handed out.855 // and claiming it for someone who did not is how an author's own rules get handed out.
855 expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({856 expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({
856 canWrite: false,857 canWrite: false,
858 canTriage: false,
857 canUpdate: true,859 canUpdate: true,
858 didAuthor: false,860 didAuthor: false,
859 });861 });
860 });862 });
863
864 it("reads triage as enough to label, and not enough to write", () => {
865 const access = expectSuccess(
866 decodeViewerPermissionsJson(
867 viewerJson({
868 viewerPermission: "TRIAGE",
869 pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false },
870 }),
871 ),
872 );
873 expect(access.canTriage).toBe(true);
874 expect(access.canWrite).toBe(false);
875 });
876});
877
878describe("label candidate decoding", () => {
879 const labelsJson = (input: {
880 readonly defined: ReadonlyArray<Record<string, unknown>>;
881 readonly applied?: ReadonlyArray<string>;
882 readonly hasNextPage?: boolean;
883 }) =>
884 JSON.stringify({
885 data: {
886 repository: {
887 labels: {
888 pageInfo: { hasNextPage: input.hasNextPage ?? false },
889 nodes: input.defined,
890 },
891 pullRequest: { labels: { nodes: (input.applied ?? []).map((name) => ({ name })) } },
892 },
893 },
894 });
895
896 it("marks the labels the pull request already wears", () => {
897 const list = expectSuccess(
898 decodeLabelCandidatesJson(
899 labelsJson({
900 defined: [
901 { name: "bug", color: "d73a4a", description: "Something is broken" },
902 { name: "size:XL", color: "e4572e", description: null },
903 ],
904 applied: ["size:XL"],
905 }),
906 ),
907 );
908 expect(list.candidates).toEqual([
909 { name: "bug", color: "d73a4a", description: "Something is broken", isApplied: false },
910 { name: "size:XL", color: "e4572e", description: null, isApplied: true },
911 ]);
912 expect(list.truncated).toBe(false);
913 });
914
915 it("keeps a worn label the repository no longer defines, so it can be taken off", () => {
916 const list = expectSuccess(
917 decodeLabelCandidatesJson(labelsJson({ defined: [{ name: "bug" }], applied: ["legacy"] })),
918 );
919 expect(list.candidates.map((label) => [label.name, label.isApplied])).toEqual([
920 ["legacy", true],
921 ["bug", false],
922 ]);
923 });
924
925 it("says so when the repository defines more labels than the read asked for", () => {
926 expect(
927 expectSuccess(decodeLabelCandidatesJson(labelsJson({ defined: [], hasNextPage: true })))
928 .truncated,
929 ).toBe(true);
930 });
861});931});
862932
863describe("review thread decoding", () => {933describe("review thread decoding", () => {
apps/server/src/pullRequest/gitHubPullRequestJson.ts+106
...@@ -24,6 +24,8 @@ import type {...@@ -24,6 +24,8 @@ import type {
24 PullRequestReviewerCandidate,24 PullRequestReviewerCandidate,
25 PullRequestReviewerCandidateList,25 PullRequestReviewerCandidateList,
26 PullRequestReviewerKind,26 PullRequestReviewerKind,
27 PullRequestLabelCandidate,
28 PullRequestLabelCandidateList,
27 PullRequestState,29 PullRequestState,
28 PullRequestThreadComment,30 PullRequestThreadComment,
29} from "@t3tools/contracts";31} from "@t3tools/contracts";
...@@ -1994,6 +1996,11 @@ function toCanWrite(viewerPermission: string | null | undefined): boolean {...@@ -1994,6 +1996,11 @@ function toCanWrite(viewerPermission: string | null | undefined): boolean {
1994 }1996 }
1995}1997}
19961998
1999/** Triage is the least role GitHub lets label a pull request; it is not a write. */
2000function toCanTriage(viewerPermission: string | null | undefined): boolean {
2001 return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission);
2002}
2003
1997export function decodeRepositoryAccessJson(2004export function decodeRepositoryAccessJson(
1998 raw: string,2005 raw: string,
1999): Result.Result<GitHubRepositoryAccess, DecodeFailure> {2006): Result.Result<GitHubRepositoryAccess, DecodeFailure> {
...@@ -2222,6 +2229,99 @@ export function buildReviewerRequestJson(...@@ -2222,6 +2229,99 @@ export function buildReviewerRequestJson(
2222 });2229 });
2223}2230}
22242231
2232export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
2233 repository(owner: $owner, name: $name) {
2234 labels(first: ${GRAPHQL_PAGE_SIZE}, orderBy: { field: NAME, direction: ASC }) {
2235 pageInfo { hasNextPage }
2236 nodes { name color description }
2237 }
2238 pullRequest(number: $number) {
2239 labels(first: ${GRAPHQL_PAGE_SIZE}) { nodes { name } }
2240 }
2241 }
2242}`;
2243
2244const RawLabelCandidatesSchema = Schema.Struct({
2245 data: Schema.Struct({
2246 repository: Schema.Struct({
2247 labels: Schema.optional(
2248 Schema.NullOr(
2249 Schema.Struct({
2250 pageInfo: Schema.optional(RawPageInfoSchema),
2251 nodes: Schema.Array(
2252 Schema.NullOr(
2253 Schema.Struct({
2254 ...RawLabelSchema.fields,
2255 description: Schema.optional(Schema.NullOr(Schema.String)),
2256 }),
2257 ),
2258 ),
2259 }),
2260 ),
2261 ),
2262 /** Null for a number that names no pull request the viewer can see. */
2263 pullRequest: Schema.NullOr(
2264 Schema.Struct({
2265 labels: Schema.optional(
2266 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLabelSchema)) })),
2267 ),
2268 }),
2269 ),
2270 }),
2271 }),
2272});
2273
2274const decodeLabelCandidates = decodeJsonResult(RawLabelCandidatesSchema);
2275
2276/**
2277 * The repository's labels, with the ones already on this pull request marked. A label the pull
2278 * request wears that the repository no longer defines — deleted since, or past the page — leads
2279 * the list anyway, because a label that cannot be seen cannot be taken off.
2280 */
2281export function decodeLabelCandidatesJson(
2282 raw: string,
2283): Result.Result<PullRequestLabelCandidateList, DecodeFailure> {
2284 const decoded = decodeLabelCandidates(raw);
2285 if (!Result.isSuccess(decoded)) {
2286 return Result.fail(decoded.failure);
2287 }
2288 const repository = decoded.success.data.repository;
2289 const applied = new Set(
2290 (repository.pullRequest?.labels?.nodes ?? []).flatMap((label) => {
2291 const name = trimmed(label?.name);
2292 return name === null ? [] : [name];
2293 }),
2294 );
2295 const candidates = new Map<string, PullRequestLabelCandidate>();
2296 for (const node of repository.labels?.nodes ?? []) {
2297 const name = trimmed(node?.name);
2298 if (name === null) continue;
2299 candidates.set(name, {
2300 name,
2301 color: trimmed(node?.color),
2302 description: trimmed(node?.description),
2303 isApplied: applied.has(name),
2304 });
2305 }
2306 const missing = [...applied].filter((name) => !candidates.has(name));
2307 return Result.succeed({
2308 candidates: [
2309 ...missing.map((name) => ({ name, color: null, description: null, isApplied: true })),
2310 ...candidates.values(),
2311 ],
2312 truncated: repository.labels?.pageInfo?.hasNextPage === true,
2313 });
2314}
2315
2316/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */
2317const LabelRequestSchema = Schema.Struct({ labels: Schema.Array(Schema.String) });
2318
2319const encodeLabelRequest = Schema.encodeSync(Schema.fromJsonString(LabelRequestSchema));
2320
2321export function buildLabelRequestJson(labels: ReadonlyArray<string>): string {
2322 return encodeLabelRequest({ labels });
2323}
2324
2225/**2325/**
2226 * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the2326 * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the
2227 * repository, the other two about this pull request in particular — which is why an author with2327 * repository, the other two about this pull request in particular — which is why an author with
...@@ -2229,6 +2329,11 @@ export function buildReviewerRequestJson(...@@ -2229,6 +2329,11 @@ export function buildReviewerRequestJson(
2229 */2329 */
2230export interface GitHubViewerAccess {2330export interface GitHubViewerAccess {
2231 readonly canWrite: boolean;2331 readonly canWrite: boolean;
2332 /**
2333 * The viewer's role reaches triage, which is the least that may label. Everyone who can write
2334 * can triage; a triager is the one role that can label without being able to merge.
2335 */
2336 readonly canTriage: boolean;
2232 /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */2337 /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */
2233 readonly canUpdate: boolean;2338 readonly canUpdate: boolean;
2234 readonly didAuthor: boolean;2339 readonly didAuthor: boolean;
...@@ -2275,6 +2380,7 @@ export function decodeViewerPermissionsJson(...@@ -2275,6 +2380,7 @@ export function decodeViewerPermissionsJson(
2275 const repository = decoded.success.data.repository;2380 const repository = decoded.success.data.repository;
2276 return Result.succeed({2381 return Result.succeed({
2277 canWrite: toCanWrite(repository.viewerPermission),2382 canWrite: toCanWrite(repository.viewerPermission),
2383 canTriage: toCanTriage(repository.viewerPermission),
2278 ...toPullRequestViewerFields(repository.pullRequest),2384 ...toPullRequestViewerFields(repository.pullRequest),
2279 });2385 });
2280}2386}
apps/server/src/ws.ts+10
...@@ -1976,6 +1976,16 @@ const makeWsRpcLayer = (...@@ -1976,6 +1976,16 @@ const makeWsRpcLayer = (
1976 pullRequests.requestReviewers(input),1976 pullRequests.requestReviewers(input),
1977 { "rpc.aggregate": "pull-requests" },1977 { "rpc.aggregate": "pull-requests" },
1978 ),1978 ),
1979 [WS_METHODS.pullRequestsLabelCandidates]: (input) =>
1980 observeRpcEffect(
1981 WS_METHODS.pullRequestsLabelCandidates,
1982 pullRequests.labelCandidates(input),
1983 { "rpc.aggregate": "pull-requests" },
1984 ),
1985 [WS_METHODS.pullRequestsSetLabels]: (input) =>
1986 observeRpcEffect(WS_METHODS.pullRequestsSetLabels, pullRequests.setLabels(input), {
1987 "rpc.aggregate": "pull-requests",
1988 }),
1979 [WS_METHODS.sourceControlLookupRepository]: (input) =>1989 [WS_METHODS.sourceControlLookupRepository]: (input) =>
1980 observeRpcEffect(1990 observeRpcEffect(
1981 WS_METHODS.sourceControlLookupRepository,1991 WS_METHODS.sourceControlLookupRepository,
apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx created+136
...@@ -0,0 +1,136 @@
1/**
2 * The menu shell the reviewer and label pickers share: an icon trigger, a search box, and a
3 * scrolling body that says when the list is loading, could not be read, is empty, or is not all
4 * of it. The rows and the words are the caller's; the frame is the same either way.
5 */
6import type { ReactNode } from "react";
7
8import { Button } from "../ui/button";
9import { Input } from "../ui/input";
10import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12import { PullRequestPeopleGhost } from "./PullRequestGhosts";
13
14export function PullRequestCandidatePicker<T>({
15 icon,
16 label,
17 allowed,
18 disabledReason,
19 open,
20 onOpenChange,
21 query,
22 onQueryChange,
23 searchLabel,
24 isPending,
25 error,
26 candidates,
27 emptyLabel,
28 noMatchLabel,
29 errorLabel,
30 truncated,
31 truncatedLabel,
32 candidateKey,
33 disabled,
34 onSelect,
35 children,
36}: {
37 icon: ReactNode;
38 /** The trigger's accessible name; the button carries an icon alone. */
39 label: string;
40 /** False where the host would refuse this account's change. Disabled with the reason rather
41 * than hidden: a control that vanishes teaches nobody why. */
42 allowed: boolean;
43 disabledReason: string;
44 open: boolean;
45 onOpenChange: (open: boolean) => void;
46 query: string;
47 onQueryChange: (query: string) => void;
48 searchLabel: string;
49 isPending: boolean;
50 error: string | null;
51 /** Already narrowed by the query; the shell only decides which state to show. */
52 candidates: ReadonlyArray<T>;
53 emptyLabel: string;
54 noMatchLabel: string;
55 /** Leads the host's own message, which follows it in the same sentence. */
56 errorLabel: string;
57 /** The host has more than the read asked for, so a name missing here may still be askable. */
58 truncated: boolean;
59 truncatedLabel: string;
60 candidateKey: (candidate: T) => string;
61 /** Every row locks while one change is in flight, so a second press cannot race the first. */
62 disabled: boolean;
63 onSelect: (candidate: T) => void;
64 children: (candidate: T) => ReactNode;
65}) {
66 if (!allowed) {
67 return (
68 <Tooltip>
69 <TooltipTrigger
70 render={
71 <Button size="icon-xs" variant="ghost" disabled aria-label={label}>
72 {icon}
73 </Button>
74 }
75 />
76 <TooltipPopup side="bottom">{disabledReason}</TooltipPopup>
77 </Tooltip>
78 );
79 }
80
81 return (
82 <Menu open={open} onOpenChange={onOpenChange}>
83 <MenuTrigger
84 render={
85 <Button size="icon-xs" variant="ghost" aria-label={label}>
86 {icon}
87 </Button>
88 }
89 />
90 <MenuPopup align="start" side="bottom" className="w-72 p-0">
91 <div className="border-b border-border/60 p-2">
92 <Input
93 autoFocus
94 value={query}
95 onChange={(event) => onQueryChange(event.currentTarget.value)}
96 placeholder={searchLabel}
97 aria-label={searchLabel}
98 size="compact"
99 />
100 </div>
101 <div className="max-h-72 overflow-y-auto p-1">
102 {isPending ? (
103 <PullRequestPeopleGhost rows={4} />
104 ) : error !== null ? (
105 <p className="p-2 text-xs text-muted-foreground">
106 {errorLabel} {error}
107 </p>
108 ) : candidates.length === 0 ? (
109 <p className="p-2 text-xs text-muted-foreground">
110 {query.length > 0 ? noMatchLabel : emptyLabel}
111 </p>
112 ) : (
113 candidates.map((candidate) => (
114 // Stays open on press: a change is confirmed by the row's own check turning over,
115 // and a second label or reviewer is usually wanted right after the first.
116 <MenuItem
117 key={candidateKey(candidate)}
118 closeOnClick={false}
119 disabled={disabled}
120 onClick={() => onSelect(candidate)}
121 className="min-h-0 py-1.5 text-xs sm:min-h-0 sm:text-xs"
122 >
123 {children(candidate)}
124 </MenuItem>
125 ))
126 )}
127 {truncated ? (
128 // Typing filters what arrived; it does not ask the host again, so this says what the
129 // list is rather than offering a search that would find nothing further.
130 <p className="px-2 py-1.5 text-xs text-muted-foreground">{truncatedLabel}</p>
131 ) : null}
132 </div>
133 </MenuPopup>
134 </Menu>
135 );
136}
apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx created+132
...@@ -0,0 +1,132 @@
1/**
2 * Putting a label on, and taking one off, from the row that says which it already wears.
3 *
4 * The repository's labels are read only once this menu opens, for the reason the reviewer menu
5 * reads its people then: they are worth a request when somebody wants them and worth nothing on
6 * every pull request they merely open.
7 */
8import type { EnvironmentId, PullRequestLabelCandidate, PullRequestRef } from "@t3tools/contracts";
9import { CheckIcon, TagIcon } from "lucide-react";
10import { useMemo, useState } from "react";
11
12import { pullRequestEnvironment } from "~/state/pullRequests";
13import { useEnvironmentQuery } from "~/state/query";
14import { useAtomCommand } from "~/state/use-atom-command";
15import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
16
17import { toastManager } from "../ui/toast";
18import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker";
19import { readableFailure } from "./pullRequestDetail.logic";
20import { pullRequestLabelColor } from "./pullRequestList.logic";
21
22/** Narrows only what arrived: the host is asked once, when the menu opens. */
23function matches(candidate: PullRequestLabelCandidate, query: string): boolean {
24 if (query.length === 0) return true;
25 const needle = query.toLowerCase();
26 return (
27 candidate.name.toLowerCase().includes(needle) ||
28 (candidate.description ?? "").toLowerCase().includes(needle)
29 );
30}
31
32export function PullRequestLabelPicker({
33 environmentId,
34 reference,
35 allowed,
36 onChanged,
37}: {
38 environmentId: EnvironmentId;
39 reference: PullRequestRef;
40 /** False where the host would refuse this account's change. Disabled with the reason rather
41 * than hidden, like the reviewer control beside it. */
42 allowed: boolean;
43 /** The detail carries the labels, so it is re-read once the host has taken the change. */
44 onChanged: () => void;
45}) {
46 const [open, setOpen] = useState(false);
47 const [query, setQuery] = useState("");
48 const [pending, setPending] = useState<string | null>(null);
49
50 // Mounted with the menu closed, so nothing is asked of the host until it opens.
51 const candidatesQuery = useEnvironmentQuery(
52 open ? pullRequestEnvironment.labelCandidates({ environmentId, input: reference }) : null,
53 );
54 const setLabels = useAtomCommand(pullRequestEnvironment.setLabels, { reportFailure: false });
55
56 const candidates = useMemo(
57 () => (candidatesQuery.data?.candidates ?? []).filter((entry) => matches(entry, query)),
58 [candidatesQuery.data, query],
59 );
60
61 const toggle = async (candidate: PullRequestLabelCandidate) => {
62 if (pending !== null) return;
63 setPending(candidate.name);
64 const result = await setLabels({
65 environmentId,
66 input: { ...reference, labels: [candidate.name], applied: !candidate.isApplied },
67 });
68 setPending(null);
69 if (result._tag === "Failure") {
70 toastManager.add({
71 type: "error",
72 title: candidate.isApplied
73 ? `Could not take ${candidate.name} off`
74 : `Could not put ${candidate.name} on`,
75 description: readableFailure(
76 squashAtomCommandFailure(result),
77 "The host refused it. Check that you have triage access on this repository.",
78 ),
79 });
80 return;
81 }
82 onChanged();
83 candidatesQuery.refresh();
84 };
85
86 return (
87 <PullRequestCandidatePicker
88 icon={<TagIcon className="size-3.5" />}
89 label="Change labels"
90 allowed={allowed}
91 disabledReason="Changing labels needs triage access on this repository"
92 open={open}
93 onOpenChange={setOpen}
94 query={query}
95 onQueryChange={setQuery}
96 searchLabel="Search labels"
97 isPending={candidatesQuery.isPending}
98 error={candidatesQuery.error}
99 candidates={candidates}
100 emptyLabel="This repository has no labels."
101 noMatchLabel="No label matches that."
102 errorLabel="The labels could not be read."
103 truncated={candidatesQuery.data?.truncated === true}
104 truncatedLabel="This repository has more labels than are listed here. Apply the rest on the host."
105 candidateKey={(candidate) => candidate.name}
106 disabled={pending !== null}
107 onSelect={(candidate) => void toggle(candidate)}
108 >
109 {(candidate) => {
110 const dot = pullRequestLabelColor(candidate.color);
111 return (
112 <>
113 <span
114 aria-hidden
115 className="size-2 shrink-0 rounded-full bg-muted-foreground"
116 {...(dot ? { style: { backgroundColor: dot } } : {})}
117 />
118 <span className="min-w-0 flex-1 truncate">
119 {candidate.name}
120 {candidate.description ? (
121 <span className="text-muted-foreground"> · {candidate.description}</span>
122 ) : null}
123 </span>
124 {candidate.isApplied ? (
125 <CheckIcon aria-label="Applied" className="size-3.5 shrink-0" />
126 ) : null}
127 </>
128 );
129 }}
130 </PullRequestCandidatePicker>
131 );
132}
apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx+34-83
...@@ -18,12 +18,8 @@ import { useEnvironmentQuery } from "~/state/query";...@@ -18,12 +18,8 @@ import { useEnvironmentQuery } from "~/state/query";
18import { useAtomCommand } from "~/state/use-atom-command";18import { useAtomCommand } from "~/state/use-atom-command";
19import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";19import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2020
21import { Button } from "../ui/button";
22import { Input } from "../ui/input";
23import { Menu, MenuPopup, MenuTrigger } from "../ui/menu";
24import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
25import { toastManager } from "../ui/toast";21import { toastManager } from "../ui/toast";
26import { PullRequestPeopleGhost } from "./PullRequestGhosts";22import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker";
27import { PullRequestActorLabel } from "./pullRequestPresentation";23import { PullRequestActorLabel } from "./pullRequestPresentation";
28import { readableFailure } from "./pullRequestDetail.logic";24import { readableFailure } from "./pullRequestDetail.logic";
2925
...@@ -104,85 +100,40 @@ export function PullRequestReviewerPicker({...@@ -104,85 +100,40 @@ export function PullRequestReviewerPicker({
104 candidatesQuery.refresh();100 candidatesQuery.refresh();
105 };101 };
106102
107 if (!allowed) {
108 return (
109 <Tooltip>
110 <TooltipTrigger
111 render={
112 <Button size="icon-xs" variant="ghost" disabled aria-label="Request a review">
113 <UserPlusIcon className="size-3.5" />
114 </Button>
115 }
116 />
117 <TooltipPopup side="bottom">
118 Asking someone to review needs write access on this repository
119 </TooltipPopup>
120 </Tooltip>
121 );
122 }
123
124 return (103 return (
125 <Menu open={open} onOpenChange={setOpen}>104 <PullRequestCandidatePicker
126 <MenuTrigger105 icon={<UserPlusIcon className="size-3.5" />}
127 render={106 label="Request a review"
128 <Button size="icon-xs" variant="ghost" aria-label="Request a review">107 allowed={allowed}
129 <UserPlusIcon className="size-3.5" />108 disabledReason="Asking someone to review needs write access on this repository"
130 </Button>109 open={open}
131 }110 onOpenChange={setOpen}
132 />111 query={query}
133 <MenuPopup align="start" side="bottom" className="w-72 p-0">112 onQueryChange={setQuery}
134 <div className="border-b border-border/60 p-2">113 searchLabel="Search people with access"
135 <Input114 isPending={candidatesQuery.isPending}
136 autoFocus115 error={candidatesQuery.error}
137 value={query}116 candidates={candidates}
138 onChange={(event) => setQuery(event.currentTarget.value)}117 emptyLabel="Nobody else has access to this repository."
139 placeholder="Search people with access"118 noMatchLabel="Nobody with access matches that."
140 aria-label="Search people with access"119 errorLabel="The people with access could not be read."
141 size="compact"120 truncated={candidatesQuery.data?.truncated === true}
142 />121 truncatedLabel="This repository has more people with access than are listed here. Ask for the rest on the host."
143 </div>122 candidateKey={(candidate) => `${candidate.kind}:${candidate.id}`}
144 <div className="max-h-72 overflow-y-auto p-1">123 disabled={pending !== null}
145 {candidatesQuery.isPending ? (124 onSelect={(candidate) => void toggle(candidate)}
146 <PullRequestPeopleGhost rows={4} />125 >
147 ) : candidatesQuery.error !== null ? (126 {(candidate) => (
148 <p className="p-2 text-xs text-muted-foreground">127 <>
149 The people with access could not be read. {candidatesQuery.error}128 <PullRequestActorLabel actor={candidate} className="min-w-0 flex-1 truncate" />
150 </p>129 {candidate.kind === "team" ? (
151 ) : candidates.length === 0 ? (130 <span className="shrink-0 text-muted-foreground">team</span>
152 <p className="p-2 text-xs text-muted-foreground">131 ) : null}
153 {query.length > 0132 {candidate.isRequested ? (
154 ? "Nobody with access matches that."133 <CheckIcon aria-label="Already asked" className="size-3.5 shrink-0" />
155 : "Nobody else has access to this repository."}
156 </p>
157 ) : (
158 candidates.map((candidate) => (
159 <button
160 key={`${candidate.kind}:${candidate.id}`}
161 type="button"
162 disabled={pending !== null}
163 onClick={() => void toggle(candidate)}
164 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent/60 disabled:opacity-60"
165 >
166 <PullRequestActorLabel actor={candidate} className="min-w-0 flex-1 truncate" />
167 {candidate.kind === "team" ? (
168 <span className="shrink-0 text-muted-foreground">team</span>
169 ) : null}
170 {candidate.isRequested ? (
171 <CheckIcon aria-label="Already asked" className="size-3.5 shrink-0" />
172 ) : null}
173 </button>
174 ))
175 )}
176 {candidatesQuery.data?.truncated ? (
177 // Typing filters what arrived; it does not ask the host again, so this says what the
178 // list is rather than offering a search that would find nothing further.
179 <p className="px-2 py-1.5 text-xs text-muted-foreground">
180 This repository has more people with access than are listed here. Ask for the rest on
181 the host.
182 </p>
183 ) : null}134 ) : null}
184 </div>135 </>
185 </MenuPopup>136 )}
186 </Menu>137 </PullRequestCandidatePicker>
187 );138 );
188}139}
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+31-16
...@@ -41,6 +41,7 @@ import {...@@ -41,6 +41,7 @@ import {
41 pullRequestReviewOutcomeRingClassName,41 pullRequestReviewOutcomeRingClassName,
42 pullRequestReviewOutcomeStaleLabel,42 pullRequestReviewOutcomeStaleLabel,
43} from "./pullRequestPresentation";43} from "./pullRequestPresentation";
44import { PullRequestLabelPicker } from "./PullRequestLabelPicker";
44import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker";45import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker";
45import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";46import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
46import {47import {
...@@ -664,25 +665,39 @@ export function PullRequestSummaryTab({...@@ -664,25 +665,39 @@ export function PullRequestSummaryTab({
664 ) : null}665 ) : null}
665 </span>666 </span>
666 </MetaRow>667 </MetaRow>
667 {detail.labels.length > 0 ? (668 {/* The row is shown empty only where a label could be put on it from here; on a host
669 with none to offer, an empty row is a row about nothing. */}
670 {detail.labels.length > 0 || detail.capabilities.labels === true ? (
668 <MetaRow icon={<TagIcon className="size-3.5" />} label="Labels">671 <MetaRow icon={<TagIcon className="size-3.5" />} label="Labels">
669 <span className="flex min-w-0 flex-wrap items-center gap-1">672 <span className="flex min-w-0 flex-wrap items-center gap-1">
670 {detail.labels.map((label) => {673 {detail.labels.length === 0 ? (
671 const dot = pullRequestLabelColor(label.color);674 <span className="text-muted-foreground">None</span>
672 return (675 ) : (
673 <span676 detail.labels.map((label) => {
674 key={label.name}677 const dot = pullRequestLabelColor(label.color);
675 className="inline-flex max-w-48 items-center gap-1.5 rounded-full border border-border/70 bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs"678 return (
676 >
677 <span679 <span
678 aria-hidden680 key={label.name}
679 className="size-2 shrink-0 rounded-full bg-muted-foreground"681 className="inline-flex max-w-48 items-center gap-1.5 rounded-full border border-border/70 bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs"
680 {...(dot ? { style: { backgroundColor: dot } } : {})}682 >
681 />683 <span
682 <span className="truncate">{label.name}</span>684 aria-hidden
683 </span>685 className="size-2 shrink-0 rounded-full bg-muted-foreground"
684 );686 {...(dot ? { style: { backgroundColor: dot } } : {})}
685 })}687 />
688 <span className="truncate">{label.name}</span>
689 </span>
690 );
691 })
692 )}
693 {detail.capabilities.labels === true ? (
694 <PullRequestLabelPicker
695 environmentId={environmentId}
696 reference={reference}
697 allowed={detail.viewerPermissions.labels !== false}
698 onChanged={onRefresh}
699 />
700 ) : null}
686 </span>701 </span>
687 </MetaRow>702 </MetaRow>
688 ) : null}703 ) : null}
docs/user/source-control.md+2
...@@ -73,6 +73,8 @@ T3 Code works with the platforms your team already uses:...@@ -73,6 +73,8 @@ T3 Code works with the platforms your team already uses:
73- Rewrite your own comments the same way, wherever they are shown73- Rewrite your own comments the same way, wherever they are shown
74- Works on GitHub, GitLab, and Bitbucket. Azure DevOps takes a new title and description; its74- Works on GitHub, GitLab, and Bitbucket. Azure DevOps takes a new title and description; its
75 comments stay read-only here, as they already were75 comments stay read-only here, as they already were
76- On GitHub, put a label on a pull request or take one off from the **Labels** row of the review.
77 Changing labels needs triage access or better on the repository
7678
77### Know Your Setup at a Glance79### Know Your Setup at a Glance
7880
packages/client-runtime/src/state/pullRequests.ts+12
...@@ -204,6 +204,18 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -204,6 +204,18 @@ export function createPullRequestEnvironmentAtoms<R, E>(
204 scheduler: commandScheduler,204 scheduler: commandScheduler,
205 concurrency: serialPerEnvironment,205 concurrency: serialPerEnvironment,
206 }),206 }),
207 /** Read when the label menu opens, and kept for a minute, like the reviewer candidates. */
208 labelCandidates: createEnvironmentRpcQueryAtomFamily(runtime, {
209 label: "environment-data:pull-requests:label-candidates",
210 tag: WS_METHODS.pullRequestsLabelCandidates,
211 staleTimeMs: 60_000,
212 }),
213 setLabels: createEnvironmentRpcCommand(runtime, {
214 label: "environment-data:pull-requests:set-labels",
215 tag: WS_METHODS.pullRequestsSetLabels,
216 scheduler: commandScheduler,
217 concurrency: serialPerEnvironment,
218 }),
207 setThreadResolution: createEnvironmentRpcCommand(runtime, {219 setThreadResolution: createEnvironmentRpcCommand(runtime, {
208 label: "environment-data:pull-requests:set-thread-resolution",220 label: "environment-data:pull-requests:set-thread-resolution",
209 tag: WS_METHODS.pullRequestsSetThreadResolution,221 tag: WS_METHODS.pullRequestsSetThreadResolution,
packages/contracts/src/pullRequest.ts+39
...@@ -291,6 +291,21 @@ export const PullRequestReviewerCandidateList = Schema.Struct({...@@ -291,6 +291,21 @@ export const PullRequestReviewerCandidateList = Schema.Struct({
291});291});
292export type PullRequestReviewerCandidateList = typeof PullRequestReviewerCandidateList.Type;292export type PullRequestReviewerCandidateList = typeof PullRequestReviewerCandidateList.Type;
293293
294/** A label the repository defines, with whether this change request already wears it. */
295export const PullRequestLabelCandidate = Schema.Struct({
296 ...PullRequestLabel.fields,
297 description: Schema.NullOr(Schema.String),
298 isApplied: Schema.Boolean,
299});
300export type PullRequestLabelCandidate = typeof PullRequestLabelCandidate.Type;
301
302export const PullRequestLabelCandidateList = Schema.Struct({
303 candidates: Schema.Array(PullRequestLabelCandidate),
304 /** The repository defines more labels than the read asked for; the list is not all of them. */
305 truncated: Schema.Boolean,
306});
307export type PullRequestLabelCandidateList = typeof PullRequestLabelCandidateList.Type;
308
294export const PullRequestCommit = Schema.Struct({309export const PullRequestCommit = Schema.Struct({
295 oid: TrimmedNonEmptyString,310 oid: TrimmedNonEmptyString,
296 messageHeadline: Schema.String,311 messageHeadline: Schema.String,
...@@ -397,6 +412,12 @@ export const PullRequestCapabilities = Schema.Struct({...@@ -397,6 +412,12 @@ export const PullRequestCapabilities = Schema.Struct({
397 * what every server before this one was.412 * what every server before this one was.
398 */413 */
399 edit: Schema.optional(PullRequestEditCapabilities),414 edit: Schema.optional(PullRequestEditCapabilities),
415 /**
416 * The repository's labels can be listed, and one put on a change request or taken off it.
417 * Optional for the same reason `edit` is: a server that says nothing about labels has no way
418 * to change them, which is what every server before this field was.
419 */
420 labels: Schema.optional(Schema.Boolean),
400});421});
401export type PullRequestCapabilities = typeof PullRequestCapabilities.Type;422export type PullRequestCapabilities = typeof PullRequestCapabilities.Type;
402423
...@@ -426,6 +447,12 @@ export const PullRequestViewerPermissions = Schema.Struct({...@@ -426,6 +447,12 @@ export const PullRequestViewerPermissions = Schema.Struct({
426 * Absent or empty means they may not, which is also what a host with no such action says.447 * Absent or empty means they may not, which is also what a host with no such action says.
427 */448 */
428 updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)),449 updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)),
450 /**
451 * This viewer may put a label on the change request, and take one off. Absent is granted, like
452 * every permission here; the capability beside it is what decides whether a label can be
453 * changed on this host at all.
454 */
455 labels: Schema.optional(Schema.Boolean),
429});456});
430export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type;457export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type;
431458
...@@ -998,6 +1025,18 @@ export const PullRequestReviewerRequestInput = Schema.Struct({...@@ -998,6 +1025,18 @@ export const PullRequestReviewerRequestInput = Schema.Struct({
998});1025});
999export type PullRequestReviewerRequestInput = typeof PullRequestReviewerRequestInput.Type;1026export type PullRequestReviewerRequestInput = typeof PullRequestReviewerRequestInput.Type;
10001027
1028/**
1029 * Putting a label on and taking it off are one operation with `applied` turned around, which is
1030 * what pressing the same row in the menu twice is. Named by the label's own name, which is how
1031 * GitHub addresses one.
1032 */
1033export const PullRequestLabelChangeInput = Schema.Struct({
1034 ...PullRequestRef.fields,
1035 labels: Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1), Schema.isMaxLength(25)),
1036 applied: Schema.Boolean,
1037});
1038export type PullRequestLabelChangeInput = typeof PullRequestLabelChangeInput.Type;
1039
1001export const PullRequestUnavailableReason = Schema.Literals([1040export const PullRequestUnavailableReason = Schema.Literals([
1002 "cli-missing",1041 "cli-missing",
1003 "cli-unauthenticated",1042 "cli-unauthenticated",
packages/contracts/src/rpc.ts+19
...@@ -100,6 +100,8 @@ import {...@@ -100,6 +100,8 @@ import {
100 PullRequestSummary,100 PullRequestSummary,
101 PullRequestReviewerCandidateList,101 PullRequestReviewerCandidateList,
102 PullRequestReviewerRequestInput,102 PullRequestReviewerRequestInput,
103 PullRequestLabelCandidateList,
104 PullRequestLabelChangeInput,
103 PullRequestSubmitReviewInput,105 PullRequestSubmitReviewInput,
104 PullRequestThreadCommentsInput,106 PullRequestThreadCommentsInput,
105 PullRequestThreadCommentsResult,107 PullRequestThreadCommentsResult,
...@@ -319,6 +321,8 @@ export const WS_METHODS = {...@@ -319,6 +321,8 @@ export const WS_METHODS = {
319 pullRequestsInvalidate: "pullRequests.invalidate",321 pullRequestsInvalidate: "pullRequests.invalidate",
320 pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates",322 pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates",
321 pullRequestsRequestReviewers: "pullRequests.requestReviewers",323 pullRequestsRequestReviewers: "pullRequests.requestReviewers",
324 pullRequestsLabelCandidates: "pullRequests.labelCandidates",
325 pullRequestsSetLabels: "pullRequests.setLabels",
322326
323 // Source control methods327 // Source control methods
324 sourceControlLookupRepository: "sourceControl.lookupRepository",328 sourceControlLookupRepository: "sourceControl.lookupRepository",
...@@ -630,6 +634,19 @@ export const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequest...@@ -630,6 +634,19 @@ export const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequest
630 error: PullRequestRpcError,634 error: PullRequestRpcError,
631});635});
632636
637/** Read when the label menu opens, for the same reason the reviewer candidates are. */
638export const WsPullRequestsLabelCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsLabelCandidates, {
639 payload: PullRequestRef,
640 success: PullRequestLabelCandidateList,
641 error: PullRequestRpcError,
642});
643
644export const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, {
645 payload: PullRequestLabelChangeInput,
646 success: Schema.Void,
647 error: PullRequestRpcError,
648});
649
633export const WsSourceControlLookupRepositoryRpc = Rpc.make(650export const WsSourceControlLookupRepositoryRpc = Rpc.make(
634 WS_METHODS.sourceControlLookupRepository,651 WS_METHODS.sourceControlLookupRepository,
635 {652 {
...@@ -1087,6 +1104,8 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1087,6 +1104,8 @@ export const WsRpcGroup = RpcGroup.make(
1087 WsPullRequestsInvalidateRpc,1104 WsPullRequestsInvalidateRpc,
1088 WsPullRequestsReviewerCandidatesRpc,1105 WsPullRequestsReviewerCandidatesRpc,
1089 WsPullRequestsRequestReviewersRpc,1106 WsPullRequestsRequestReviewersRpc,
1107 WsPullRequestsLabelCandidatesRpc,
1108 WsPullRequestsSetLabelsRpc,
1090 WsSourceControlLookupRepositoryRpc,1109 WsSourceControlLookupRepositoryRpc,
1091 WsSourceControlCloneRepositoryRpc,1110 WsSourceControlCloneRepositoryRpc,
1092 WsSourceControlPublishRepositoryRpc,1111 WsSourceControlPublishRepositoryRpc,