1import type {
2 PullRequestActor,
3 PullRequestCapabilities,
4 PullRequestComment,
5 PullRequestDetail,
6 PullRequestViewerPermissions,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 canEditPullRequestChangeRequest,
12 canEditPullRequestComment,
13 canResolvePullRequestThread,
14} from "./pullRequestEditing.logic";
15
16type Subject = Pick<PullRequestDetail, "author" | "capabilities" | "viewer" | "viewerPermissions">;
17
18function actor(login: string): PullRequestActor {
19 return { login, name: null, avatarUrl: null };
20}
21
22function 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
36function 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
49function 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
59function 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
73describe("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
153describe("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
219describe("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});