| author | |
| committer | |
| log | 9ebbeda5a03fd8fce7be147b65ea396e50721c68 |
| tree | 6ba7db2383a9ee37f10d119b9e5b4892a0b2b86d |
| parent | 0fbb94248581aaefda36e4f8a40ec2c6455c779a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 2732 | 2737 | ||
| ... | @@ -2889,7 +2894,12 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -2889,7 +2894,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2889 | }); | 2894 | }); |
| 2890 | 2895 | ||
| 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>; |
| 599 | 603 | ||
| 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 | }, |
| 1979 | 2001 | ||
| 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", () => |
| 47 | 47 | ||
| 48 | describe("gitHubViewerPermissions", () => { | 48 | describe("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 | }); |
| 69 | 77 | ||
| ... | @@ -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 actions | 79 | // 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 | }); |
| 84 | 110 | ||
| 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 | }); |
| 96 | 130 | ||
| ... | @@ -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 | }); |
| 425 | 466 | ||
| 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 | }; |
| 51 | 52 | ||
| 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 | } |
| 98 | 101 | ||
| ... | @@ -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"))), |
| 528 | 531 | ||
| 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 | cli | 548 | 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>; |
| 471 | 472 | ||
| 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 | ); |
| 2286 | 2286 | ||
| 2287 | it.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 | |||
| 2319 | it.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 | |||
| 2364 | it.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 | |||
| 2287 | it.effect("answers a repeated listing from cache, and concurrent readers share one request", () => | 2406 | it.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 | */ |
| 215 | const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review."; | 223 | const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review."; |
| 224 | const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to change its labels."; | ||
| 216 | 225 | ||
| 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. */ |
| 218 | interface SupportedProject { | 227 | interface 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 | ); |
| 1845 | 1858 | ||
| 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 GitHub | 1932 | * 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 | }); |
| 837 | 838 | ||
| 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 | }); |
| 850 | 851 | ||
| 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 | |||
| 878 | describe("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 | }); |
| 862 | 932 | ||
| 863 | describe("review thread decoding", () => { | 933 | describe("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 | } |
| 1996 | 1998 | ||
| 1999 | /** Triage is the least role GitHub lets label a pull request; it is not a write. */ | ||
| 2000 | function toCanTriage(viewerPermission: string | null | undefined): boolean { | ||
| 2001 | return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); | ||
| 2002 | } | ||
| 2003 | |||
| 1997 | export function decodeRepositoryAccessJson( | 2004 | export 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 | } |
| 2224 | 2231 | ||
| 2232 | export 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 | |||
| 2244 | const 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 | |||
| 2274 | const 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 | */ | ||
| 2281 | export 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. */ | ||
| 2317 | const LabelRequestSchema = Schema.Struct({ labels: Schema.Array(Schema.String) }); | ||
| 2318 | |||
| 2319 | const encodeLabelRequest = Schema.encodeSync(Schema.fromJsonString(LabelRequestSchema)); | ||
| 2320 | |||
| 2321 | export 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 the | 2326 | * 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 with | 2327 | * 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 | */ |
| 2230 | export interface GitHubViewerAccess { | 2330 | export 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 | */ | ||
| 6 | import type { ReactNode } from "react"; | ||
| 7 | |||
| 8 | import { Button } from "../ui/button"; | ||
| 9 | import { Input } from "../ui/input"; | ||
| 10 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | ||
| 11 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 12 | import { PullRequestPeopleGhost } from "./PullRequestGhosts"; | ||
| 13 | |||
| 14 | export 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 | */ | ||
| 8 | import type { EnvironmentId, PullRequestLabelCandidate, PullRequestRef } from "@t3tools/contracts"; | ||
| 9 | import { CheckIcon, TagIcon } from "lucide-react"; | ||
| 10 | import { useMemo, useState } from "react"; | ||
| 11 | |||
| 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; | ||
| 13 | import { useEnvironmentQuery } from "~/state/query"; | ||
| 14 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 15 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 16 | |||
| 17 | import { toastManager } from "../ui/toast"; | ||
| 18 | import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker"; | ||
| 19 | import { readableFailure } from "./pullRequestDetail.logic"; | ||
| 20 | import { pullRequestLabelColor } from "./pullRequestList.logic"; | ||
| 21 | |||
| 22 | /** Narrows only what arrived: the host is asked once, when the menu opens. */ | ||
| 23 | function 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 | |||
| 32 | export 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"; |
| 18 | import { useAtomCommand } from "~/state/use-atom-command"; | 18 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 19 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 19 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 20 | 20 | ||
| 21 | import { Button } from "../ui/button"; | ||
| 22 | import { Input } from "../ui/input"; | ||
| 23 | import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; | ||
| 24 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 25 | import { toastManager } from "../ui/toast"; | 21 | import { toastManager } from "../ui/toast"; |
| 26 | import { PullRequestPeopleGhost } from "./PullRequestGhosts"; | 22 | import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker"; |
| 27 | import { PullRequestActorLabel } from "./pullRequestPresentation"; | 23 | import { PullRequestActorLabel } from "./pullRequestPresentation"; |
| 28 | import { readableFailure } from "./pullRequestDetail.logic"; | 24 | import { readableFailure } from "./pullRequestDetail.logic"; |
| 29 | 25 | ||
| ... | @@ -104,85 +100,40 @@ export function PullRequestReviewerPicker({ | ... | @@ -104,85 +100,40 @@ export function PullRequestReviewerPicker({ |
| 104 | candidatesQuery.refresh(); | 100 | candidatesQuery.refresh(); |
| 105 | }; | 101 | }; |
| 106 | 102 | ||
| 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 | <MenuTrigger | 105 | 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 | <Input | 114 | isPending={candidatesQuery.isPending} |
| 136 | autoFocus | 115 | 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 > 0 | 132 | {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"; |
| 44 | import { PullRequestLabelPicker } from "./PullRequestLabelPicker"; | ||
| 44 | import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker"; | 45 | import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker"; |
| 45 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; | 46 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; |
| 46 | import { | 47 | import { |
| ... | @@ -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 | <span | 676 | 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 | <span | 679 | <span |
| 678 | aria-hidden | 680 | 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 shown | 73 | - 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; its | 74 | - Works on GitHub, GitLab, and Bitbucket. Azure DevOps takes a new title and description; its |
| 75 | comments stay read-only here, as they already were | 75 | 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 | ||
| 76 | 78 | ||
| 77 | ### Know Your Setup at a Glance | 79 | ### Know Your Setup at a Glance |
| 78 | 80 |
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 | }); |
| 292 | export type PullRequestReviewerCandidateList = typeof PullRequestReviewerCandidateList.Type; | 292 | export type PullRequestReviewerCandidateList = typeof PullRequestReviewerCandidateList.Type; |
| 293 | 293 | ||
| 294 | /** A label the repository defines, with whether this change request already wears it. */ | ||
| 295 | export const PullRequestLabelCandidate = Schema.Struct({ | ||
| 296 | ...PullRequestLabel.fields, | ||
| 297 | description: Schema.NullOr(Schema.String), | ||
| 298 | isApplied: Schema.Boolean, | ||
| 299 | }); | ||
| 300 | export type PullRequestLabelCandidate = typeof PullRequestLabelCandidate.Type; | ||
| 301 | |||
| 302 | export 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 | }); | ||
| 307 | export type PullRequestLabelCandidateList = typeof PullRequestLabelCandidateList.Type; | ||
| 308 | |||
| 294 | export const PullRequestCommit = Schema.Struct({ | 309 | export 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 | }); |
| 401 | export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; | 422 | export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; |
| 402 | 423 | ||
| ... | @@ -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 | }); |
| 430 | export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; | 457 | export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; |
| 431 | 458 | ||
| ... | @@ -998,6 +1025,18 @@ export const PullRequestReviewerRequestInput = Schema.Struct({ | ... | @@ -998,6 +1025,18 @@ export const PullRequestReviewerRequestInput = Schema.Struct({ |
| 998 | }); | 1025 | }); |
| 999 | export type PullRequestReviewerRequestInput = typeof PullRequestReviewerRequestInput.Type; | 1026 | export type PullRequestReviewerRequestInput = typeof PullRequestReviewerRequestInput.Type; |
| 1000 | 1027 | ||
| 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 | */ | ||
| 1033 | export const PullRequestLabelChangeInput = Schema.Struct({ | ||
| 1034 | ...PullRequestRef.fields, | ||
| 1035 | labels: Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1), Schema.isMaxLength(25)), | ||
| 1036 | applied: Schema.Boolean, | ||
| 1037 | }); | ||
| 1038 | export type PullRequestLabelChangeInput = typeof PullRequestLabelChangeInput.Type; | ||
| 1039 | |||
| 1001 | export const PullRequestUnavailableReason = Schema.Literals([ | 1040 | export 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", | ||
| 322 | 326 | ||
| 323 | // Source control methods | 327 | // 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 | }); |
| 632 | 636 | ||
| 637 | /** Read when the label menu opens, for the same reason the reviewer candidates are. */ | ||
| 638 | export const WsPullRequestsLabelCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsLabelCandidates, { | ||
| 639 | payload: PullRequestRef, | ||
| 640 | success: PullRequestLabelCandidateList, | ||
| 641 | error: PullRequestRpcError, | ||
| 642 | }); | ||
| 643 | |||
| 644 | export const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, { | ||
| 645 | payload: PullRequestLabelChangeInput, | ||
| 646 | success: Schema.Void, | ||
| 647 | error: PullRequestRpcError, | ||
| 648 | }); | ||
| 649 | |||
| 633 | export const WsSourceControlLookupRepositoryRpc = Rpc.make( | 650 | export 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, |