Skip to content
Source
Sign inSign up

GitLabPullRequestProvider.ts

687 lines24.0 KB

1import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage";
2import { SourceControlProviderKind } from "@t3tools/contracts";
3import * as Cache from "effect/Cache";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import type {
8 PullRequestActor,
9 PullRequestCapabilities,
10 PullRequestReaction,
11 PullRequestThreadComment,
12 PullRequestViewerPermissions,
13} from "@t3tools/contracts";
14​
15import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
16import type { GitLabDiffRefs, GitLabMergeRequestListState } from "./gitLabMergeRequestJson.ts";
17import {
18 PullRequestProviderError,
19 type PullRequestProviderFailure,
20 type ProviderChangeRequestActivity,
21 type ProviderChangeRequestDetail,
22 type PullRequestProviderApi,
23} from "@t3tools/source-control-core/server/PullRequestProvider";
24import { definition } from "../shared/definition.ts";
25​
26const CAPABILITIES: PullRequestCapabilities = {
27 diff: true,
28 comment: true,
29 actions: [...definition.changeRequestActions],
30 mergeMethods: ["merge", "squash", "rebase"],
31 deleteBranch: true,
32 // GitLab has no merge-target-into-source operation.
33 updateMethods: ["rebase"],
34 search: true,
35 reactions: true,
36 mentions: true,
37 referenceSigils: ["#", "!"],
38 // GitLab's viewed marks live in browser-local storage.
39 viewedFiles: "environment",
40 review: {
41 inlineComment: true,
42 rangeComment: true,
43 reply: true,
44 resolve: true,
45 verdicts: ["comment", "approve", "request-changes"],
46 },
47 reviewers: { request: true, listCandidates: true, searchCandidates: true },
48 labels: true,
49 edit: { changeRequest: true, comment: true, maintainerEdits: true },
50};
51​
52const UNAVAILABLE_REQUIREMENT = {
53 missing:
54 "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.",
55 unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.",
56};
57​
58/**
59 * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to
60 * the target, so it is not literally the same permission — but GitLab reports nothing narrower,
61 * and someone it will not let land this change has no business rewriting its branch either.
62 * Reverting pushes a branch to the target project, which the same permission covers.
63 */
64const MERGE_ACTIONS: ReadonlySet<string> = new Set([
65 "merge",
66 "update-branch",
67 "enable-auto-merge",
68 "disable-auto-merge",
69 "revert",
70]);
71​
72/** Authors may close/reopen/change draft state regardless of role; REST omits authorship. */
73export function gitLabViewerPermissions(input: {
74 readonly viewerCanMerge: boolean;
75 readonly viewerCanEditMaintainerEdits?: boolean | undefined;
76 readonly viewerCanDeleteBranch?: boolean;
77 readonly viewerCanEditLabels?: boolean | undefined;
78}): PullRequestViewerPermissions {
79 return {
80 actions: CAPABILITIES.actions.filter((action) =>
81 action === "delete-branch"
82 ? input.viewerCanDeleteBranch === true
83 : !MERGE_ACTIONS.has(action) || input.viewerCanMerge,
84 ),
85 comment: true,
86 resolve: true,
87 verdicts: CAPABILITIES.review.verdicts,
88 requestReviewers: true,
89 maintainerEdits: input.viewerCanEditMaintainerEdits === true,
90 labels: input.viewerCanEditLabels === true,
91 ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}),
92 };
94​
95/**
96 * One page view asks for the detail and then its checks within a second, and the poll that
97 * follows asks again. A minute keeps those to one `glab` call without hiding a pipeline for long;
98 * a refresh or a finished turn drops it sooner through `forgetChangeRequestReads`.
99 */
100const MERGE_REQUEST_DETAIL_TTL = Duration.minutes(1);
101/** A project's merge methods change in its settings, which nobody does between page views. */
102const PROJECT_MERGE_CAPABILITIES_TTL = Duration.minutes(15);
103const READ_CACHE_CAPACITY = 256;
104/** A host's GitLab version only changes on upgrade. */
105const GRAPHQL_LIST_SUPPORT_TTL = Duration.hours(1);
106​
107function gitLabProviderFailure(
108 error: GitLabPullRequestCli.GitLabPullRequestCliError,
109): PullRequestProviderFailure {
110 if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" };
111 if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" };
112 if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" };
113 if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" };
114 return { reason: "failed" };
116​
117// GitLab keeps a thread on the latest diff while its line survives, so older refs mean the code moved.
118const isOutdated = (
119 thread: GitLabDiffRefs | undefined,
120 current: GitLabDiffRefs | undefined,
121): boolean =>
122 thread !== undefined &&
123 current !== undefined &&
124 (thread.baseSha !== current.baseSha ||
125 thread.startSha !== current.startSha ||
126 thread.headSha !== current.headSha);
127​
128export const make = Effect.gen(function* () {
129 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
130​
131 // Keyed on the checkout as well: `glab` picks the host and the account to answer as from the
132 // checkout's remote and config, so two checkouts may read the same project as different people.
133 const mergeRequestDetails = yield* Cache.makeWith(
134 (key: string) => {
135 const [cwd, repository, number, host] = JSON.parse(key) as [string, string, number, string];
136 return cli.getMergeRequestDetail({ cwd, repository, number, host });
137 },
138 {
139 capacity: READ_CACHE_CAPACITY,
140 timeToLive: (exit) => (Exit.isSuccess(exit) ? MERGE_REQUEST_DETAIL_TTL : Duration.zero),
141 },
142 );
143 const graphqlListSupport = yield* Cache.makeWith(
144 (key: string) => {
145 const [cwd, host] = JSON.parse(key) as [string, string | undefined];
146 // A host whose version read fails keeps failing it; remember REST rather than ask each poll.
147 return cli.supportsGraphqlList({ cwd, host }).pipe(
148 Effect.catchIf(
149 (cause) =>
150 cause._tag !== "GitLabCliUnavailableError" &&
151 cause._tag !== "GitLabCliAuthenticationError" &&
152 cause._tag !== "GitLabCliRateLimitError",
153 () => Effect.succeed(false),
154 ),
155 );
156 },
157 {
158 capacity: READ_CACHE_CAPACITY,
159 timeToLive: (exit) => (Exit.isSuccess(exit) ? GRAPHQL_LIST_SUPPORT_TTL : Duration.zero),
160 },
161 );
162 const projectMergeCapabilities = yield* Cache.makeWith(
163 (key: string) => {
164 const [cwd, repository, host] = JSON.parse(key) as [string, string, string];
165 return cli.getProjectMergeCapabilities({ cwd, repository, host });
166 },
167 {
168 capacity: READ_CACHE_CAPACITY,
169 timeToLive: (exit) => (Exit.isSuccess(exit) ? PROJECT_MERGE_CAPABILITIES_TTL : Duration.zero),
170 },
171 );
172​
173 const detailKey = (input: {
174 readonly cwd: string;
175 readonly host: string;
176 readonly repository: string;
177 readonly number: number;
178 }) => JSON.stringify([input.cwd, input.repository, input.number, input.host]);
179 const mergeRequestDetail = (input: {
180 readonly cwd: string;
181 readonly host: string;
182 readonly repository: string;
183 readonly number: number;
184 }) => Cache.get(mergeRequestDetails, detailKey(input));
185​
186 // Dropped on both sides, as the service does with its own reads: a read that starts while the
187 // write is in flight must not outlive it.
188 const writing =
189 <
190 I extends {
191 readonly cwd: string;
192 readonly host: string;
193 readonly repository: string;
194 readonly number: number;
195 },
196 A,
197 E,
198 >(
199 write: (input: I) => Effect.Effect<A, E>,
200 ) =>
201 (input: I): Effect.Effect<A, E> => {
202 const invalidate = Cache.invalidate(mergeRequestDetails, detailKey(input));
203 return invalidate.pipe(Effect.andThen(write(input)), Effect.ensuring(invalidate));
204 };
205​
206 const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) =>
207 new PullRequestProviderError({
208 provider: SourceControlProviderKind.make("gitlab"),
209 operation,
210 ...gitLabProviderFailure(error),
211 detail: error.detail,
212 cause: error,
213 });
214​
215 // Optional endpoints may lose part of the activity, but an unusable CLI or account must
216 // reach the caller so it can report authentication failures and pause a throttled host.
217 const recoverRead = <A>(
218 read: Effect.Effect<A, GitLabPullRequestCli.GitLabPullRequestCliError>,
219 fallback: A,
220 ) =>
221 read.pipe(
222 Effect.catch((error) => {
223 const { reason } = gitLabProviderFailure(error);
224 return reason === "rate-limited" ||
225 reason === "unauthenticated" ||
226 reason === "missing-tool"
227 ? Effect.fail(error)
228 : Effect.succeed(fallback);
229 }),
230 );
231​
232 // The rows were already read, so even a rate limit only leaves their states unknown.
233 const listStates = (input: Parameters<typeof cli.getListStates>[0]) =>
234 cli.getListStates(input).pipe(
235 Effect.catch((error) =>
236 Effect.logWarning("GitLab merge request approval and pipeline read failed", {
237 repository: input.repository,
238 reason: error._tag,
239 }).pipe(Effect.as<ReadonlyMap<number, GitLabMergeRequestListState>>(new Map())),
240 ),
241 );
242​
243 // REST omits line counts; the CLI recovers optional stats failures per batch.
244 const mergeRequestWithStats = (
245 input: {
246 readonly cwd: string;
247 readonly host: string;
248 readonly repository: string;
249 readonly number: number;
250 },
251 detail = cli.getMergeRequestDetail(input),
252 ) =>
253 Effect.all(
254 [
255 detail,
256 cli.listMergeRequestStats({ cwd: input.cwd, host: input.host, changeRequests: [input] }),
257 ],
258 {
259 concurrency: 2,
260 },
261 ).pipe(
262 Effect.map(([mergeRequest, [stat]]) =>
263 stat === undefined
264 ? mergeRequest
265 : { ...mergeRequest, additions: stat.additions, deletions: stat.deletions },
266 ),
267 );
268​
269 const provider: PullRequestProviderApi = {
270 kind: SourceControlProviderKind.make("gitlab"),
271 capabilities: CAPABILITIES,
272 mergeMessageRewrite: removeAgentCredits,
273 unavailableRequirement: UNAVAILABLE_REQUIREMENT,
274​
275 getViewer: (input) => cli.getViewerUsername(input).pipe(Effect.mapError(fail("getViewer"))),
276​
277 listChangeRequests: (input) =>
278 Cache.get(graphqlListSupport, JSON.stringify([input.cwd, input.host])).pipe(
279 // Without a known version the REST list still answers, only with fewer filters.
280 Effect.orElseSucceed(() => false),
281 Effect.flatMap((graphql) =>
282 cli.listMergeRequests({
283 cwd: input.cwd,
284 host: input.host,
285 repository: input.repository,
286 state: input.state,
287 involvement: input.involvement,
288 viewer: input.viewer,
289 limit: input.limit,
290 query: input.query,
291 cursor: input.cursor,
292 filters: input.filters,
293 graphql,
294 }),
295 ),
296 Effect.flatMap(({ listStates: carried, ...batch }) =>
297 (carried === undefined
298 ? listStates({
299 cwd: input.cwd,
300 host: input.host,
301 repository: input.repository,
302 numbers: batch.items.map((item) => item.number),
303 })
304 : Effect.succeed(carried)
305 ).pipe(
306 Effect.map((states) => ({
307 ...batch,
308 items: batch.items.map((item) => withListState(item, states)),
309 continues: true,
310 })),
311 ),
312 ),
313 Effect.mapError(fail("listChangeRequests")),
314 ),
315​
316 getFileStats: (input) =>
317 cli.getMergeRequestFileStats(input).pipe(Effect.mapError(fail("getFileStats"))),
318​
319 listChangeRequestStats: (input) =>
320 cli.listMergeRequestStats(input).pipe(Effect.mapError(fail("listChangeRequestStats"))),
321​
322 getChangeRequestChecks: (input) =>
323 mergeRequestDetail(input).pipe(
324 Effect.map(({ state, checks }) => ({ state, checks })),
325 Effect.mapError(fail("getChangeRequestChecks")),
326 ),
327​
328 // Merge settings only affect the detail panel's actions.
329 getChangeRequestPreview: (input) =>
330 cli
331 .getMergeRequestDetail({ ...input, maintainerEdits: false })
332 .pipe(Effect.mapError(fail("getChangeRequestPreview"))),
333​
334 getChangeRequestSummary: (input) =>
335 Effect.all(
336 [
337 mergeRequestWithStats(
338 input,
339 cli.getMergeRequestDetail({ ...input, maintainerEdits: false }),
340 ),
341 listStates({
342 cwd: input.cwd,
343 host: input.host,
344 repository: input.repository,
345 numbers: [input.number],
346 }),
347 ],
348 { concurrency: 2 },
349 ).pipe(
350 Effect.map(([mergeRequest, states]) => withListState(mergeRequest, states)),
351 Effect.mapError(fail("getChangeRequestSummary")),
352 ),
353​
354 getChangeRequest: (input) =>
355 Effect.all(
356 [
357 mergeRequestWithStats(input, mergeRequestDetail(input)),
358 Cache.get(
359 projectMergeCapabilities,
360 JSON.stringify([input.cwd, input.repository, input.host]),
361 ),
362 ],
363 { concurrency: 2 },
364 ).pipe(
365 Effect.mapError(fail("getChangeRequest")),
366 Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({
367 ...mergeRequest,
368 mergeCapabilities,
369 viewerPermissions: gitLabViewerPermissions(mergeRequest),
370 // Missing divergence counts mean unknown, not up to date.
371 baseComparison:
372 mergeRequest.divergedCommits === undefined
373 ? "unknown"
374 : mergeRequest.divergedCommits > 0
375 ? "behind"
376 : "up-to-date",
377 ...(mergeRequest.divergedCommits === undefined
378 ? {}
379 : { behindBy: mergeRequest.divergedCommits }),
380 })),
381 ),
382​
383 getChangeRequestActivity: (input) =>
384 Effect.all(
385 [
386 recoverRead(cli.listNotes(input), { comments: [], truncated: true }),
387 recoverRead(cli.listCommits(input), []),
388 recoverRead(cli.listDiscussions(input), {
389 threads: [],
390 diffRefsByThreadId: new Map(),
391 overviewFindingIds: [],
392 truncated: true,
393 }),
394 recoverRead(cli.listReactions(input), {
395 reactions: [] as ReadonlyArray<PullRequestReaction>,
396 reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
397 editedAtByNoteId: new Map<string, string>(),
398 botLogins: new Set<string>(),
399 }),
400 // Fresh, not the cached detail: refs from before a push would mark every thread outdated.
401 recoverRead(
402 cli.getMergeRequestDetail({ ...input, downstreamChecks: false }).pipe(
403 Effect.map((mergeRequest) => ({
404 url: mergeRequest.url,
405 diffRefs: mergeRequest.diffRefs,
406 })),
407 ),
408 null,
409 ),
410 ],
411 { concurrency: 5 },
412 ).pipe(
413 Effect.mapError(fail("getChangeRequestActivity")),
414 Effect.map(
415 ([notes, commits, discussions, awards, detail]): ProviderChangeRequestActivity => {
416 const noteUrl = (id: string) => (detail === null ? null : `${detail.url}#note_${id}`);
417 const findingIds = new Set(discussions.overviewFindingIds);
418 const edited = (id: string) => {
419 const editedAt = awards.editedAtByNoteId.get(id);
420 return editedAt === undefined ? {} : { editedAt };
421 };
422 const botLogins = new Set([...awards.botLogins].map((login) => login.toLowerCase()));
423 const withBotFlag = (author: PullRequestActor | null) =>
424 author !== null && botLogins.has(author.login.toLowerCase())
425 ? { ...author, isBot: true }
426 : author;
427 const withNoteDetails = (comment: PullRequestThreadComment) => ({
428 ...comment,
429 ...edited(comment.id),
430 author: withBotFlag(comment.author),
431 url: noteUrl(comment.id),
432 reactions: awards.reactionsByNoteId.get(comment.id) ?? [],
433 });
434 return {
435 reactions: awards.reactions,
436 comments: notes.comments.map((comment) => ({
437 ...comment,
438 ...(findingIds.has(comment.id) ? { kind: "review-comment" as const } : {}),
439 ...edited(comment.id),
440 author: withBotFlag(comment.author),
441 url: noteUrl(comment.id),
442 reactions: awards.reactionsByNoteId.get(comment.id) ?? [],
443 })),
444 commentCount: notes.comments.length,
445 commentsTruncated: notes.truncated || discussions.truncated,
446 reviewThreads: discussions.threads.flatMap((thread) =>
447 thread.path === null
448 ? []
449 : [
450 {
451 ...thread,
452 isOutdated: isOutdated(
453 discussions.diffRefsByThreadId.get(thread.id),
454 detail?.diffRefs,
455 ),
456 comments: thread.comments.map(withNoteDetails),
457 },
458 ],
459 ),
460 overviewThreads: discussions.threads.flatMap((thread) =>
461 thread.path === null
462 ? [{ ...thread, comments: thread.comments.map(withNoteDetails) }]
463 : [],
464 ),
465 commits,
466 };
467 },
468 ),
469 ),
470​
471 // Straight from the host, never the shared read: this is what a write is refused or let
472 // through on, and access granted or withdrawn within the minute must count.
473 getViewerPermissions: (input) =>
474 cli
475 .getMergeRequestDetail({ ...input, downstreamChecks: false })
476 .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)),
477​
478 forgetChangeRequestReads: (scope) =>
479 Cache.keys(mergeRequestDetails).pipe(
480 Effect.flatMap((keys) =>
481 Effect.forEach(
482 Array.from(keys).filter((key) => {
483 if (scope === undefined) return true;
484 const [, repository, number] = JSON.parse(key) as [string, string, number];
485 return (
486 repository.toLowerCase() === scope.repository.toLowerCase() &&
487 (scope.number === undefined || number === scope.number)
488 );
489 }),
490 (key) => Cache.invalidate(mergeRequestDetails, key),
491 { discard: true },
492 ),
493 ),
494 ),
495​
496 getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),
497​
498 getDiffFileContents: (input) =>
499 cli.getMergeRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))),
500​
501 getFileRevisions: (input) =>
502 cli.getFileRevisions(input).pipe(
503 Effect.mapError(fail("getFileRevisions")),
504 Effect.map((revisions) => ({ revisions })),
505 ),
506​
507 // MR reviewers are users; groups belong to approval rules.
508 listReviewerCandidates: (input) =>
509 cli
510 .listReviewerCandidates({
511 cwd: input.cwd,
512 host: input.host,
513 repository: input.repository,
514 number: input.number,
515 query: input.query,
516 })
517 .pipe(Effect.mapError(fail("listReviewerCandidates"))),
518​
519 listLabelCandidates: (input) =>
520 cli
521 .listLabelCandidates({
522 cwd: input.cwd,
523 host: input.host,
524 repository: input.repository,
525 number: input.number,
526 })
527 .pipe(Effect.mapError(fail("listLabelCandidates"))),
528​
529 setLabels: writing((input) =>
530 cli
531 .setLabels({
532 cwd: input.cwd,
533 host: input.host,
534 repository: input.repository,
535 number: input.number,
536 labels: input.labels,
537 applied: input.applied,
538 })
539 .pipe(Effect.mapError(fail("setLabels"))),
540 ),
541​
542 listRepositoryLabels: (input) =>
543 cli
544 .listProjectLabels({
545 cwd: input.cwd,
546 host: input.host,
547 repository: input.repository,
548 search: input.query,
549 })
550 .pipe(Effect.mapError(fail("listRepositoryLabels"))),
551​
552 searchRepositoryAuthors: (input) =>
553 cli
554 .listProjectUsers({
555 cwd: input.cwd,
556 host: input.host,
557 repository: input.repository,
558 search: input.query,
559 })
560 .pipe(Effect.mapError(fail("searchRepositoryAuthors"))),
561​
562 searchRepositoryReferences: (input) =>
563 cli
564 .searchProjectReferences({
565 cwd: input.cwd,
566 host: input.host,
567 repository: input.repository,
568 search: input.query,
569 sigil: input.sigil,
570 })
571 .pipe(Effect.mapError(fail("searchRepositoryReferences"))),
572​
573 setReviewerRequest: writing((input) =>
574 cli
575 .setReviewerRequest({
576 cwd: input.cwd,
577 host: input.host,
578 repository: input.repository,
579 number: input.number,
580 reviewers: input.reviewers,
581 requested: input.requested,
582 })
583 .pipe(Effect.mapError(fail("setReviewerRequest"))),
584 ),
585​
586 runAction: writing((input) =>
587 cli
588 .runMergeRequestAction({
589 cwd: input.cwd,
590 host: input.host,
591 repository: input.repository,
592 number: input.number,
593 action: input.action,
594 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
595 ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}),
596 })
597 .pipe(Effect.mapError(fail("runAction"))),
598 ),
599​
600 updateChangeRequest: writing((input) =>
601 cli
602 .updateMergeRequest({
603 cwd: input.cwd,
604 host: input.host,
605 repository: input.repository,
606 number: input.number,
607 ...(input.title === undefined ? {} : { title: input.title }),
608 ...(input.body === undefined ? {} : { description: input.body }),
609 ...(input.maintainerCanModify === undefined
610 ? {}
611 : { allowCollaboration: input.maintainerCanModify }),
612 })
613 .pipe(Effect.mapError(fail("updateChangeRequest"))),
614 ),
615​
616 comment: writing((input) =>
617 cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))),
618 ),
619​
620 updateComment: writing((input) =>
621 cli
622 .updateNote({
623 cwd: input.cwd,
624 host: input.host,
625 repository: input.repository,
626 number: input.number,
627 noteId: input.commentId,
628 body: input.body,
629 })
630 .pipe(Effect.mapError(fail("updateComment"))),
631 ),
632​
633 submitReview: writing((input) =>
634 cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))),
635 ),
636​
637 replyToThread: writing((input) =>
638 cli
639 .replyToDiscussion({
640 cwd: input.cwd,
641 host: input.host,
642 repository: input.repository,
643 number: input.number,
644 discussionId: input.threadId,
645 body: input.body,
646 })
647 .pipe(Effect.mapError(fail("replyToThread"))),
648 ),
649​
650 setReaction: writing((input) =>
651 cli
652 .setReaction({
653 cwd: input.cwd,
654 host: input.host,
655 repository: input.repository,
656 number: input.number,
657 ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }),
658 content: input.content,
659 reacted: input.reacted,
660 })
661 .pipe(Effect.mapError(fail("setReaction"))),
662 ),
663​
664 setThreadResolution: writing((input) =>
665 cli
666 .setDiscussionResolution({
667 cwd: input.cwd,
668 host: input.host,
669 repository: input.repository,
670 number: input.number,
671 discussionId: input.threadId,
672 resolved: input.resolved,
673 })
674 .pipe(Effect.mapError(fail("setThreadResolution"))),
675 ),
676 };
677​
678 return provider;
679});
680​
681function withListState<Item extends { readonly number: number }>(
682 item: Item,
683 states: ReadonlyMap<number, GitLabMergeRequestListState>,
684): Item & Partial<GitLabMergeRequestListState> {
685 const state = states.get(item.number);
686 return state === undefined ? item : { ...item, ...state };