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 = {
7575 // write like every other one.
7676 [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,
7777 [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope,
78 [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,
79 [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope,
7880 [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
7981 [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
8082 [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+64-2
......@@ -2726,7 +2726,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
27262726 // One request, because both answers hang off the same repository object.
27272727 assert.strictEqual(mockedExecute.mock.calls.length, 1);
27282728 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 });
27302735 }),
27312736 );
27322737
......@@ -2889,7 +2894,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
28892894 });
28902895
28912896 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 });
28932903 yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z"));
28942904 }),
28952905 );
......@@ -3029,4 +3039,56 @@ layer("GitHubPullRequestCli.layer", (it) => {
30293039 ]);
30303040 }),
30313041 );
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 );
30323094});
apps/server/src/pullRequest/GitHubPullRequestCli.ts+72
......@@ -18,6 +18,7 @@ import {
1818 type PullRequestReviewVerdict,
1919 type PullRequestReviewerCandidateList,
2020 type PullRequestReviewerKind,
21 type PullRequestLabelCandidateList,
2122 type PullRequestThreadCommentsResult,
2223 type PullRequestUpdateMethod,
2324} from "@t3tools/contracts";
......@@ -42,6 +43,9 @@ import {
4243 decodeReactionSubjectScopeJson,
4344 decodeRepositoryAccessJson,
4445 decodeReviewerCandidatesJson,
46 decodeLabelCandidatesJson,
47 buildLabelRequestJson,
48 LABEL_CANDIDATES_GRAPHQL_QUERY,
4549 decodeReviewDismissalsJson,
4650 decodeReviewThreadCommentsJson,
4751 decodeReviewThreadsJson,
......@@ -597,6 +601,24 @@ export class GitHubPullRequestCli extends Context.Service<
597601 readonly requested: boolean;
598602 }) => 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
600622 readonly runPullRequestAction: (input: {
601623 readonly cwd: string;
602624 readonly repository: string;
......@@ -1977,6 +1999,56 @@ export const make = Effect.gen(function* () {
19771999 .pipe(Effect.asVoid);
19782000 },
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
19802052 runPullRequestAction: (input) => {
19812053 if (input.action === "revert") {
19822054 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", () =>
4747
4848describe("gitHubViewerPermissions", () => {
4949 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({
5158 // Arming a merge for later is the merge, so it travels with it.
5259 actions: [
5360 "merge",
......@@ -64,6 +71,7 @@ describe("gitHubViewerPermissions", () => {
6471 resolve: true,
6572 verdicts: ["comment", "approve", "request-changes"],
6673 requestReviewers: true,
74 labels: true,
6775 });
6876 });
6977
......@@ -71,7 +79,12 @@ describe("gitHubViewerPermissions", () => {
7179 // Every open-source pull request somebody else opened: GitHub says no to all five actions
7280 // and to resolving, and yes to commenting and to every verdict.
7381 expect(
74 gitHubViewerPermissions({ canWrite: false, canUpdate: false, didAuthor: false }),
82 gitHubViewerPermissions({
83 canWrite: false,
84 canTriage: false,
85 canUpdate: false,
86 didAuthor: false,
87 }),
7588 ).toEqual({
7689 actions: [],
7790 comment: true,
......@@ -79,11 +92,31 @@ describe("gitHubViewerPermissions", () => {
7992 verdicts: ["comment", "approve", "request-changes"],
8093 // Asking somebody else to review is the one thing read access never stretches to.
8194 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,
82105 });
106 expect(permissions.labels).toBe(true);
107 expect(permissions.requestReviewers).toBe(false);
108 expect(permissions.actions).toEqual([]);
83109 });
84110
85111 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({
87120 // Merging is the one thing writing is needed for, now or later; the rest an author may do.
88121 actions: ["ready", "draft", "close", "reopen"],
89122 comment: true,
......@@ -91,6 +124,7 @@ describe("gitHubViewerPermissions", () => {
91124 // GitHub refuses an author's approval of their own change, so the page does not offer one.
92125 verdicts: ["comment"],
93126 requestReviewers: false,
127 labels: false,
94128 });
95129 });
96130
......@@ -110,6 +144,7 @@ describe("gitHubViewerPermissions", () => {
110144 resolve: false,
111145 verdicts: ["comment", "approve", "request-changes"],
112146 requestReviewers: false,
147 labels: false,
113148 });
114149 expect(detail.workflowApprovalsRequired).toBeUndefined();
115150 expect(detail.checks).toContainEqual({
......@@ -158,7 +193,12 @@ describe("gitHubViewerPermissions", () => {
158193 mergeCapabilities: { merge: true, squash: true, rebase: true },
159194 }),
160195 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 }),
162202 }),
163203 ),
164204 ),
......@@ -254,7 +294,7 @@ describe("gitHubViewerPermissions", () => {
254294 mergeCapabilities: { merge: true, squash: true, rebase: true },
255295 }),
256296 getViewerAccess: () =>
257 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
297 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
258298 }),
259299 ),
260300 ),
......@@ -318,7 +358,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals",
318358 mergeCapabilities: { merge: true, squash: true, rebase: true },
319359 }),
320360 getViewerAccess: () =>
321 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
361 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
322362 }),
323363 ),
324364 ),
......@@ -365,7 +405,7 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>
365405 mergeCapabilities: { merge: true, squash: true, rebase: true },
366406 }),
367407 getViewerAccess: () =>
368 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
408 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
369409 }),
370410 ),
371411 ),
......@@ -404,7 +444,7 @@ it.effect("propagates workflow discovery rate limits", () =>
404444 mergeCapabilities: { merge: true, squash: true, rebase: true },
405445 }),
406446 getViewerAccess: () =>
407 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
447 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
408448 }),
409449 ),
410450 ),
......@@ -420,7 +460,8 @@ describe("getViewerPermissions", () => {
420460 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
421461 getPullRequestDetail: () => Effect.succeed(openDetail),
422462 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 }),
424465 });
425466
426467 it.effect("offers update-branch when the comparison grants it", () =>
......@@ -466,7 +507,7 @@ describe("getViewerPermissions", () => {
466507 getViewerAccess: (input) =>
467508 Effect.sync(() => {
468509 viewerAllowReserve = input.allowReserve;
469 return { canWrite: true, canUpdate: true, didAuthor: false };
510 return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false };
470511 }),
471512 }),
472513 ),
......@@ -501,7 +542,7 @@ describe("getViewerPermissions", () => {
501542 }),
502543 ),
503544 getViewerAccess: () =>
504 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
545 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
505546 }),
506547 ),
507548 ),
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+18
......@@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = {
4747 },
4848 reviewers: { request: true, listCandidates: true },
4949 edit: { changeRequest: true, comment: true },
50 labels: true,
5051};
5152
5253/**
......@@ -93,6 +94,8 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest
9394 verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts,
9495 requestReviewers: access.canWrite,
9596 ...(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,
9699 };
97100}
98101
......@@ -526,6 +529,21 @@ export const make = Effect.gen(function* () {
526529 })
527530 .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
529547 runAction: (input) =>
530548 cli
531549 .runPullRequestAction({
apps/server/src/pullRequest/PullRequestProvider.ts+18
......@@ -26,6 +26,7 @@ import type {
2626 PullRequestReviewVerdict,
2727 PullRequestReviewerCandidateList,
2828 PullRequestReviewerKind,
29 PullRequestLabelCandidateList,
2930 PullRequestState,
3031 PullRequestUpdateMethod,
3132 PullRequestViewerPermissions,
......@@ -469,6 +470,23 @@ export interface PullRequestProviderApi {
469470 },
470471 ) => 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
472490 /** Only called when `capabilities.review.reply` is true. */
473491 readonly replyToThread: (
474492 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
22842284 }),
22852285);
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
22872406it.effect("answers a repeated listing from cache, and concurrent readers share one request", () =>
22882407 Effect.gen(function* () {
22892408 let hostCalls = 0;
apps/server/src/pullRequest/PullRequestService.ts+87
......@@ -38,6 +38,8 @@ import {
3838 type PullRequestReviewVerdict,
3939 type PullRequestReviewerCandidateList,
4040 type PullRequestReviewerRequestInput,
41 type PullRequestLabelCandidateList,
42 type PullRequestLabelChangeInput,
4143 type PullRequestSubmitReviewInput,
4244 type PullRequestSummary,
4345 type PullRequestThreadReplyInput,
......@@ -170,6 +172,12 @@ export class PullRequestService extends Context.Service<
170172 readonly requestReviewers: (
171173 input: PullRequestReviewerRequestInput,
172174 ) => 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>;
173181 readonly invalidate: (input: PullRequestInvalidateInput) => Effect.Effect<void>;
174182 }
175183>()("t3/pullRequest/PullRequestService") {}
......@@ -213,6 +221,7 @@ const ACTION_ACCESS_REFUSALS: Record<PullRequestAction, string> = {
213221 * sentence is only ever the answer where a host said no.
214222 */
215223const 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
217226/** A project this page can read: its remote is on a host with an implementation. */
218227interface SupportedProject {
......@@ -473,6 +482,10 @@ function withRateLimitBackoff(
473482 submitReview: interactive("submitReview", api.submitReview),
474483 listReviewerCandidates: interactive("listReviewerCandidates", api.listReviewerCandidates),
475484 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) }),
476489 replyToThread: interactive("replyToThread", api.replyToThread),
477490 setReaction: interactive("setReaction", api.setReaction),
478491 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),
......@@ -1843,6 +1856,78 @@ export const make = Effect.gen(function* () {
18431856 }),
18441857 );
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
18461931 /**
18471932 * The line counts for rows already on the page, which the listing left out because on GitHub
18481933 * they cost more than everything else on the row put together.
......@@ -2336,6 +2421,8 @@ export const make = Effect.gen(function* () {
23362421 // The candidate list is deliberately read fresh per menu-open, so it stays uncached.
23372422 reviewerCandidates,
23382423 requestReviewers: invalidatedByMutation(requestReviewers),
2424 labelCandidates,
2425 setLabels: invalidatedByMutation(setLabels),
23392426 invalidate,
23402427 });
23412428});
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+72-2
......@@ -11,6 +11,7 @@ import {
1111 decodePullRequestListJson,
1212 decodePullRequestNodeIdJson,
1313 decodePullRequestSearchJson,
14 decodeLabelCandidatesJson,
1415 decodeRepositoryAccessJson,
1516 decodeReviewerCandidatesJson,
1617 decodeReviewThreadCommentsJson,
......@@ -832,7 +833,7 @@ describe("viewer permission decoding", () => {
832833 }),
833834 ),
834835 ),
835 ).toEqual({ canWrite: false, canUpdate: true, didAuthor: true });
836 ).toEqual({ canWrite: false, canTriage: false, canUpdate: true, didAuthor: true });
836837 });
837838
838839 it("says no to a passer-by on a repository they can only read", () => {
......@@ -845,7 +846,7 @@ describe("viewer permission decoding", () => {
845846 }),
846847 ),
847848 ),
848 ).toEqual({ canWrite: false, canUpdate: false, didAuthor: false });
849 ).toEqual({ canWrite: false, canTriage: false, canUpdate: false, didAuthor: false });
849850 });
850851
851852 it("reads silence as permission, but not as authorship", () => {
......@@ -854,10 +855,79 @@ describe("viewer permission decoding", () => {
854855 // and claiming it for someone who did not is how an author's own rules get handed out.
855856 expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({
856857 canWrite: false,
858 canTriage: false,
857859 canUpdate: true,
858860 didAuthor: false,
859861 });
860862 });
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 });
861931});
862932
863933describe("review thread decoding", () => {
apps/server/src/pullRequest/gitHubPullRequestJson.ts+106
......@@ -24,6 +24,8 @@ import type {
2424 PullRequestReviewerCandidate,
2525 PullRequestReviewerCandidateList,
2626 PullRequestReviewerKind,
27 PullRequestLabelCandidate,
28 PullRequestLabelCandidateList,
2729 PullRequestState,
2830 PullRequestThreadComment,
2931} from "@t3tools/contracts";
......@@ -1994,6 +1996,11 @@ function toCanWrite(viewerPermission: string | null | undefined): boolean {
19941996 }
19951997}
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
19972004export function decodeRepositoryAccessJson(
19982005 raw: string,
19992006): Result.Result<GitHubRepositoryAccess, DecodeFailure> {
......@@ -2222,6 +2229,99 @@ export function buildReviewerRequestJson(
22222229 });
22232230}
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
22252325/**
22262326 * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the
22272327 * repository, the other two about this pull request in particular — which is why an author with
......@@ -2229,6 +2329,11 @@ export function buildReviewerRequestJson(
22292329 */
22302330export interface GitHubViewerAccess {
22312331 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;
22322337 /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */
22332338 readonly canUpdate: boolean;
22342339 readonly didAuthor: boolean;
......@@ -2275,6 +2380,7 @@ export function decodeViewerPermissionsJson(
22752380 const repository = decoded.success.data.repository;
22762381 return Result.succeed({
22772382 canWrite: toCanWrite(repository.viewerPermission),
2383 canTriage: toCanTriage(repository.viewerPermission),
22782384 ...toPullRequestViewerFields(repository.pullRequest),
22792385 });
22802386}
apps/server/src/ws.ts+10
......@@ -1976,6 +1976,16 @@ const makeWsRpcLayer = (
19761976 pullRequests.requestReviewers(input),
19771977 { "rpc.aggregate": "pull-requests" },
19781978 ),
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 }),
19791989 [WS_METHODS.sourceControlLookupRepository]: (input) =>
19801990 observeRpcEffect(
19811991 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";
1818import { useAtomCommand } from "~/state/use-atom-command";
1919import { 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";
2521import { toastManager } from "../ui/toast";
26import { PullRequestPeopleGhost } from "./PullRequestGhosts";
22import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker";
2723import { PullRequestActorLabel } from "./pullRequestPresentation";
2824import { readableFailure } from "./pullRequestDetail.logic";
2925
......@@ -104,85 +100,40 @@ export function PullRequestReviewerPicker({
104100 candidatesQuery.refresh();
105101 };
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
124103 return (
125 <Menu open={open} onOpenChange={setOpen}>
126 <MenuTrigger
127 render={
128 <Button size="icon-xs" variant="ghost" aria-label="Request a review">
129 <UserPlusIcon className="size-3.5" />
130 </Button>
131 }
132 />
133 <MenuPopup align="start" side="bottom" className="w-72 p-0">
134 <div className="border-b border-border/60 p-2">
135 <Input
136 autoFocus
137 value={query}
138 onChange={(event) => setQuery(event.currentTarget.value)}
139 placeholder="Search people with access"
140 aria-label="Search people with access"
141 size="compact"
142 />
143 </div>
144 <div className="max-h-72 overflow-y-auto p-1">
145 {candidatesQuery.isPending ? (
146 <PullRequestPeopleGhost rows={4} />
147 ) : candidatesQuery.error !== null ? (
148 <p className="p-2 text-xs text-muted-foreground">
149 The people with access could not be read. {candidatesQuery.error}
150 </p>
151 ) : candidates.length === 0 ? (
152 <p className="p-2 text-xs text-muted-foreground">
153 {query.length > 0
154 ? "Nobody with access matches that."
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>
104 <PullRequestCandidatePicker
105 icon={<UserPlusIcon className="size-3.5" />}
106 label="Request a review"
107 allowed={allowed}
108 disabledReason="Asking someone to review needs write access on this repository"
109 open={open}
110 onOpenChange={setOpen}
111 query={query}
112 onQueryChange={setQuery}
113 searchLabel="Search people with access"
114 isPending={candidatesQuery.isPending}
115 error={candidatesQuery.error}
116 candidates={candidates}
117 emptyLabel="Nobody else has access to this repository."
118 noMatchLabel="Nobody with access matches that."
119 errorLabel="The people with access could not be read."
120 truncated={candidatesQuery.data?.truncated === true}
121 truncatedLabel="This repository has more people with access than are listed here. Ask for the rest on the host."
122 candidateKey={(candidate) => `${candidate.kind}:${candidate.id}`}
123 disabled={pending !== null}
124 onSelect={(candidate) => void toggle(candidate)}
125 >
126 {(candidate) => (
127 <>
128 <PullRequestActorLabel actor={candidate} className="min-w-0 flex-1 truncate" />
129 {candidate.kind === "team" ? (
130 <span className="shrink-0 text-muted-foreground">team</span>
131 ) : null}
132 {candidate.isRequested ? (
133 <CheckIcon aria-label="Already asked" className="size-3.5 shrink-0" />
183134 ) : null}
184 </div>
185 </MenuPopup>
186 </Menu>
135 </>
136 )}
137 </PullRequestCandidatePicker>
187138 );
188139}
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+31-16
......@@ -41,6 +41,7 @@ import {
4141 pullRequestReviewOutcomeRingClassName,
4242 pullRequestReviewOutcomeStaleLabel,
4343} from "./pullRequestPresentation";
44import { PullRequestLabelPicker } from "./PullRequestLabelPicker";
4445import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker";
4546import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
4647import {
......@@ -664,25 +665,39 @@ export function PullRequestSummaryTab({
664665 ) : null}
665666 </span>
666667 </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 ? (
668671 <MetaRow icon={<TagIcon className="size-3.5" />} label="Labels">
669672 <span className="flex min-w-0 flex-wrap items-center gap-1">
670 {detail.labels.map((label) => {
671 const dot = pullRequestLabelColor(label.color);
672 return (
673 <span
674 key={label.name}
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"
676 >
673 {detail.labels.length === 0 ? (
674 <span className="text-muted-foreground">None</span>
675 ) : (
676 detail.labels.map((label) => {
677 const dot = pullRequestLabelColor(label.color);
678 return (
677679 <span
678 aria-hidden
679 className="size-2 shrink-0 rounded-full bg-muted-foreground"
680 {...(dot ? { style: { backgroundColor: dot } } : {})}
681 />
682 <span className="truncate">{label.name}</span>
683 </span>
684 );
685 })}
680 key={label.name}
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"
682 >
683 <span
684 aria-hidden
685 className="size-2 shrink-0 rounded-full bg-muted-foreground"
686 {...(dot ? { style: { backgroundColor: dot } } : {})}
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}
686701 </span>
687702 </MetaRow>
688703 ) : null}
docs/user/source-control.md+2
......@@ -73,6 +73,8 @@ T3 Code works with the platforms your team already uses:
7373- Rewrite your own comments the same way, wherever they are shown
7474- Works on GitHub, GitLab, and Bitbucket. Azure DevOps takes a new title and description; its
7575 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
7779### Know Your Setup at a Glance
7880
packages/client-runtime/src/state/pullRequests.ts+12
......@@ -204,6 +204,18 @@ export function createPullRequestEnvironmentAtoms<R, E>(
204204 scheduler: commandScheduler,
205205 concurrency: serialPerEnvironment,
206206 }),
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 }),
207219 setThreadResolution: createEnvironmentRpcCommand(runtime, {
208220 label: "environment-data:pull-requests:set-thread-resolution",
209221 tag: WS_METHODS.pullRequestsSetThreadResolution,
packages/contracts/src/pullRequest.ts+39
......@@ -291,6 +291,21 @@ export const PullRequestReviewerCandidateList = Schema.Struct({
291291});
292292export 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
294309export const PullRequestCommit = Schema.Struct({
295310 oid: TrimmedNonEmptyString,
296311 messageHeadline: Schema.String,
......@@ -397,6 +412,12 @@ export const PullRequestCapabilities = Schema.Struct({
397412 * what every server before this one was.
398413 */
399414 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),
400421});
401422export type PullRequestCapabilities = typeof PullRequestCapabilities.Type;
402423
......@@ -426,6 +447,12 @@ export const PullRequestViewerPermissions = Schema.Struct({
426447 * Absent or empty means they may not, which is also what a host with no such action says.
427448 */
428449 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),
429456});
430457export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type;
431458
......@@ -998,6 +1025,18 @@ export const PullRequestReviewerRequestInput = Schema.Struct({
9981025});
9991026export 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
10011040export const PullRequestUnavailableReason = Schema.Literals([
10021041 "cli-missing",
10031042 "cli-unauthenticated",
packages/contracts/src/rpc.ts+19
......@@ -100,6 +100,8 @@ import {
100100 PullRequestSummary,
101101 PullRequestReviewerCandidateList,
102102 PullRequestReviewerRequestInput,
103 PullRequestLabelCandidateList,
104 PullRequestLabelChangeInput,
103105 PullRequestSubmitReviewInput,
104106 PullRequestThreadCommentsInput,
105107 PullRequestThreadCommentsResult,
......@@ -319,6 +321,8 @@ export const WS_METHODS = {
319321 pullRequestsInvalidate: "pullRequests.invalidate",
320322 pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates",
321323 pullRequestsRequestReviewers: "pullRequests.requestReviewers",
324 pullRequestsLabelCandidates: "pullRequests.labelCandidates",
325 pullRequestsSetLabels: "pullRequests.setLabels",
322326
323327 // Source control methods
324328 sourceControlLookupRepository: "sourceControl.lookupRepository",
......@@ -630,6 +634,19 @@ export const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequest
630634 error: PullRequestRpcError,
631635});
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
633650export const WsSourceControlLookupRepositoryRpc = Rpc.make(
634651 WS_METHODS.sourceControlLookupRepository,
635652 {
......@@ -1087,6 +1104,8 @@ export const WsRpcGroup = RpcGroup.make(
10871104 WsPullRequestsInvalidateRpc,
10881105 WsPullRequestsReviewerCandidatesRpc,
10891106 WsPullRequestsRequestReviewersRpc,
1107 WsPullRequestsLabelCandidatesRpc,
1108 WsPullRequestsSetLabelsRpc,
10901109 WsSourceControlLookupRepositoryRpc,
10911110 WsSourceControlCloneRepositoryRpc,
10921111 WsSourceControlPublishRepositoryRpc,