| 1 | import type { |
| 2 | PullRequestActor, |
| 3 | PullRequestCapabilities, |
| 4 | PullRequestComment, |
| 5 | PullRequestDetail, |
| 6 | PullRequestViewerPermissions, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { describe, expect, it } from "vite-plus/test"; |
| 9 | |
| 10 | import { |
| 11 | canEditPullRequestChangeRequest, |
| 12 | canEditPullRequestComment, |
| 13 | canResolvePullRequestThread, |
| 14 | } from "./pullRequestEditing.logic"; |
| 15 | |
| 16 | type Subject = Pick<PullRequestDetail, "author" | "capabilities" | "viewer" | "viewerPermissions">; |
| 17 | |
| 18 | function actor(login: string): PullRequestActor { |
| 19 | return { login, name: null, avatarUrl: null }; |
| 20 | } |
| 21 | |
| 22 | function capabilities(edit?: PullRequestCapabilities["edit"]): PullRequestCapabilities { |
| 23 | return { |
| 24 | diff: true, |
| 25 | comment: true, |
| 26 | actions: [], |
| 27 | mergeMethods: [], |
| 28 | search: true, |
| 29 | reactions: true, |
| 30 | review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, |
| 31 | reviewers: { request: true, listCandidates: true }, |
| 32 | ...(edit === undefined ? {} : { edit }), |
| 33 | }; |
| 34 | } |
| 35 | |
| 36 | function permissions( |
| 37 | overrides: Partial<PullRequestViewerPermissions> = {}, |
| 38 | ): PullRequestViewerPermissions { |
| 39 | return { |
| 40 | actions: [], |
| 41 | comment: true, |
| 42 | resolve: true, |
| 43 | verdicts: [], |
| 44 | requestReviewers: true, |
| 45 | ...overrides, |
| 46 | }; |
| 47 | } |
| 48 | |
| 49 | function subject(overrides: Partial<Subject> = {}): Subject { |
| 50 | return { |
| 51 | author: actor("octocat"), |
| 52 | capabilities: capabilities({ changeRequest: true, comment: true }), |
| 53 | viewer: "octocat", |
| 54 | viewerPermissions: permissions(), |
| 55 | ...overrides, |
| 56 | }; |
| 57 | } |
| 58 | |
| 59 | function comment(overrides: Partial<PullRequestComment> = {}): PullRequestComment { |
| 60 | return { |
| 61 | id: "c1", |
| 62 | kind: "issue-comment", |
| 63 | author: actor("octocat"), |
| 64 | body: "words", |
| 65 | createdAt: "2026-01-01T00:00:00.000Z", |
| 66 | url: null, |
| 67 | path: null, |
| 68 | reviewState: null, |
| 69 | ...overrides, |
| 70 | }; |
| 71 | } |
| 72 | |
| 73 | describe("canEditPullRequestChangeRequest", () => { |
| 74 | it("lets the author rewrite their own change request", () => { |
| 75 | expect(canEditPullRequestChangeRequest(subject())).toBe(true); |
| 76 | }); |
| 77 | |
| 78 | it("matches the author regardless of case", () => { |
| 79 | expect(canEditPullRequestChangeRequest(subject({ viewer: "OctoCat" }))).toBe(true); |
| 80 | }); |
| 81 | |
| 82 | it("lets somebody who may merge rewrite a change request they did not write", () => { |
| 83 | expect( |
| 84 | canEditPullRequestChangeRequest( |
| 85 | subject({ |
| 86 | author: actor("someone-else"), |
| 87 | viewerPermissions: permissions({ actions: ["merge"] }), |
| 88 | }), |
| 89 | ), |
| 90 | ).toBe(true); |
| 91 | }); |
| 92 | |
| 93 | it("follows the host's own answer about the reader over authorship and merge access", () => { |
| 94 | expect( |
| 95 | canEditPullRequestChangeRequest( |
| 96 | subject({ |
| 97 | author: actor("someone-else"), |
| 98 | viewerPermissions: permissions({ editChangeRequest: true }), |
| 99 | }), |
| 100 | ), |
| 101 | ).toBe(true); |
| 102 | expect( |
| 103 | canEditPullRequestChangeRequest( |
| 104 | subject({ |
| 105 | viewerPermissions: permissions({ actions: ["merge"], editChangeRequest: false }), |
| 106 | }), |
| 107 | ), |
| 108 | ).toBe(false); |
| 109 | // The host can't rewrite change requests at all, so the reader's answer can't widen that. |
| 110 | expect( |
| 111 | canEditPullRequestChangeRequest( |
| 112 | subject({ |
| 113 | capabilities: capabilities({ changeRequest: false, comment: true }), |
| 114 | viewerPermissions: permissions({ editChangeRequest: true }), |
| 115 | }), |
| 116 | ), |
| 117 | ).toBe(false); |
| 118 | }); |
| 119 | |
| 120 | it("refuses a reader who neither wrote it nor may merge it", () => { |
| 121 | expect( |
| 122 | canEditPullRequestChangeRequest( |
| 123 | subject({ |
| 124 | author: actor("someone-else"), |
| 125 | viewerPermissions: permissions({ actions: ["close"] }), |
| 126 | }), |
| 127 | ), |
| 128 | ).toBe(false); |
| 129 | }); |
| 130 | |
| 131 | it("refuses where the host cannot rewrite a change request at all", () => { |
| 132 | expect( |
| 133 | canEditPullRequestChangeRequest( |
| 134 | subject({ capabilities: capabilities({ changeRequest: false, comment: true }) }), |
| 135 | ), |
| 136 | ).toBe(false); |
| 137 | expect(canEditPullRequestChangeRequest(subject({ capabilities: capabilities() }))).toBe(false); |
| 138 | }); |
| 139 | |
| 140 | it("refuses where the host did not say who the reader is", () => { |
| 141 | expect(canEditPullRequestChangeRequest(subject({ viewer: undefined }))).toBe(false); |
| 142 | }); |
| 143 | |
| 144 | it("still allows a merger the host could not name", () => { |
| 145 | expect( |
| 146 | canEditPullRequestChangeRequest( |
| 147 | subject({ viewer: undefined, viewerPermissions: permissions({ actions: ["merge"] }) }), |
| 148 | ), |
| 149 | ).toBe(true); |
| 150 | }); |
| 151 | }); |
| 152 | |
| 153 | describe("canEditPullRequestComment", () => { |
| 154 | it("lets the reader rewrite their own conversation comment", () => { |
| 155 | expect(canEditPullRequestComment(subject(), comment())).toBe(true); |
| 156 | }); |
| 157 | |
| 158 | it("lets the reader rewrite their own remark on a line", () => { |
| 159 | expect(canEditPullRequestComment(subject(), comment({ kind: "review-comment" }))).toBe(true); |
| 160 | }); |
| 161 | |
| 162 | it("matches the author regardless of case", () => { |
| 163 | expect( |
| 164 | canEditPullRequestComment( |
| 165 | subject({ viewer: "OCTOCAT" }), |
| 166 | comment({ author: actor("octocat") }), |
| 167 | ), |
| 168 | ).toBe(true); |
| 169 | }); |
| 170 | |
| 171 | it("refuses somebody else's remark", () => { |
| 172 | expect(canEditPullRequestComment(subject(), comment({ author: actor("someone-else") }))).toBe( |
| 173 | false, |
| 174 | ); |
| 175 | }); |
| 176 | |
| 177 | it("follows the host's own answer about a remark over authorship", () => { |
| 178 | expect(canEditPullRequestComment(subject(), comment({ canEdit: false }))).toBe(false); |
| 179 | expect( |
| 180 | canEditPullRequestComment( |
| 181 | subject(), |
| 182 | comment({ author: actor("someone-else"), canEdit: true }), |
| 183 | ), |
| 184 | ).toBe(true); |
| 185 | // The host can't rewrite remarks at all, so its per-remark answer can't widen that. |
| 186 | expect( |
| 187 | canEditPullRequestComment( |
| 188 | subject({ capabilities: capabilities({ changeRequest: true, comment: false }) }), |
| 189 | comment({ canEdit: true }), |
| 190 | ), |
| 191 | ).toBe(false); |
| 192 | }); |
| 193 | |
| 194 | it("refuses a remark the host attributes to nobody", () => { |
| 195 | expect(canEditPullRequestComment(subject(), comment({ author: null }))).toBe(false); |
| 196 | }); |
| 197 | |
| 198 | it("refuses a review's own summary", () => { |
| 199 | expect(canEditPullRequestComment(subject(), comment({ kind: "review" }))).toBe(false); |
| 200 | }); |
| 201 | |
| 202 | it("refuses where the host cannot rewrite a remark at all", () => { |
| 203 | expect( |
| 204 | canEditPullRequestComment( |
| 205 | subject({ capabilities: capabilities({ changeRequest: true, comment: false }) }), |
| 206 | comment(), |
| 207 | ), |
| 208 | ).toBe(false); |
| 209 | expect(canEditPullRequestComment(subject({ capabilities: capabilities() }), comment())).toBe( |
| 210 | false, |
| 211 | ); |
| 212 | }); |
| 213 | |
| 214 | it("refuses where the host did not say who the reader is", () => { |
| 215 | expect(canEditPullRequestComment(subject({ viewer: undefined }), comment())).toBe(false); |
| 216 | }); |
| 217 | }); |
| 218 | |
| 219 | describe("canResolvePullRequestThread", () => { |
| 220 | it("uses the reader's repository-wide permission where the host says nothing per thread", () => { |
| 221 | expect(canResolvePullRequestThread(subject(), {})).toBe(true); |
| 222 | expect( |
| 223 | canResolvePullRequestThread( |
| 224 | subject({ viewerPermissions: permissions({ resolve: false }) }), |
| 225 | {}, |
| 226 | ), |
| 227 | ).toBe(false); |
| 228 | }); |
| 229 | |
| 230 | it("only narrows the reader's permission with the host's answer for one thread", () => { |
| 231 | expect(canResolvePullRequestThread(subject(), { canResolve: false })).toBe(false); |
| 232 | expect( |
| 233 | canResolvePullRequestThread(subject({ viewerPermissions: permissions({ resolve: false }) }), { |
| 234 | canResolve: true, |
| 235 | }), |
| 236 | ).toBe(false); |
| 237 | }); |
| 238 | }); |