1/**
2 * A review being written, held until it is sent.
3 *
4 * Nothing here reaches the host: a review is one request carrying every line comment and the
5 * verdict together, so a half-written one is invisible to everyone else — including on the
6 * hosts that have no pending review of their own. That also means a draft lives only as long
7 * as the tab does, which is why this is deliberately not persisted.
8 */
9import type { PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts";
10import { create } from "zustand";
11
12export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string };
13
14/**
15 * A counter rather than anything derived from the comment: two remarks on one line can be the
16 * same length, and an id built from the draft's own contents would collide with a comment that
17 * was already removed — which shares a React key with it and, worse, makes discarding one card
18 * delete both.
19 */
20let pendingCommentSequence = 0;
21
22export function nextPendingReviewCommentId(): string {
23 pendingCommentSequence += 1;
24 return `pending-review-comment-${pendingCommentSequence}`;
25}
26
27/** A project's thread can review the same repository path and number on different hosts. */
28export function pullRequestReviewKey(reference: PullRequestRef): string {
29 return JSON.stringify([
30 reference.projectId,
31 reference.host?.toLowerCase() ?? null,
32 reference.repository.toLowerCase(),
33 reference.number,
34 ]);
35}
36
37interface PullRequestReviewStoreState {
38 readonly drafts: Readonly<Record<string, ReadonlyArray<PendingReviewComment>>>;
39 readonly summaries: Readonly<Record<string, string>>;
40 readonly addComment: (key: string, comment: PendingReviewComment) => void;
41 readonly removeComment: (key: string, commentId: string) => void;
42 readonly removeComments: (key: string, commentIds: ReadonlyArray<string>) => void;
43 readonly clear: (key: string) => void;
44 readonly setSummary: (key: string, body: string) => void;
45 readonly clearSummary: (key: string, submittedBody: string) => void;
46}
47
48const EMPTY: ReadonlyArray<PendingReviewComment> = [];
49
50export const usePullRequestReviewStore = create<PullRequestReviewStoreState>()((set) => ({
51 drafts: {},
52 summaries: {},
53 addComment: (key, comment) =>
54 set((state) => ({
55 drafts: { ...state.drafts, [key]: [...(state.drafts[key] ?? EMPTY), comment] },
56 })),
57 removeComment: (key, commentId) =>
58 set((state) => {
59 const remaining = (state.drafts[key] ?? EMPTY).filter((entry) => entry.id !== commentId);
60 if (remaining.length > 0) return { drafts: { ...state.drafts, [key]: remaining } };
61 const { [key]: _removed, ...rest } = state.drafts;
62 return { drafts: rest };
63 }),
64 removeComments: (key, commentIds) =>
65 set((state) => {
66 const submitted = new Set(commentIds);
67 const remaining = (state.drafts[key] ?? EMPTY).filter((entry) => !submitted.has(entry.id));
68 if (remaining.length > 0) return { drafts: { ...state.drafts, [key]: remaining } };
69 const { [key]: _removed, ...rest } = state.drafts;
70 return { drafts: rest };
71 }),
72 clear: (key) =>
73 set((state) => {
74 const { [key]: _removed, ...rest } = state.drafts;
75 return { drafts: rest };
76 }),
77 setSummary: (key, body) => set((state) => ({ summaries: { ...state.summaries, [key]: body } })),
78 clearSummary: (key, submittedBody) =>
79 set((state) => {
80 // The textarea stays editable while the request is in flight. Only remove the exact draft
81 // the host accepted; a revised summary for the same pull request is new work.
82 if (state.summaries[key] !== submittedBody) return state;
83 const { [key]: _removed, ...rest } = state.summaries;
84 return { summaries: rest };
85 }),
86}));
87
88/** The comments a pull request's draft holds, stable across renders while it is empty. */
89export function usePendingReviewComments(
90 reference: PullRequestRef,
91): ReadonlyArray<PendingReviewComment> {
92 return usePullRequestReviewStore(
93 (store) => store.drafts[pullRequestReviewKey(reference)] ?? EMPTY,
94 );
95}