@reviewflow/ui 0.0.0-stage → 6.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +95 -2
  3. package/package.json +102 -4
  4. package/src/board/bucket-section.tsx +94 -0
  5. package/src/board/dataSource.ts +44 -0
  6. package/src/board/facts.ts +183 -0
  7. package/src/board/index.ts +12 -0
  8. package/src/board/pull-request-board.tsx +239 -0
  9. package/src/board/pull-request-row.tsx +181 -0
  10. package/src/components/branch-diff/diff-themes.ts +6 -0
  11. package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
  12. package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
  13. package/src/components/branch-diff/diff-worker.ts +7 -0
  14. package/src/components/branch-diff/use-collapsed-files.ts +49 -0
  15. package/src/components/branch-diff/use-diff-settings.tsx +97 -0
  16. package/src/components/branch-diff/whitespace-button.tsx +32 -0
  17. package/src/components/copyable-code-block.tsx +101 -0
  18. package/src/components/label-pill.tsx +84 -0
  19. package/src/components/link-in-row.ts +6 -0
  20. package/src/components/markdown-suggestion.tsx +60 -0
  21. package/src/components/markdown-text.tsx +1067 -0
  22. package/src/components/pull-request-actions-merge.tsx +341 -0
  23. package/src/components/pull-request-actions-review.tsx +218 -0
  24. package/src/components/pull-request-checks.tsx +86 -0
  25. package/src/components/pull-request-commit-files.ts +143 -0
  26. package/src/components/pull-request-commits.tsx +221 -0
  27. package/src/components/pull-request-conversation-card.tsx +220 -0
  28. package/src/components/pull-request-conversation-edit.tsx +147 -0
  29. package/src/components/pull-request-conversation-event.tsx +267 -0
  30. package/src/components/pull-request-conversation-person.tsx +28 -0
  31. package/src/components/pull-request-conversation-thread.tsx +158 -0
  32. package/src/components/pull-request-diff-annotations.tsx +443 -0
  33. package/src/components/pull-request-diff-collapse.ts +43 -0
  34. package/src/components/pull-request-diff-data.ts +174 -0
  35. package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
  36. package/src/components/pull-request-diff-impacts.ts +45 -0
  37. package/src/components/pull-request-diff-jump.ts +87 -0
  38. package/src/components/pull-request-diff-lines.ts +103 -0
  39. package/src/components/pull-request-diff-overview.tsx +416 -0
  40. package/src/components/pull-request-diff-view.tsx +438 -0
  41. package/src/components/pull-request-diff-viewer.tsx +1374 -0
  42. package/src/components/pull-request-files.tsx +533 -0
  43. package/src/components/pull-request-markdown-input.tsx +91 -0
  44. package/src/components/pull-request-panel.tsx +1270 -0
  45. package/src/components/pull-request-part-notice.tsx +82 -0
  46. package/src/components/pull-request-reactions.tsx +178 -0
  47. package/src/components/pull-request-review.tsx +164 -0
  48. package/src/components/pull-request-revisions.tsx +273 -0
  49. package/src/components/pull-request-screen.tsx +299 -0
  50. package/src/components/pull-request-suggestion-actions.tsx +201 -0
  51. package/src/components/pull-request-timeline.tsx +270 -0
  52. package/src/components/repository-attachment-banner.tsx +42 -0
  53. package/src/components/scroll-parent.ts +50 -0
  54. package/src/css.d.ts +1 -0
  55. package/src/dataSource.ts +242 -0
  56. package/src/editor/alerts.ts +105 -0
  57. package/src/editor/code-editor.native.tsx +25 -0
  58. package/src/editor/code-editor.tsx +112 -0
  59. package/src/editor/codeEditorExtensions.ts +65 -0
  60. package/src/editor/codeHighlight.ts +71 -0
  61. package/src/editor/commands.ts +175 -0
  62. package/src/editor/decorations.ts +425 -0
  63. package/src/editor/editable-file.tsx +224 -0
  64. package/src/editor/emojiCompletion.ts +110 -0
  65. package/src/editor/extensions.ts +149 -0
  66. package/src/editor/frontmatter.ts +76 -0
  67. package/src/editor/inlineFormat.ts +114 -0
  68. package/src/editor/lineTypes.ts +55 -0
  69. package/src/editor/markdown-editor.css +511 -0
  70. package/src/editor/markdown-editor.native.tsx +29 -0
  71. package/src/editor/markdown-editor.tsx +295 -0
  72. package/src/editor/nodeClasses.ts +69 -0
  73. package/src/index.ts +75 -0
  74. package/src/markdown-github/flatten.ts +49 -0
  75. package/src/markdown-github/html-tokens.ts +93 -0
  76. package/src/markdown-github/pair-html.ts +354 -0
  77. package/src/markdown-github/safe-url.ts +15 -0
  78. package/src/markdown-github/table-columns.ts +23 -0
  79. package/src/markdown-github/toggle-task.ts +164 -0
  80. package/src/nativewind-env.d.ts +1 -0
  81. package/src/prStatus.ts +90 -0
  82. package/src/services/PullRequestDraftsProvider.tsx +179 -0
  83. package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
  84. package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
  85. package/src/services/emojify.ts +13 -0
  86. package/src/services/errorToMessage.ts +6 -0
  87. package/src/services/expiringDraftStore.ts +102 -0
  88. package/src/services/formatDataAge.ts +12 -0
  89. package/src/services/formatRelativeDate.ts +56 -0
  90. package/src/services/githubImages.tsx +33 -0
  91. package/src/services/isViewerAuthor.ts +7 -0
  92. package/src/slots.tsx +34 -0
@@ -0,0 +1,354 @@
1
+ import type {
2
+ BlockNode,
3
+ BreakNode,
4
+ CodeBlockNode,
5
+ CodeSpanNode,
6
+ FootnoteReferenceNode,
7
+ ImageNode,
8
+ InlineNode,
9
+ TableCellNode,
10
+ TextNode,
11
+ ThematicBreakNode,
12
+ } from "@tanstack/markdown";
13
+ import { parseMarkdown } from "@tanstack/markdown/parser";
14
+ import {
15
+ type HtmlToken,
16
+ decodeEntities,
17
+ textAfterLeadingComments,
18
+ tokenizeHtml,
19
+ } from "./html-tokens.ts";
20
+ import type { TaskSignature } from "./toggle-task.ts";
21
+ import { taskSignature } from "./toggle-task.ts";
22
+
23
+ /**
24
+ * GitHub allows HTML in a comment, and `@tanstack/markdown` (with `allowHtml`)
25
+ * hands it over as CommonMark does: a tag is a sibling node of its content, not
26
+ * its parent. `<details>` is in one `html` node, the markdown it wraps is
27
+ * ordinary nodes, and `</details>` is in a later `html` node; inline,
28
+ * `<sub>`, `x` and `</sub>` are three siblings.
29
+ *
30
+ * This turns each run of siblings into a tree, pairing an open tag with its
31
+ * close across them. Everything else about the AST is kept, so a renderer only
32
+ * has one extra node type, `element`.
33
+ */
34
+
35
+ export interface GhElement {
36
+ type: "element";
37
+ tag: string;
38
+ attrs: Record<string, string>;
39
+ children: GhBlockNode[];
40
+ }
41
+
42
+ export type GhInlineNode =
43
+ | BreakNode
44
+ | CodeSpanNode
45
+ | FootnoteReferenceNode
46
+ | GhElement
47
+ | ImageNode
48
+ | TextNode
49
+ | { type: "emphasis" | "strike" | "strong"; children: GhInlineNode[] }
50
+ | { type: "link"; href: string; title?: string; children: GhInlineNode[] };
51
+
52
+ export interface GhListItem {
53
+ type: "listItem";
54
+ checked?: boolean;
55
+ /** Position among every task item of the document, in source order. */
56
+ taskIndex?: number;
57
+ children: GhBlockNode[];
58
+ }
59
+
60
+ export interface GhTableCell {
61
+ type: "tableCell";
62
+ children: GhInlineNode[];
63
+ }
64
+
65
+ /**
66
+ * Inline nodes are part of the union: the raw text inside an html block, such
67
+ * as the `Click` of `<summary>Click</summary>`, sits among blocks.
68
+ */
69
+ export type GhBlockNode =
70
+ | CodeBlockNode
71
+ | GhInlineNode
72
+ | ThematicBreakNode
73
+ | {
74
+ type: "table";
75
+ header: GhTableCell[];
76
+ rows: GhTableCell[][];
77
+ }
78
+ | { type: "blockquote"; children: GhBlockNode[] }
79
+ | { type: "callout"; kind: string; title: string; children: GhBlockNode[] }
80
+ | { type: "heading"; depth: 1 | 2 | 3 | 4 | 5 | 6; children: GhInlineNode[] }
81
+ | { type: "list"; ordered: boolean; start?: number; items: GhListItem[] }
82
+ | { type: "paragraph"; children: GhInlineNode[] };
83
+
84
+ export interface PairedDocument {
85
+ children: GhBlockNode[];
86
+ taskCount: number;
87
+ /** each task item's state and first line, by `taskIndex`, for `toggleTask` */
88
+ tasks: TaskSignature[];
89
+ }
90
+
91
+ const inlineTags = new Set([
92
+ "a",
93
+ "abbr",
94
+ "b",
95
+ "big",
96
+ "br",
97
+ "cite",
98
+ "code",
99
+ "del",
100
+ "em",
101
+ "i",
102
+ "img",
103
+ "ins",
104
+ "kbd",
105
+ "mark",
106
+ "q",
107
+ "s",
108
+ "samp",
109
+ "small",
110
+ "span",
111
+ "strike",
112
+ "strong",
113
+ "sub",
114
+ "sup",
115
+ "tt",
116
+ "u",
117
+ "var",
118
+ ]);
119
+
120
+ const inlineTypes = new Set<string>([
121
+ "text",
122
+ "inlineCode",
123
+ "footnoteReference",
124
+ "image",
125
+ "break",
126
+ "strong",
127
+ "emphasis",
128
+ "strike",
129
+ "link",
130
+ ]);
131
+
132
+ /** Whether a node belongs in a run of text rather than stacked as a block. */
133
+ export const isInlineNode = (node: GhBlockNode): boolean =>
134
+ inlineTypes.has(node.type) ||
135
+ (node.type === "element" && inlineTags.has(node.tag));
136
+
137
+ interface Frame {
138
+ tag: string;
139
+ attrs: Record<string, string>;
140
+ children: GhBlockNode[];
141
+ }
142
+
143
+ const trimEdges = (children: GhBlockNode[]): void => {
144
+ const first = children[0];
145
+ if (first?.type === "text") first.value = first.value.trimStart();
146
+ const last = children.at(-1);
147
+ if (last?.type === "text") last.value = last.value.trimEnd();
148
+ };
149
+
150
+ /** A stack of open tags over one run of siblings. */
151
+ const createBuilder = (blockContext: boolean) => {
152
+ const root: Frame = { tag: "", attrs: {}, children: [] };
153
+ const stack: Frame[] = [root];
154
+ const top = (): Frame => stack.at(-1) ?? root;
155
+
156
+ const pop = (): void => {
157
+ const frame = stack.pop();
158
+ if (frame === undefined) return;
159
+ trimEdges(frame.children);
160
+ top().children.push({
161
+ type: "element",
162
+ tag: frame.tag,
163
+ attrs: frame.attrs,
164
+ children: frame.children,
165
+ });
166
+ };
167
+
168
+ const addText = (raw: string): void => {
169
+ const inPre = stack.some((frame) => frame.tag === "pre");
170
+ const value = inPre ? raw : raw.replaceAll(/\s+/g, " ");
171
+ if (value === "") return;
172
+ if (!inPre && value.trim() === "") {
173
+ const last = top().children.at(-1);
174
+ const afterInline = last !== undefined && isInlineNode(last);
175
+ if (blockContext && !afterInline) return;
176
+ }
177
+ top().children.push({ type: "text", value });
178
+ };
179
+
180
+ return {
181
+ append: (node: GhBlockNode): void => {
182
+ top().children.push(node);
183
+ },
184
+ html: (value: string): void => {
185
+ tokenizeHtml(
186
+ value,
187
+ stack.slice(1).map((frame) => frame.tag),
188
+ ).forEach((token: HtmlToken) => {
189
+ if (token.kind === "text") {
190
+ addText(token.value);
191
+ } else if (token.kind === "open") {
192
+ stack.push({ tag: token.tag, attrs: token.attrs, children: [] });
193
+ } else {
194
+ // A stray close has no open tag to match and is ignored; one that
195
+ // matches a deeper tag also closes what was left open above it.
196
+ const index = stack.findLastIndex((frame) => frame.tag === token.tag);
197
+ while (index > 0 && stack.length > index) pop();
198
+ }
199
+ });
200
+ },
201
+ finish: (): GhBlockNode[] => {
202
+ while (stack.length > 1) pop();
203
+ return root.children;
204
+ },
205
+ };
206
+ };
207
+
208
+ interface PairContext {
209
+ taskCount: number;
210
+ tasks: TaskSignature[];
211
+ }
212
+
213
+ const pairInlines = (nodes: InlineNode[]): GhInlineNode[] => {
214
+ const builder = createBuilder(false);
215
+
216
+ nodes.forEach((node) => {
217
+ switch (node.type) {
218
+ case "inlineHtml":
219
+ builder.html(node.value);
220
+ break;
221
+ case "strong":
222
+ case "emphasis":
223
+ case "strike":
224
+ builder.append({
225
+ type: node.type,
226
+ children: pairInlines(node.children),
227
+ });
228
+ break;
229
+ case "link":
230
+ builder.append({
231
+ type: "link",
232
+ href: node.href,
233
+ title: node.title,
234
+ children: pairInlines(node.children),
235
+ });
236
+ break;
237
+ case "text":
238
+ builder.append({ type: "text", value: decodeEntities(node.value) });
239
+ break;
240
+ case "inlineCode":
241
+ case "footnoteReference":
242
+ case "image":
243
+ case "break":
244
+ builder.append(node);
245
+ break;
246
+ default:
247
+ break;
248
+ }
249
+ });
250
+
251
+ return builder.finish() as GhInlineNode[];
252
+ };
253
+
254
+ const pairCell = (cell: TableCellNode): GhTableCell => ({
255
+ type: "tableCell",
256
+ children: pairInlines(cell.children),
257
+ });
258
+
259
+ const withoutLeadingComments = (nodes: BlockNode[]): BlockNode[] =>
260
+ nodes.flatMap((node): BlockNode[] => {
261
+ const rest =
262
+ node.type === "html" ? textAfterLeadingComments(node.value) : null;
263
+ return rest === null
264
+ ? [node]
265
+ : withoutLeadingComments(
266
+ parseMarkdown(rest, { allowHtml: true }).children,
267
+ );
268
+ });
269
+
270
+ const pairBlocks = (
271
+ nodes: BlockNode[],
272
+ context: PairContext,
273
+ ): GhBlockNode[] => {
274
+ const builder = createBuilder(true);
275
+
276
+ withoutLeadingComments(nodes).forEach((node) => {
277
+ switch (node.type) {
278
+ case "html":
279
+ builder.html(node.value);
280
+ break;
281
+ case "paragraph":
282
+ builder.append({
283
+ type: "paragraph",
284
+ children: pairInlines(node.children),
285
+ });
286
+ break;
287
+ case "heading":
288
+ builder.append({
289
+ type: "heading",
290
+ depth: node.depth,
291
+ children: pairInlines(node.children),
292
+ });
293
+ break;
294
+ case "code":
295
+ case "thematicBreak":
296
+ builder.append(node);
297
+ break;
298
+ case "list": {
299
+ const items = node.items.map((item): GhListItem => {
300
+ // The marker comes before the item's content, so number it first.
301
+ const taskIndex =
302
+ item.checked === undefined ? undefined : context.taskCount++;
303
+ if (taskIndex !== undefined) context.tasks.push(taskSignature(item));
304
+ return {
305
+ type: "listItem",
306
+ checked: item.checked,
307
+ taskIndex,
308
+ children: pairBlocks(item.children, context),
309
+ };
310
+ });
311
+ builder.append({
312
+ type: "list",
313
+ ordered: node.ordered,
314
+ start: node.start,
315
+ items,
316
+ });
317
+ break;
318
+ }
319
+ case "blockquote":
320
+ builder.append({
321
+ type: "blockquote",
322
+ children: pairBlocks(node.children, context),
323
+ });
324
+ break;
325
+ case "callout":
326
+ builder.append({
327
+ type: "callout",
328
+ kind: node.kind,
329
+ title: node.title,
330
+ children: pairBlocks(node.children, context),
331
+ });
332
+ break;
333
+ case "table":
334
+ builder.append({
335
+ type: "table",
336
+ header: node.header.map(pairCell),
337
+ rows: node.rows.map((row) => row.map(pairCell)),
338
+ });
339
+ break;
340
+ case "footnotes":
341
+ case "component":
342
+ default:
343
+ break;
344
+ }
345
+ });
346
+
347
+ return builder.finish();
348
+ };
349
+
350
+ export const pairGithubHtml = (nodes: BlockNode[]): PairedDocument => {
351
+ const context: PairContext = { taskCount: 0, tasks: [] };
352
+ const children = pairBlocks(nodes, context);
353
+ return { children, taskCount: context.taskCount, tasks: context.tasks };
354
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * GitHub's sanitizer drops a `javascript:` href and an `src` it cannot load; so
3
+ * does this. A relative url is refused too: it was relative to a repository
4
+ * this renderer does not know.
5
+ */
6
+ export const isSafeUrl = (
7
+ value: string,
8
+ protocols: readonly string[] = ["http:", "https:", "mailto:"],
9
+ ): boolean => {
10
+ try {
11
+ return protocols.includes(new URL(value).protocol);
12
+ } catch {
13
+ return false;
14
+ }
15
+ };
@@ -0,0 +1,23 @@
1
+ const MIN_COLUMN_WEIGHT = 3;
2
+ const MAX_COLUMN_WEIGHT = 40;
3
+
4
+ const longestLineLength = (text: string): number =>
5
+ Math.max(...text.split("\n").map((line) => line.trim().length));
6
+
7
+ /**
8
+ * The flex-grow of each column, from the plain text of every row (header
9
+ * included). Rows are separate flex rows, so a measured width cannot be shared
10
+ * between them: a weight per column keeps them aligned. It is the column's
11
+ * longest line, capped so a column of prose cannot squeeze an identifier, and
12
+ * floored so an empty column stays readable.
13
+ */
14
+ export function columnWeights(rows: readonly (readonly string[])[]): number[] {
15
+ const columnCount = Math.max(0, ...rows.map((row) => row.length));
16
+ return Array.from({ length: columnCount }, (_, column) => {
17
+ const widest = Math.max(
18
+ 0,
19
+ ...rows.map((row) => longestLineLength(row[column] ?? "")),
20
+ );
21
+ return Math.min(MAX_COLUMN_WEIGHT, Math.max(MIN_COLUMN_WEIGHT, widest));
22
+ });
23
+ }
@@ -0,0 +1,164 @@
1
+ import type { BlockNode, InlineNode, ListItemNode } from "@tanstack/markdown";
2
+ import { parseMarkdown } from "@tanstack/markdown/parser";
3
+
4
+ /**
5
+ * The source with the `taskIndex`th task-list marker (`[ ]`, `[x]`, `[X]`
6
+ * after a list bullet) flipped, or `null` when it cannot be done safely.
7
+ *
8
+ * The parser reports which list items are tasks but not where they are in the
9
+ * source, so the markers are found again by scanning lines. A scan and a parser
10
+ * can disagree (an indented code block, an html block, a lazy quote), and two
11
+ * disagreements can cancel out in a count. Flipping the wrong box in someone's
12
+ * pull request description is worse than flipping none: each marker found must
13
+ * match the parser's task of the same rank, its state and its first line of
14
+ * text (`expected`), else the boxes stay read-only.
15
+ */
16
+
17
+ const fence = /^[\s>]*(`{3,}|~{3,})/;
18
+ const task = /^([\s>]*(?:(?:[*+-]|\d{1,9}[.)])[ \t]+)+\[)([ x])(\](?:\s|$))/i;
19
+ /** CommonMark html blocks of kinds 6 and 7, which end at a blank line */
20
+ const htmlBlockStart = /^ {0,3}<\/?[a-z][\w-]*(?:\s|\/?>|$)/i;
21
+
22
+ export interface TaskSignature {
23
+ checked: boolean;
24
+ /** the plain text of the item's first line */
25
+ text: string;
26
+ }
27
+
28
+ const plainText = (nodes: readonly InlineNode[]): string =>
29
+ nodes
30
+ .map((node): string => {
31
+ switch (node.type) {
32
+ case "text":
33
+ case "inlineCode":
34
+ return node.value;
35
+ case "image":
36
+ return node.alt;
37
+ case "break":
38
+ return "\n";
39
+ case "emphasis":
40
+ case "link":
41
+ case "strike":
42
+ case "strong":
43
+ return plainText(node.children);
44
+ case "inlineHtml":
45
+ case "footnoteReference":
46
+ return "";
47
+ default:
48
+ return node satisfies never;
49
+ }
50
+ })
51
+ .join("");
52
+
53
+ const firstLineOf = (blocks: readonly BlockNode[]): string => {
54
+ const first = blocks[0];
55
+ const text =
56
+ first?.type === "paragraph" || first?.type === "heading"
57
+ ? plainText(first.children)
58
+ : "";
59
+ return (text.split("\n")[0] ?? "").replaceAll(/\s+/g, " ").trim();
60
+ };
61
+
62
+ export const taskSignature = (item: ListItemNode): TaskSignature => ({
63
+ checked: item.checked === true,
64
+ text: firstLineOf(item.children),
65
+ });
66
+
67
+ const lineSignature = (line: string): TaskSignature | null => {
68
+ const match = task.exec(line);
69
+ if (match === null) return null;
70
+ const rest = line.slice(match[0].length);
71
+ return {
72
+ checked: match[2] !== " ",
73
+ text: firstLineOf(parseMarkdown(rest, { allowHtml: true }).children),
74
+ };
75
+ };
76
+
77
+ interface OpenFence {
78
+ char: string;
79
+ length: number;
80
+ }
81
+
82
+ const closesFence = (line: string, open: OpenFence): boolean => {
83
+ const match = fence.exec(line);
84
+ const marker = match?.[1];
85
+ return (
86
+ marker !== undefined &&
87
+ marker.startsWith(open.char) &&
88
+ marker.length >= open.length &&
89
+ line.slice(match?.[0].length).trim() === ""
90
+ );
91
+ };
92
+
93
+ const opensFence = (line: string): OpenFence | null => {
94
+ const match = fence.exec(line);
95
+ const marker = match?.[1];
96
+ if (marker === undefined) return null;
97
+ // A backtick fence's info string cannot hold a backtick: that is inline code.
98
+ if (marker.startsWith("`") && line.slice(match?.[0].length).includes("`")) {
99
+ return null;
100
+ }
101
+ return { char: marker.charAt(0), length: marker.length };
102
+ };
103
+
104
+ const scanMarkers = (lines: readonly string[]): number[] => {
105
+ const markers: number[] = [];
106
+ let open: OpenFence | null = null;
107
+ let inHtmlBlock = false;
108
+
109
+ lines.forEach((line, index) => {
110
+ if (open) {
111
+ if (closesFence(line, open)) open = null;
112
+ return;
113
+ }
114
+ if (inHtmlBlock) {
115
+ inHtmlBlock = line.trim() !== "";
116
+ return;
117
+ }
118
+ open = opensFence(line);
119
+ if (open) return;
120
+ if (htmlBlockStart.test(line)) {
121
+ inHtmlBlock = true;
122
+ return;
123
+ }
124
+ if (task.test(line)) markers.push(index);
125
+ });
126
+ return markers;
127
+ };
128
+
129
+ const matchesParser = (
130
+ lines: readonly string[],
131
+ markers: readonly number[],
132
+ expected: readonly TaskSignature[],
133
+ ): boolean =>
134
+ markers.length === expected.length &&
135
+ markers.every((lineIndex, rank) => {
136
+ const found = lineSignature(lines[lineIndex]?.replace(/\r$/, "") ?? "");
137
+ const wanted = expected[rank];
138
+ return (
139
+ found !== null &&
140
+ wanted !== undefined &&
141
+ found.checked === wanted.checked &&
142
+ found.text === wanted.text
143
+ );
144
+ });
145
+
146
+ export const toggleTask = (
147
+ source: string,
148
+ taskIndex: number,
149
+ expected?: readonly TaskSignature[],
150
+ ): string | null => {
151
+ const lines = source.split("\n");
152
+ const markers = scanMarkers(lines);
153
+
154
+ if (expected !== undefined && !matchesParser(lines, markers, expected)) {
155
+ return null;
156
+ }
157
+ const lineIndex = markers[taskIndex];
158
+ if (lineIndex === undefined) return null;
159
+
160
+ const line = lines[lineIndex] ?? "";
161
+ const checked = task.exec(line)?.[2] !== " ";
162
+ lines[lineIndex] = line.replace(task, checked ? "$1 $3" : "$1x$3");
163
+ return lines.join("\n");
164
+ };
@@ -0,0 +1 @@
1
+ /// <reference types="react-native-css/types" />
@@ -0,0 +1,90 @@
1
+ import type {
2
+ PrReviewDecision,
3
+ PrState,
4
+ ReviewflowBoardPr,
5
+ } from "@reviewflow/sdk";
6
+ import type { Accent } from "alouette";
7
+
8
+ /**
9
+ * What a reader is told about a pull request in one word or two: an open pull
10
+ * request is only interesting for where its review stands, so state and review
11
+ * decision collapse into one status.
12
+ */
13
+ export type PrStatus =
14
+ | "approved"
15
+ | "changesRequested"
16
+ | "closed"
17
+ | "draft"
18
+ | "merged"
19
+ | "noReviewers"
20
+ | "waitingReview";
21
+
22
+ /** A closed or merged pull request keeps its own status whatever the last review said. */
23
+ export const prStatus = ({
24
+ state,
25
+ reviewDecision,
26
+ }: {
27
+ state: PrState;
28
+ reviewDecision: PrReviewDecision;
29
+ }): PrStatus => {
30
+ if (state !== "open") return state;
31
+ switch (reviewDecision) {
32
+ case "approved": {
33
+ return "approved";
34
+ }
35
+ case "changesRequested": {
36
+ return "changesRequested";
37
+ }
38
+ case "reviewRequired": {
39
+ return "waitingReview";
40
+ }
41
+ case null: {
42
+ return "noReviewers";
43
+ }
44
+ default: {
45
+ return reviewDecision satisfies never;
46
+ }
47
+ }
48
+ };
49
+
50
+ export const prStatusLabels: Record<PrStatus, string> = {
51
+ approved: "approved",
52
+ changesRequested: "changes requested",
53
+ closed: "closed",
54
+ draft: "draft",
55
+ merged: "merged",
56
+ noReviewers: "no reviewers",
57
+ waitingReview: "waiting review",
58
+ };
59
+
60
+ /** A merged pull request takes the brand accent; an app with its own accent for it passes one instead. */
61
+ export const prStatusAccents: Record<PrStatus, Accent | undefined> = {
62
+ approved: "success",
63
+ changesRequested: "danger",
64
+ closed: "danger",
65
+ draft: undefined,
66
+ merged: "brand",
67
+ noReviewers: "warning",
68
+ waitingReview: "info",
69
+ };
70
+
71
+ const reviewflowReviewDecisions = {
72
+ approved: "approved",
73
+ changesRequested: "changesRequested",
74
+ requested: "reviewRequired",
75
+ none: null,
76
+ } as const;
77
+
78
+ /** null when reviewflow does not know enough to say */
79
+ export const reviewflowPrStatus = ({
80
+ state,
81
+ reviewState,
82
+ }: Pick<ReviewflowBoardPr, "reviewState" | "state">): PrStatus | null => {
83
+ if (state === null) return null;
84
+ if (state !== "open") return state;
85
+ if (reviewState === null) return null;
86
+ return prStatus({
87
+ state,
88
+ reviewDecision: reviewflowReviewDecisions[reviewState],
89
+ });
90
+ };