@vintasoftware/pr-review-canvas 0.1.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 (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,150 @@
1
+ // @ts-check
2
+ // The canvas link scheme. Four forms, used in markdown, diagrams, and chat:
3
+ // #layer:<layerKey>
4
+ // #file:<path>
5
+ // #hunk:<path>#<n>
6
+ // #line:<path>:<start>[-<end>][:old]
7
+ // The server validates links with this parser (src/contract/links.ts re-exports it) and the
8
+ // browser turns them into anchors, so both sides agree on what a link means.
9
+
10
+ /**
11
+ * @typedef {{ kind: 'layer', layerKey: string }
12
+ * | { kind: 'file', path: string }
13
+ * | { kind: 'hunk', path: string, n: number }
14
+ * | { kind: 'line', path: string, start: number, end: number, side: 'new' | 'old' }} ParsedLink
15
+ */
16
+
17
+ /**
18
+ * @typedef {{
19
+ * layers: ReadonlyArray<{ key: string }>,
20
+ * files: ReadonlyArray<{ path: string, hunks: ReadonlyArray<import('./hunks.js').HunkRange> }>,
21
+ * }} LinkTargets
22
+ */
23
+ import { hunkForLine, hunkLineRanges } from './hunks.js'
24
+
25
+ const LINE_RE = /^([^\n]+?):(\d+)(?:-(\d+))?(:old)?$/
26
+
27
+ /**
28
+ * Parses one link. Returns null for anything that is not one of the four forms.
29
+ * @param {string} href
30
+ * @returns {ParsedLink | null}
31
+ */
32
+ export function parseLink(href) {
33
+ if (href.startsWith('#layer:')) {
34
+ const layerKey = href.slice('#layer:'.length)
35
+ return layerKey ? { kind: 'layer', layerKey } : null
36
+ }
37
+ if (href.startsWith('#file:')) {
38
+ const path = href.slice('#file:'.length)
39
+ return path ? { kind: 'file', path } : null
40
+ }
41
+ if (href.startsWith('#hunk:')) {
42
+ const m = /^(.+)#(\d+)$/.exec(href.slice('#hunk:'.length))
43
+ if (!m || m[1] === undefined || m[2] === undefined) {
44
+ return null
45
+ }
46
+ return { kind: 'hunk', path: m[1], n: Number(m[2]) }
47
+ }
48
+ if (href.startsWith('#line:')) {
49
+ const m = LINE_RE.exec(href.slice('#line:'.length))
50
+ if (!m || m[1] === undefined || m[2] === undefined) {
51
+ return null
52
+ }
53
+ const start = Number(m[2])
54
+ const end = m[3] === undefined ? start : Number(m[3])
55
+ if (end < start) {
56
+ return null
57
+ }
58
+ return { kind: 'line', path: m[1], start, end, side: m[4] ? 'old' : 'new' }
59
+ }
60
+ return null
61
+ }
62
+
63
+ /**
64
+ * Every link found in a markdown text, in order. Matches the `#…` inside `](…)` and bare links
65
+ * that stand on their own (surrounded by whitespace or at the edges).
66
+ * @param {string} markdown
67
+ * @returns {string[]}
68
+ */
69
+ export function extractLinks(markdown) {
70
+ /** @type {string[]} */
71
+ const out = []
72
+ const re = /\]\((#(?:layer|file|hunk|line):[^)\s]+)\)|(?:^|\s)(#(?:layer|file|hunk|line):[^\s)]+)/g
73
+ for (const m of markdown.matchAll(re)) {
74
+ if (m[1] !== undefined) {
75
+ out.push(m[1])
76
+ } else if (m[2] !== undefined) {
77
+ // A bare link at the end of a sentence carries the punctuation; it is not part of the target.
78
+ out.push(m[2].replace(/[.,;:!?]+$/, ''))
79
+ }
80
+ }
81
+ return out
82
+ }
83
+
84
+ /**
85
+ * Checks a parsed link against the artifact. The message explains what is missing so the
86
+ * validator can print it as-is.
87
+ * @param {ParsedLink} link
88
+ * @param {LinkTargets} targets
89
+ * @returns {{ ok: true } | { ok: false, message: string }}
90
+ */
91
+ export function resolveLink(link, targets) {
92
+ if (link.kind === 'layer') {
93
+ return targets.layers.some(l => l.key === link.layerKey)
94
+ ? { ok: true }
95
+ : { ok: false, message: `layer ${link.layerKey} does not exist` }
96
+ }
97
+ const file = targets.files.find(f => f.path === link.path)
98
+ if (!file) {
99
+ return { ok: false, message: `${link.path} is not in the diff` }
100
+ }
101
+ if (link.kind === 'hunk' && (link.n < 1 || link.n > file.hunks.length)) {
102
+ return { ok: false, message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length} hunks)` }
103
+ }
104
+ if (link.kind === 'line') {
105
+ const start = hunkForLine(file.hunks, link.side, link.start)
106
+ const end = hunkForLine(file.hunks, link.side, link.end)
107
+ if (start === null || start !== end) {
108
+ const range = link.end === link.start ? `${link.start}` : `${link.start}-${link.end}`
109
+ return { ok: false, message: `${link.path}:${range} (${link.side}) is not inside one hunk of the diff (${hunkLineRanges(file.hunks, link.side)})` }
110
+ }
111
+ }
112
+ return { ok: true }
113
+ }
114
+
115
+ /**
116
+ * The DOM id a link points at. Line links target their first line.
117
+ * @param {ParsedLink} link
118
+ * @param {(path: string) => string} keyFor
119
+ * @returns {string}
120
+ */
121
+ export function linkTargetId(link, keyFor) {
122
+ switch (link.kind) {
123
+ case 'layer':
124
+ return `layer-${link.layerKey}`
125
+ case 'file':
126
+ return `file-${keyFor(link.path)}`
127
+ case 'hunk':
128
+ return `hunk-${keyFor(link.path)}-${link.n}`
129
+ case 'line':
130
+ return `L-${keyFor(link.path)}-${link.side}-${link.start}`
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Short text for a link when the markdown had none, for example `packages/x.ts:40-52`.
136
+ * @param {ParsedLink} link
137
+ * @returns {string}
138
+ */
139
+ export function linkLabel(link) {
140
+ switch (link.kind) {
141
+ case 'layer':
142
+ return link.layerKey
143
+ case 'file':
144
+ return link.path
145
+ case 'hunk':
146
+ return `${link.path} hunk ${link.n}`
147
+ case 'line':
148
+ return `${link.path}:${link.start}${link.end !== link.start ? `-${link.end}` : ''}${link.side === 'old' ? ' (old)' : ''}`
149
+ }
150
+ }
@@ -0,0 +1,232 @@
1
+ // @ts-check
2
+ // Markdown for every AI-written or GitHub-written text: marked (GFM, raw HTML off) →
3
+ // DOMPurify with an allowlist → headings demoted → path:line linkify. Returns an HTML string that
4
+ // is safe to assign to innerHTML.
5
+ import DOMPurify from 'dompurify'
6
+ import { Marked } from 'marked'
7
+ import { diagramPlaceholderHtml } from './diagram.js'
8
+ import { esc } from './dom.js'
9
+ import { linkLabel, parseLink } from './links.js'
10
+ import { splitMermaid } from './mermaid-fences.js'
11
+
12
+ const ALLOWED_TAGS = [
13
+ 'p',
14
+ 'br',
15
+ 'em',
16
+ 'strong',
17
+ 'code',
18
+ 'pre',
19
+ 'ul',
20
+ 'ol',
21
+ 'li',
22
+ 'a',
23
+ 'blockquote',
24
+ 'table',
25
+ 'thead',
26
+ 'tbody',
27
+ 'tr',
28
+ 'th',
29
+ 'td',
30
+ // h1 and h2 pass the sanitizer only so demoteHeadings can turn them into h3 and h4.
31
+ 'h1',
32
+ 'h2',
33
+ 'h3',
34
+ 'h4',
35
+ 'h5',
36
+ 'h6',
37
+ 'hr',
38
+ 'del',
39
+ 'input',
40
+ 'img',
41
+ 'details',
42
+ 'summary',
43
+ 'kbd',
44
+ 'sup',
45
+ 'sub',
46
+ ]
47
+ // `type`, `checked`, and `disabled` are for GFM task-list checkboxes. No `class`: model text must
48
+ // not be able to style itself as a command.
49
+ const ALLOWED_ATTR = ['href', 'src', 'alt', 'title', 'type', 'checked', 'disabled', 'open']
50
+ const ALLOWED_URI = /^(?:https?:|#)/i
51
+
52
+ const marked = new Marked({
53
+ gfm: true,
54
+ breaks: false,
55
+ renderer: {
56
+ // Raw HTML in the source renders as text, never as markup.
57
+ html(token) {
58
+ return esc(token.text)
59
+ },
60
+ },
61
+ })
62
+
63
+ const githubMarked = new Marked({ gfm: true, breaks: false })
64
+
65
+ /** Model text never outranks the page's own headings: h1→h3, h2→h4, h3→h5, h4-h6→h6. */
66
+ const DEMOTE = { H1: 'h3', H2: 'h4', H3: 'h5', H4: 'h6', H5: 'h6', H6: 'h6' }
67
+
68
+ /**
69
+ * @param {HTMLElement} root
70
+ */
71
+ function demoteHeadings(root) {
72
+ for (const el of Array.from(root.querySelectorAll('h1,h2,h3,h4,h5,h6'))) {
73
+ const tag = DEMOTE[/** @type {keyof typeof DEMOTE} */ (el.tagName)]
74
+ const next = document.createElement(tag)
75
+ while (el.firstChild) {
76
+ next.append(el.firstChild)
77
+ }
78
+ el.replaceWith(next)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Turns `path/to/file.ts:12` and `path/to/file.ts:12-20` in text nodes into canvas links, but
84
+ * only for paths that are in the diff.
85
+ * @param {HTMLElement} root
86
+ * @param {ReadonlySet<string>} paths
87
+ */
88
+ function linkifyPaths(root, paths) {
89
+ if (paths.size === 0) {
90
+ return
91
+ }
92
+ const re = /((?:[\w@.-]+\/)+[\w@.-]+\.[\w-]+):(\d+)(?:-(\d+))?/g
93
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
94
+ /** @type {Text[]} */
95
+ const texts = []
96
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
97
+ const parent = n.parentElement
98
+ if (parent && !parent.closest('a,code,pre')) {
99
+ texts.push(/** @type {Text} */ (n))
100
+ }
101
+ }
102
+ for (const node of texts) {
103
+ const text = node.data
104
+ let last = 0
105
+ /** @type {Node[]} */
106
+ const out = []
107
+ for (const m of text.matchAll(re)) {
108
+ const path = m[1] ?? ''
109
+ if (!paths.has(path) || m.index === undefined) {
110
+ continue
111
+ }
112
+ out.push(document.createTextNode(text.slice(last, m.index)))
113
+ const a = document.createElement('a')
114
+ const range = m[3] ? `${m[2]}-${m[3]}` : m[2]
115
+ a.href = `#line:${path}:${range}`
116
+ a.className = 'loc'
117
+ a.textContent = m[0]
118
+ out.push(a)
119
+ last = m.index + m[0].length
120
+ }
121
+ if (out.length === 0) {
122
+ continue
123
+ }
124
+ out.push(document.createTextNode(text.slice(last)))
125
+ node.replaceWith(...out)
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Canvas links get `.loc` and a label when empty; http(s) links open in a new tab.
131
+ * @param {HTMLElement} root
132
+ */
133
+ function decorateLinks(root) {
134
+ for (const a of Array.from(root.querySelectorAll('a'))) {
135
+ const href = a.getAttribute('href') ?? ''
136
+ const link = parseLink(href)
137
+ if (link) {
138
+ a.classList.add('loc')
139
+ a.setAttribute('data-link', href)
140
+ if (a.textContent === '' || a.textContent === href) {
141
+ a.textContent = linkLabel(link)
142
+ }
143
+ } else if (/^https?:/i.test(href)) {
144
+ a.setAttribute('target', '_blank')
145
+ a.setAttribute('rel', 'noopener noreferrer')
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * One markdown text, without mermaid blocks.
152
+ * @param {string} src markdown
153
+ * @param {{ paths?: ReadonlySet<string>, github?: boolean }} opts
154
+ * @returns {string} sanitized HTML
155
+ */
156
+ function renderProse(src, opts) {
157
+ const raw = (opts.github ? githubMarked : marked).parse(src, { async: false })
158
+ // marked's renderer escapes raw HTML, and the sanitizer is still the boundary: nothing from
159
+ // `raw` reaches a DOM tree before DOMPurify has parsed and filtered it.
160
+ const root = document.createElement('div')
161
+ root.append(
162
+ DOMPurify.sanitize(raw, {
163
+ ALLOWED_TAGS,
164
+ FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'style', 'form'],
165
+ ALLOWED_ATTR,
166
+ ALLOWED_URI_REGEXP: ALLOWED_URI,
167
+ KEEP_CONTENT: true,
168
+ RETURN_DOM_FRAGMENT: true,
169
+ })
170
+ )
171
+ for (const element of root.querySelectorAll('*')) {
172
+ if (!ALLOWED_TAGS.includes(element.tagName.toLowerCase())) element.remove()
173
+ }
174
+ for (const img of root.querySelectorAll('img')) {
175
+ if (!/^https:\/\//i.test(img.getAttribute('src') ?? '')) {
176
+ img.remove()
177
+ } else {
178
+ img.loading = 'lazy'
179
+ img.referrerPolicy = 'no-referrer'
180
+ }
181
+ }
182
+ for (const input of root.querySelectorAll('input')) {
183
+ input.type = 'checkbox'
184
+ input.disabled = true
185
+ }
186
+ demoteHeadings(root)
187
+ linkifyPaths(root, opts.paths ?? new Set())
188
+ decorateLinks(root)
189
+ return root.innerHTML
190
+ }
191
+
192
+ /**
193
+ * The link reference definitions of a text, written out again as markdown. Marked reads them, so
194
+ * a definition shown inside a code block is an example and a definition written over two lines is
195
+ * still one definition. The title is dropped, since the sanitizer allows no `title` attribute.
196
+ * @param {string} src markdown
197
+ * @returns {string}
198
+ */
199
+ function linkDefinitions(src) {
200
+ // The destination is already parsed, so the characters marked reads in one are written back
201
+ // escaped: it parses this text a second time and must arrive at the same destination.
202
+ return Object.entries(marked.lexer(src).links)
203
+ .map(([label, def]) => `[${label}]: <${def.href.replace(/[\\<>]/g, ch => `\\${ch}`)}>`)
204
+ .join('\n')
205
+ }
206
+
207
+ /**
208
+ * Markdown as sanitized HTML. Two fields draw diagrams, the PR summary and a layer rationale, and
209
+ * they ask for it with `diagrams`. Everywhere else a ```mermaid fence stays a code block: a PR
210
+ * description and a GitHub comment are text from anyone, and the validator counts and measures
211
+ * diagrams only in the two fields the model writes them in.
212
+ * @param {string} src markdown
213
+ * @param {{ paths?: ReadonlySet<string>, diagrams?: boolean, github?: boolean }} [opts] `paths` linkifies `path:line`
214
+ * @returns {string} sanitized HTML
215
+ */
216
+ export function renderMarkdown(src, opts = {}) {
217
+ if (opts.diagrams !== true) {
218
+ return renderProse(src, opts)
219
+ }
220
+ const segments = splitMermaid(src)
221
+ // Each piece of prose is parsed on its own, so the link definitions of the whole text are
222
+ // repeated in front of every piece: `[label]: target` written after a drawing still resolves a
223
+ // `[label]` written before it. A definition renders nothing, so repeating one shows nothing.
224
+ const defs = linkDefinitions(src)
225
+ return segments
226
+ .map(segment =>
227
+ segment.type === 'mermaid'
228
+ ? diagramPlaceholderHtml(segment.text)
229
+ : renderProse(defs === '' ? segment.text : `${defs}\n\n${segment.text}`, opts)
230
+ )
231
+ .join('')
232
+ }
@@ -0,0 +1,55 @@
1
+ // @ts-check
2
+ // Finds the ```mermaid fences in a markdown text. One implementation for both sides: the client
3
+ // turns each block into a diagram placeholder, and the server counts and measures them
4
+ // (src/contract/mermaid-fences.ts re-exports this file).
5
+ import { splitFences } from './fences.js'
6
+
7
+ /** @typedef {{ type: 'markdown' | 'mermaid', text: string }} MermaidSegment */
8
+
9
+ /**
10
+ * The pieces of a markdown text in order: prose and mermaid blocks. A mermaid block is a fenced
11
+ * block whose info string is exactly `mermaid`.
12
+ * @param {string} markdown
13
+ * @returns {MermaidSegment[]}
14
+ */
15
+ export function splitMermaid(markdown) {
16
+ return splitFences(markdown, 'mermaid').map(s => ({
17
+ type: s.type === 'block' ? 'mermaid' : 'markdown',
18
+ text: s.text,
19
+ }))
20
+ }
21
+
22
+ /**
23
+ * The kind of drawing a source asks for, taken from its first word: `stateDiagram-v2` reads as
24
+ * `stateDiagram`. An empty or unreadable source has no kind.
25
+ * @param {string} source mermaid text
26
+ * @returns {string}
27
+ */
28
+ export function diagramKind(source) {
29
+ const first = source.split('\n').find(line => line.trim() !== '' && !line.trim().startsWith('%%')) ?? ''
30
+ const word = first.trim().split(/[\s{(]/)[0] ?? ''
31
+ return /^[A-Za-z]/.test(word) ? word.replace(/-v\d+$/, '') : ''
32
+ }
33
+
34
+ /**
35
+ * The source of every mermaid block, in order.
36
+ * @param {string} markdown
37
+ * @returns {string[]}
38
+ */
39
+ export function mermaidBlocks(markdown) {
40
+ return splitMermaid(markdown)
41
+ .filter(s => s.type === 'mermaid')
42
+ .map(s => s.text)
43
+ }
44
+
45
+ /**
46
+ * The text without its mermaid blocks, for a length measured on the prose a reader reads.
47
+ * @param {string} markdown
48
+ * @returns {string}
49
+ */
50
+ export function withoutMermaid(markdown) {
51
+ return splitMermaid(markdown)
52
+ .filter(s => s.type === 'markdown')
53
+ .map(s => s.text)
54
+ .join('\n')
55
+ }
@@ -0,0 +1,91 @@
1
+ // @ts-check
2
+ // The page order of layers and file cards, for the rail, scrollspy, and the j/k n/p keys. Other
3
+ // comes last with its files, mirroring the ids layers.js renders.
4
+ /** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
5
+ import { fileAnchorId, layerAnchorId, sanitizeKey } from './keys.js'
6
+
7
+ /**
8
+ * @typedef {{ kind: 'overview', id: 'overview' }
9
+ * | { kind: 'layer', id: string, layerId: string, key: string, other: boolean }
10
+ * | { kind: 'file', id: string, layerId: string, key: string, path: string, isTest: boolean, other: boolean }} NavItem
11
+ */
12
+
13
+ /**
14
+ * Overview, then every layer followed by its file cards, with the Other layer last.
15
+ * @param {ReviewArtifact} artifact
16
+ * @returns {NavItem[]}
17
+ */
18
+ export function buildNavOrder(artifact) {
19
+ /** @type {NavItem[]} */
20
+ const out = [{ kind: 'overview', id: 'overview' }]
21
+ const layers = [
22
+ ...artifact.layers.filter(l => l.kind !== 'other'),
23
+ ...artifact.layers.filter(l => l.kind === 'other'),
24
+ ]
25
+ /** @type {Set<string>} */
26
+ const seen = new Set()
27
+ for (const layer of layers) {
28
+ const other = layer.kind === 'other'
29
+ out.push({ kind: 'layer', id: layerAnchorId(layer.key), layerId: layer.id, key: layer.key, other })
30
+ for (const f of layer.files) {
31
+ const key = sanitizeKey(f.path)
32
+ // The first card of a file carries the plain id; later cards of the same file add the layer key.
33
+ const id = seen.has(key) ? `${fileAnchorId(key)}-${layer.key}` : fileAnchorId(key)
34
+ seen.add(key)
35
+ out.push({ kind: 'file', id, layerId: layer.id, key, path: f.path, isTest: f.isTest, other })
36
+ }
37
+ }
38
+ return out
39
+ }
40
+
41
+ /**
42
+ * @param {ReadonlyArray<NavItem>} order
43
+ * @param {string | null} currentId
44
+ * @param {'layer' | 'file'} kind
45
+ * @param {1 | -1} direction
46
+ * @returns {NavItem | null}
47
+ */
48
+ function step(order, currentId, kind, direction) {
49
+ const from = currentId === null ? (direction === 1 ? -1 : order.length) : order.findIndex(i => i.id === currentId)
50
+ const start = from === -1 && currentId !== null ? (direction === 1 ? -1 : order.length) : from
51
+ for (let i = start + direction; i >= 0 && i < order.length; i += direction) {
52
+ const item = order[i]
53
+ if (item !== undefined && item.kind === kind) {
54
+ return item
55
+ }
56
+ }
57
+ return null
58
+ }
59
+
60
+ /** @param {ReadonlyArray<NavItem>} order @param {string | null} currentId */
61
+ export function nextLayer(order, currentId) {
62
+ return step(order, currentId, 'layer', 1)
63
+ }
64
+
65
+ /** @param {ReadonlyArray<NavItem>} order @param {string | null} currentId */
66
+ export function prevLayer(order, currentId) {
67
+ return step(order, currentId, 'layer', -1)
68
+ }
69
+
70
+ /** @param {ReadonlyArray<NavItem>} order @param {string | null} currentId */
71
+ export function nextFile(order, currentId) {
72
+ return step(order, currentId, 'file', 1)
73
+ }
74
+
75
+ /** @param {ReadonlyArray<NavItem>} order @param {string | null} currentId */
76
+ export function prevFile(order, currentId) {
77
+ return step(order, currentId, 'file', -1)
78
+ }
79
+
80
+ /**
81
+ * The layer an item belongs to: itself for a layer, its owner for a file, null for the overview.
82
+ * @param {ReadonlyArray<NavItem>} order
83
+ * @param {string} id
84
+ */
85
+ export function layerOf(order, id) {
86
+ const item = order.find(i => i.id === id)
87
+ if (!item || item.kind === 'overview') {
88
+ return null
89
+ }
90
+ return item.kind === 'layer' ? item : (order.find(i => i.kind === 'layer' && i.layerId === item.layerId) ?? null)
91
+ }
@@ -0,0 +1,85 @@
1
+ // @ts-check
2
+ /** @typedef {import('./contract-types.js').PrBundle} PrBundle */
3
+ /** @typedef {import('./contract-types.js').IssueComment} IssueComment */
4
+
5
+ import { commentHtml } from './diff-decorations.js'
6
+ import { viewCommentHtml } from './comment-link.js'
7
+ import { detailsSummaryHtml, esc, avatarHtml, timeAgo } from './dom.js'
8
+ import { renderMarkdown } from './markdown.js'
9
+ import { dismissedListHtml, postedUrls, sevsumHtml } from './points.js'
10
+
11
+ /**
12
+ * "What changes": the PR-wide summary as one prose block.
13
+ * @param {string} summary
14
+ * @param {ReadonlySet<string>} paths
15
+ */
16
+ export function summaryHtml(summary, paths) {
17
+ return `<div class="summary prose">${renderMarkdown(summary, { paths, diagrams: true })}</div>`
18
+ }
19
+
20
+ /**
21
+ * @param {IssueComment} c
22
+ * @param {Date} now
23
+ */
24
+ export function issueCommentHtml(c, now) {
25
+ return (
26
+ `<div class="cmt">${avatarHtml(c)}` +
27
+ `<span class="who"><b>${esc(c.author)}</b> <span class="muted">${esc(timeAgo(c.createdAt, now))}</span> ${viewCommentHtml(c.url)}</span>` +
28
+ `<div class="prose">${renderMarkdown(c.body, { github: true })}</div></div>`
29
+ )
30
+ }
31
+
32
+ /**
33
+ * PR-level comments; bot comments (`[bot]` authors) collapse into one details block.
34
+ * @param {ReadonlyArray<IssueComment>} comments
35
+ * @param {Date} now
36
+ */
37
+ export function conversationHtml(comments, now) {
38
+ const humans = comments.filter(c => !c.author.endsWith('[bot]'))
39
+ const bots = comments.filter(c => c.author.endsWith('[bot]'))
40
+ const humanHtml =
41
+ humans.length === 0
42
+ ? '<p class="muted">No PR-level comments yet.</p>'
43
+ : humans.map(c => issueCommentHtml(c, now)).join('')
44
+ const botHtml =
45
+ bots.length === 0
46
+ ? ''
47
+ : `<details class="bots">${detailsSummaryHtml(`<span class="muted small">${bots.length} bot ${bots.length === 1 ? 'comment' : 'comments'}</span>`, 'Toggle bot comments')}${bots
48
+ .map(c => issueCommentHtml(c, now))
49
+ .join('')}</details>`
50
+ return (
51
+ `<h3 class="lbl sub">Conversation · ${comments.length}</h3><div class="body conversation">${humanHtml}${botHtml}` +
52
+ '<div class="pr-composer-host"></div>' +
53
+ '<p><button class="cmd" type="button" data-act="pr-comment" data-needs-post>comment</button></p></div>'
54
+ )
55
+ }
56
+
57
+ /**
58
+ * @param {PrBundle} bundle
59
+ * @param {{ paths: ReadonlySet<string>, now: Date }} ctx
60
+ * @returns {string}
61
+ */
62
+ export function renderOverview(bundle, ctx) {
63
+ const artifact = bundle.artifact
64
+ const active = artifact ? artifact.points.filter(p => bundle.state.dismissed[p.fingerprint] === undefined) : []
65
+ const summary = artifact ? summaryHtml(artifact.summary, ctx.paths) : ''
66
+ const description = bundle.pr.body.trim()
67
+ ? `<details class="pr-desc">${detailsSummaryHtml('<span>PR description (from GitHub)</span>', 'Toggle PR description')}<div class="body prose">${renderMarkdown(bundle.pr.body, { paths: ctx.paths, github: true })}</div></details>`
68
+ : '<div class="pr-desc body muted">No PR description.</div>'
69
+ return (
70
+ '<section class="panel" id="overview" aria-labelledby="ov-h">' +
71
+ `<div class="panel-h"><h2 id="ov-h">Overview</h2>${artifact ? sevsumHtml(active, artifact.layers) : ''}</div>` +
72
+ `<div class="body">${summary}</div>` +
73
+ description +
74
+ conversationHtml(bundle.comments.issueComments, ctx.now) +
75
+ (bundle.comments.reviews ?? []).map(review => `<div class="body"><span class="pill">${esc(review.state.toLowerCase().replaceAll('_', ' '))}</span>${issueCommentHtml(review, ctx.now)}</div>`).join('') +
76
+ (bundle.comments.reviewComments.length ? `<details class="body all-review-comments">${detailsSummaryHtml(`<span>All review comments · ${bundle.comments.reviewComments.length}</span>`, 'Toggle all review comments')}${bundle.comments.reviewComments.map(c => `<div><p class="muted small">${esc(c.path)}${c.line ? `:${c.line}` : ''}${c.outdated ? ' · outdated' : ''}${c.resolved ? ' · resolved' : ''}</p>${commentHtml(c, ctx.now)}</div>`).join('')}</details>` : '') +
77
+ (artifact
78
+ ? dismissedListHtml(artifact.points, bundle.state, {
79
+ paths: ctx.paths,
80
+ posted: postedUrls(bundle.state, bundle.comments.reviewComments),
81
+ })
82
+ : '') +
83
+ '</section>'
84
+ )
85
+ }