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 descriptionDraftInForce,
15 settleDescriptionSave,
16 startDescriptionSave,
17 type DescriptionDraft,
18} from "./pullRequestEditing.ts";
19
20type Subject = Pick<PullRequestDetail, "author" | "capabilities" | "viewer" | "viewerPermissions">;
21
22function actor(login: string): PullRequestActor {
23 return { login, name: null, avatarUrl: null };
24}
25
26function capabilities(edit?: PullRequestCapabilities["edit"]): PullRequestCapabilities {
27 return {
28 diff: true,
29 comment: true,
30 actions: [],
31 mergeMethods: [],
32 search: true,
33 reactions: true,
34 review: { inlineComment: true, reply: true, resolve: true, verdicts: [] },
35 reviewers: { request: true, listCandidates: true },
36 ...(edit === undefined ? {} : { edit }),
37 };
38}
39
40function permissions(
41 overrides: Partial<PullRequestViewerPermissions> = {},
42): PullRequestViewerPermissions {
43 return {
44 actions: [],
45 comment: true,
46 resolve: true,
47 verdicts: [],
48 requestReviewers: true,
49 ...overrides,
50 };
51}
52
53function subject(overrides: Partial<Subject> = {}): Subject {
54 return {
55 author: actor("octocat"),
56 capabilities: capabilities({ changeRequest: true, comment: true }),
57 viewer: "octocat",
58 viewerPermissions: permissions(),
59 ...overrides,
60 };
61}
62
63function comment(overrides: Partial<PullRequestComment> = {}): PullRequestComment {
64 return {
65 id: "c1",
66 kind: "issue-comment",
67 author: actor("octocat"),
68 body: "words",
69 createdAt: "2026-01-01T00:00:00.000Z",
70 url: null,
71 path: null,
72 reviewState: null,
73 ...overrides,
74 };
75}
76
77describe("canEditPullRequestChangeRequest", () => {
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 expect(
110 canEditPullRequestChangeRequest(
111 subject({
112 capabilities: capabilities({ changeRequest: false, comment: true }),
113 viewerPermissions: permissions({ editChangeRequest: true }),
114 }),
115 ),
116 ).toBe(false);
117 });
118
119 it("refuses a reader who neither wrote it nor may merge it", () => {
120 expect(
121 canEditPullRequestChangeRequest(
122 subject({
123 author: actor("someone-else"),
124 viewerPermissions: permissions({ actions: ["close"] }),
125 }),
126 ),
127 ).toBe(false);
128 });
129
130 it("refuses where the host cannot rewrite a change request at all", () => {
131 expect(
132 canEditPullRequestChangeRequest(
133 subject({ capabilities: capabilities({ changeRequest: false, comment: true }) }),
134 ),
135 ).toBe(false);
136 expect(canEditPullRequestChangeRequest(subject({ capabilities: capabilities() }))).toBe(false);
137 });
138
139 it("still allows a merger the host could not name", () => {
140 expect(
141 canEditPullRequestChangeRequest(
142 subject({ viewer: undefined, viewerPermissions: permissions({ actions: ["merge"] }) }),
143 ),
144 ).toBe(true);
145 });
146});
147
148describe("canEditPullRequestComment", () => {
149 it("lets the reader rewrite their own remark on a line", () => {
150 expect(canEditPullRequestComment(subject(), comment({ kind: "review-comment" }))).toBe(true);
151 });
152
153 it("matches the author regardless of case", () => {
154 expect(
155 canEditPullRequestComment(
156 subject({ viewer: "OCTOCAT" }),
157 comment({ author: actor("octocat") }),
158 ),
159 ).toBe(true);
160 });
161
162 it("refuses somebody else's remark", () => {
163 expect(canEditPullRequestComment(subject(), comment({ author: actor("someone-else") }))).toBe(
164 false,
165 );
166 });
167
168 it("follows the host's own answer about a remark over authorship", () => {
169 expect(canEditPullRequestComment(subject(), comment({ canEdit: false }))).toBe(false);
170 expect(
171 canEditPullRequestComment(
172 subject(),
173 comment({ author: actor("someone-else"), canEdit: true }),
174 ),
175 ).toBe(true);
176 expect(
177 canEditPullRequestComment(
178 subject({ capabilities: capabilities({ changeRequest: true, comment: false }) }),
179 comment({ canEdit: true }),
180 ),
181 ).toBe(false);
182 });
183
184 it("refuses a review's own summary", () => {
185 expect(canEditPullRequestComment(subject(), comment({ kind: "review" }))).toBe(false);
186 });
187});
188
189describe("canResolvePullRequestThread", () => {
190 it("uses the reader's repository-wide permission where the host says nothing per thread", () => {
191 expect(canResolvePullRequestThread(subject(), {})).toBe(true);
192 expect(
193 canResolvePullRequestThread(
194 subject({ viewerPermissions: permissions({ resolve: false }) }),
195 {},
196 ),
197 ).toBe(false);
198 });
199
200 it("only narrows the reader's permission with the host's answer for one thread", () => {
201 expect(canResolvePullRequestThread(subject(), { canResolve: false })).toBe(false);
202 expect(
203 canResolvePullRequestThread(subject({ viewerPermissions: permissions({ resolve: false }) }), {
204 canResolve: true,
205 }),
206 ).toBe(false);
207 });
208});
209
210describe("description drafts", () => {
211 const url = "https://github.com/owner/repo/pull/1";
212 const unticked = "- [ ] Ship it";
213 const ticked = "- [x] Ship it";
214
215 it("hides readings taken during the saves and ends at the first one after them", () => {
216 // Ticking and unticking the same box saves the host's own body last.
217 let draft: DescriptionDraft | null = startDescriptionSave(null, { url, observedAt: 1 }, ticked);
218 draft = startDescriptionSave(draft, { url, observedAt: 1 }, unticked);
219 expect(descriptionDraftInForce(draft, url, 2)?.body).toBe(unticked);
220 draft = descriptionDraftInForce(draft, url, 2);
221 draft = settleDescriptionSave(draft, url, true);
222 expect(descriptionDraftInForce(draft, url, 2)?.body).toBe(unticked);
223 draft = settleDescriptionSave(draft, url, true);
224 expect(descriptionDraftInForce(draft, url, 2)?.body).toBe(unticked);
225 // The acknowledging reading ends the draft, so the host's later ticks show.
226 expect(descriptionDraftInForce(draft, url, 3)).toBeNull();
227 });
228
229 it("outlives failed saves until a reading after the last one, losing the body only with it", () => {
230 let draft: DescriptionDraft | null = startDescriptionSave(null, { url, observedAt: 1 }, ticked);
231 draft = startDescriptionSave(draft, { url, observedAt: 1 }, unticked);
232 // The save still running writes the whole body again.
233 draft = settleDescriptionSave(draft, url, false);
234 expect(descriptionDraftInForce(draft, url, 2)).toMatchObject({ body: unticked, saves: 1 });
235 draft = descriptionDraftInForce(draft, url, 2);
236 draft = settleDescriptionSave(draft, url, false);
237 expect(descriptionDraftInForce(draft, url, 2)).toMatchObject({ body: null, saves: 0 });
238 expect(settleDescriptionSave(draft, url, true)).toBe(draft);
239 expect(descriptionDraftInForce(draft, url, 3)).toBeNull();
240 });
241
242 it("belongs to one change request", () => {
243 const draft = startDescriptionSave(null, { url, observedAt: 1 }, ticked);
244 const other = "https://github.com/owner/repo/pull/2";
245 expect(descriptionDraftInForce(draft, other, 1)).toBeNull();
246 expect(settleDescriptionSave(draft, other, false)).toBe(draft);
247 expect(startDescriptionSave(draft, { url: other, observedAt: 5 }, unticked).saves).toBe(1);
248 });
249});