@reviewflow/ui 0.0.0-stage → 6.22.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.
- package/LICENSE +15 -0
- package/README.md +95 -2
- package/package.json +101 -3
- package/src/board/bucket-section.tsx +94 -0
- package/src/board/dataSource.ts +44 -0
- package/src/board/facts.ts +183 -0
- package/src/board/index.ts +12 -0
- package/src/board/pull-request-board.tsx +239 -0
- package/src/board/pull-request-row.tsx +181 -0
- package/src/components/branch-diff/diff-themes.ts +6 -0
- package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
- package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
- package/src/components/branch-diff/diff-worker.ts +7 -0
- package/src/components/branch-diff/use-collapsed-files.ts +49 -0
- package/src/components/branch-diff/use-diff-settings.tsx +97 -0
- package/src/components/branch-diff/whitespace-button.tsx +32 -0
- package/src/components/copyable-code-block.tsx +101 -0
- package/src/components/label-pill.tsx +84 -0
- package/src/components/link-in-row.ts +6 -0
- package/src/components/markdown-suggestion.tsx +60 -0
- package/src/components/markdown-text.tsx +1067 -0
- package/src/components/pull-request-actions-merge.tsx +341 -0
- package/src/components/pull-request-actions-review.tsx +218 -0
- package/src/components/pull-request-checks.tsx +86 -0
- package/src/components/pull-request-commit-files.ts +143 -0
- package/src/components/pull-request-commits.tsx +221 -0
- package/src/components/pull-request-conversation-card.tsx +220 -0
- package/src/components/pull-request-conversation-edit.tsx +147 -0
- package/src/components/pull-request-conversation-event.tsx +267 -0
- package/src/components/pull-request-conversation-person.tsx +28 -0
- package/src/components/pull-request-conversation-thread.tsx +158 -0
- package/src/components/pull-request-diff-annotations.tsx +443 -0
- package/src/components/pull-request-diff-collapse.ts +43 -0
- package/src/components/pull-request-diff-data.ts +174 -0
- package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
- package/src/components/pull-request-diff-impacts.ts +45 -0
- package/src/components/pull-request-diff-jump.ts +87 -0
- package/src/components/pull-request-diff-lines.ts +103 -0
- package/src/components/pull-request-diff-overview.tsx +416 -0
- package/src/components/pull-request-diff-view.tsx +438 -0
- package/src/components/pull-request-diff-viewer.tsx +1374 -0
- package/src/components/pull-request-files.tsx +533 -0
- package/src/components/pull-request-markdown-input.tsx +91 -0
- package/src/components/pull-request-panel.tsx +1270 -0
- package/src/components/pull-request-part-notice.tsx +82 -0
- package/src/components/pull-request-reactions.tsx +178 -0
- package/src/components/pull-request-review.tsx +164 -0
- package/src/components/pull-request-revisions.tsx +273 -0
- package/src/components/pull-request-screen.tsx +299 -0
- package/src/components/pull-request-suggestion-actions.tsx +201 -0
- package/src/components/pull-request-timeline.tsx +270 -0
- package/src/components/repository-attachment-banner.tsx +42 -0
- package/src/components/scroll-parent.ts +50 -0
- package/src/css.d.ts +1 -0
- package/src/dataSource.ts +242 -0
- package/src/editor/alerts.ts +105 -0
- package/src/editor/code-editor.native.tsx +25 -0
- package/src/editor/code-editor.tsx +112 -0
- package/src/editor/codeEditorExtensions.ts +65 -0
- package/src/editor/codeHighlight.ts +71 -0
- package/src/editor/commands.ts +175 -0
- package/src/editor/decorations.ts +425 -0
- package/src/editor/editable-file.tsx +224 -0
- package/src/editor/emojiCompletion.ts +110 -0
- package/src/editor/extensions.ts +149 -0
- package/src/editor/frontmatter.ts +76 -0
- package/src/editor/inlineFormat.ts +114 -0
- package/src/editor/lineTypes.ts +55 -0
- package/src/editor/markdown-editor.css +511 -0
- package/src/editor/markdown-editor.native.tsx +29 -0
- package/src/editor/markdown-editor.tsx +295 -0
- package/src/editor/nodeClasses.ts +69 -0
- package/src/index.ts +75 -0
- package/src/markdown-github/flatten.ts +49 -0
- package/src/markdown-github/html-tokens.ts +93 -0
- package/src/markdown-github/pair-html.ts +354 -0
- package/src/markdown-github/safe-url.ts +15 -0
- package/src/markdown-github/table-columns.ts +23 -0
- package/src/markdown-github/toggle-task.ts +164 -0
- package/src/nativewind-env.d.ts +1 -0
- package/src/prStatus.ts +90 -0
- package/src/services/PullRequestDraftsProvider.tsx +179 -0
- package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
- package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
- package/src/services/emojify.ts +13 -0
- package/src/services/errorToMessage.ts +6 -0
- package/src/services/expiringDraftStore.ts +102 -0
- package/src/services/formatDataAge.ts +12 -0
- package/src/services/formatRelativeDate.ts +56 -0
- package/src/services/githubImages.tsx +33 -0
- package/src/services/isViewerAuthor.ts +7 -0
- 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" />
|
package/src/prStatus.ts
ADDED
|
@@ -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
|
+
};
|