@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,206 @@
1
+ // @ts-check
2
+ // The settings dialog: the personal chat settings this browser can change, and a read-only look
3
+ // at the project config, which is committed and belongs to the repository.
4
+ /** @typedef {import('./contract-types.js').AgentsResponse} AgentsResponse */
5
+ /** @typedef {import('./contract-types.js').SettingsResponse} SettingsResponse */
6
+ import { fetchAgents, fetchSettings, probeAgent, saveSettings } from './api.js'
7
+ import { runCommand } from './commands.js'
8
+ import { esc, qs } from './dom.js'
9
+
10
+ export const SETTINGS_DIALOG_ID = 'settings-dialog'
11
+
12
+ /** Model ids the input suggests per agent. Free text is allowed; this is only a shortcut. */
13
+ export const MODEL_SUGGESTIONS = {
14
+ claude: ['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5-20251001'],
15
+ codex: ['gpt-5.2', 'gpt-5.2[high]'],
16
+ }
17
+
18
+ /** @param {string} agent */
19
+ export function modelOptionsHtml(agent) {
20
+ const list = agent === 'codex' ? MODEL_SUGGESTIONS.codex : MODEL_SUGGESTIONS.claude
21
+ return list.map(id => `<option value="${esc(id)}"></option>`).join('')
22
+ }
23
+
24
+ /**
25
+ * @param {SettingsResponse} data
26
+ * @param {AgentsResponse} agents
27
+ * @returns {string}
28
+ */
29
+ export function settingsDialogHtml(data, agents) {
30
+ const { settings, overrides, project } = data
31
+ const options = agents.agents
32
+ .map(a => {
33
+ const reason = a.available ? '' : ` (${a.reason ?? 'not available'})`
34
+ return `<option value="${esc(a.id)}"${a.id === settings.agent ? ' selected' : ''}${a.available ? '' : ' disabled'}>${esc(a.id)}${esc(reason)}</option>`
35
+ })
36
+ .join('')
37
+ const overrideNote =
38
+ overrides.agent === undefined && overrides.model === undefined
39
+ ? ''
40
+ : `<p class="notice" role="status">A serve flag wins over this file for now: ${esc(
41
+ [
42
+ overrides.agent === undefined ? '' : `--agent ${overrides.agent}`,
43
+ overrides.model === undefined ? '' : `--model ${overrides.model}`,
44
+ ]
45
+ .filter(Boolean)
46
+ .join(' ')
47
+ )}</p>`
48
+ return (
49
+ `<dialog id="${SETTINGS_DIALOG_ID}" class="settings" aria-labelledby="settings-h">` +
50
+ '<h2 id="settings-h">Settings</h2>' +
51
+ overrideNote +
52
+ (agents.acpx.installed
53
+ ? ''
54
+ : '<p class="notice" role="status">acpx is not on PATH, so AI Chat is off. Install acpx and reload.</p>') +
55
+ '<div class="field"><label for="set-agent">Agent</label>' +
56
+ `<select id="set-agent">${options}</select></div>` +
57
+ '<div class="field"><label for="set-model">Model</label>' +
58
+ `<input id="set-model" list="model-list" value="${esc(settings.model ?? '')}" placeholder="the agent's default">` +
59
+ `<datalist id="model-list">${modelOptionsHtml(settings.agent)}</datalist></div>` +
60
+ '<div class="field"><label for="set-timeout">Chat timeout (seconds)</label>' +
61
+ `<input id="set-timeout" type="number" min="30" max="3600" value="${esc(settings.chatTimeoutSec)}"></div>` +
62
+ '<div class="field"><label for="set-turns">Max turns</label>' +
63
+ `<input id="set-turns" type="number" min="1" max="100" value="${esc(settings.maxTurns ?? '')}" placeholder="the agent's default"></div>` +
64
+ '<p class="muted small">Changing the agent starts a new chat thread; the old ones stay in the list.</p>' +
65
+ `<p class="muted small mono">${esc(data.file)}</p>` +
66
+ '<div class="panel-ro"><h3>Project config (read-only)</h3>' +
67
+ `<ul class="plain"><li>chat enabled: ${project.chatEnabled ? 'yes' : 'no'}</li>` +
68
+ `<li>rulebook: ${esc(project.rulebook ?? 'none')}</li>` +
69
+ `<li>layers in the taxonomy: ${project.layers}</li>` +
70
+ `<li>high-risk patterns: ${project.highRisk}</li>` +
71
+ `<li>max repair rounds: ${project.maxRepairRounds}</li>` +
72
+ `<li>inline diff max lines: ${project.inlineDiffMaxLines}</li>` +
73
+ `<li>small change set: ${project.smallPrHunks} hunks</li></ul>` +
74
+ `<p class="muted small mono">${esc(project.file ?? 'built-in defaults (no pr-review.config.yml)')}</p></div>` +
75
+ '<p class="probe-result" role="status"></p>' +
76
+ '<div class="dialog-actions">' +
77
+ '<button class="cmd" type="button" data-act="settings-probe">test agent</button>' +
78
+ '<button class="cmd fill" type="button" data-act="settings-save">save</button>' +
79
+ '<button class="cmd" type="button" data-act="settings-close">close</button>' +
80
+ '</div></dialog>'
81
+ )
82
+ }
83
+
84
+ /**
85
+ * The four requests the dialog makes. Tests pass their own.
86
+ * @typedef {{
87
+ * fetchSettings: typeof fetchSettings,
88
+ * saveSettings: typeof saveSettings,
89
+ * fetchAgents: typeof fetchAgents,
90
+ * probeAgent: typeof probeAgent,
91
+ * }} SettingsApi
92
+ */
93
+
94
+ /**
95
+ * Opens the dialog, filling it from the server. The caller passes the command that opened it so
96
+ * its pending state shows while the two requests are in flight.
97
+ * @param {HTMLElement} root
98
+ * @param {HTMLElement} opener
99
+ * @param {{ api?: Partial<SettingsApi>, onSaved?: (data: SettingsResponse) => void }} [opts]
100
+ */
101
+ export async function openSettingsDialog(root, opener, opts = {}) {
102
+ const api = { fetchSettings, saveSettings, fetchAgents, probeAgent, ...opts.api }
103
+ const loaded = await runCommand(
104
+ opener,
105
+ async () => {
106
+ const [data, agents] = await Promise.all([api.fetchSettings(), api.fetchAgents()])
107
+ return { data, agents }
108
+ },
109
+ { pendingLabel: 'loading…' }
110
+ )
111
+ if (loaded === undefined) {
112
+ return null
113
+ }
114
+ qs(`#${SETTINGS_DIALOG_ID}`, root)?.remove()
115
+ root.insertAdjacentHTML('beforeend', settingsDialogHtml(loaded.data, loaded.agents))
116
+ const dialog = qs(`#${SETTINGS_DIALOG_ID}`, root)
117
+ if (!(dialog instanceof HTMLDialogElement)) {
118
+ return null
119
+ }
120
+ wireSettingsDialog(dialog, api, opts.onSaved)
121
+ if (typeof dialog.showModal === 'function') {
122
+ dialog.showModal()
123
+ } else {
124
+ dialog.setAttribute('open', '')
125
+ }
126
+ return dialog
127
+ }
128
+
129
+ /** The values the dialog holds right now, as the PUT body. */
130
+ export function readSettingsForm(/** @type {ParentNode} */ dialog) {
131
+ const agent = qs('#set-agent', dialog)
132
+ const model = qs('#set-model', dialog)
133
+ const timeout = qs('#set-timeout', dialog)
134
+ const turns = qs('#set-turns', dialog)
135
+ /** @type {import('./contract-types.js').SettingsInput} */
136
+ const input = {}
137
+ if (agent instanceof HTMLSelectElement && agent.value !== '') {
138
+ input.agent = /** @type {import('./contract-types.js').ChatAgent} */ (agent.value)
139
+ }
140
+ if (model instanceof HTMLInputElement) {
141
+ input.model = model.value.trim() === '' ? null : model.value.trim()
142
+ }
143
+ if (timeout instanceof HTMLInputElement && timeout.value !== '') {
144
+ input.chatTimeoutSec = Number(timeout.value)
145
+ }
146
+ if (turns instanceof HTMLInputElement) {
147
+ input.maxTurns = turns.value.trim() === '' ? null : Number(turns.value)
148
+ }
149
+ return input
150
+ }
151
+
152
+ /**
153
+ * @param {HTMLDialogElement} dialog
154
+ * @param {SettingsApi} api
155
+ * @param {((data: SettingsResponse) => void) | undefined} onSaved
156
+ */
157
+ export function wireSettingsDialog(dialog, api, onSaved) {
158
+ dialog.addEventListener('change', event => {
159
+ if (event.target instanceof HTMLSelectElement && event.target.id === 'set-agent') {
160
+ const list = dialog.querySelector('#model-list')
161
+ if (list !== null) {
162
+ list.innerHTML = modelOptionsHtml(event.target.value)
163
+ }
164
+ }
165
+ })
166
+ dialog.addEventListener('click', event => {
167
+ const el = event.target instanceof Element ? event.target.closest('[data-act]') : null
168
+ if (!(el instanceof HTMLElement)) {
169
+ return
170
+ }
171
+ const act = el.getAttribute('data-act')
172
+ if (act === 'settings-close') {
173
+ dialog.close()
174
+ return
175
+ }
176
+ if (act === 'settings-save') {
177
+ void runCommand(
178
+ el,
179
+ async () => {
180
+ const saved = await api.saveSettings(readSettingsForm(dialog))
181
+ onSaved?.(saved)
182
+ dialog.close()
183
+ },
184
+ { pendingLabel: 'saving…' }
185
+ )
186
+ return
187
+ }
188
+ if (act === 'settings-probe') {
189
+ const agent = qs('#set-agent', dialog)
190
+ const id = agent instanceof HTMLSelectElement ? agent.value : ''
191
+ void runCommand(
192
+ el,
193
+ async () => {
194
+ const result = await api.probeAgent(id)
195
+ const out = dialog.querySelector('.probe-result')
196
+ if (out !== null) {
197
+ out.textContent = result.ok
198
+ ? `${result.id} answered in ${Math.round(result.ms / 100) / 10}s${result.cached ? ' (cached)' : ''}`
199
+ : `${result.id} failed: ${result.message ?? result.code ?? 'unknown'}`
200
+ }
201
+ },
202
+ { pendingLabel: 'testing…' }
203
+ )
204
+ }
205
+ })
206
+ }
@@ -0,0 +1,171 @@
1
+ // @ts-check
2
+ // Signing off: approve or request changes. The dialog shows the body the server built, lets the
3
+ // reader edit it, and names the commit the review lands on.
4
+ /** @typedef {import('./contract-types.js').PrState} PrState */
5
+ /** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
6
+ /** @typedef {import('./contract-types.js').ReviewBodyResponse} ReviewBodyResponse */
7
+ import { previewControlsHtml, setDisabledReason } from './composer.js'
8
+ import { esc } from './dom.js'
9
+ import { layerProgress } from './progress.js'
10
+
11
+ export const SIGNOFF_DIALOG_ID = 'signoff-dialog'
12
+
13
+ /** Why the post command waits: the body the server writes is not there yet. */
14
+ export const LOADING_REASON = 'the review body is still loading'
15
+
16
+ /**
17
+ * Why approve is not allowed yet, or null when it is. Other changes never counts.
18
+ * @param {ReviewArtifact} artifact
19
+ * @param {PrState} state
20
+ * @returns {string | null}
21
+ */
22
+ export function approveBlockedReason(artifact, state) {
23
+ const [first, ...rest] = artifact.layers.filter(l => l.kind !== 'other' && layerProgress(l, state) !== 'done')
24
+ if (first === undefined) {
25
+ return null
26
+ }
27
+ return rest.length === 0
28
+ ? `1 layer is not reviewed yet: ${first.title}`
29
+ : `${rest.length + 1} layers are not reviewed yet`
30
+ }
31
+
32
+ /**
33
+ * @param {'APPROVE' | 'REQUEST_CHANGES'} event
34
+ */
35
+ export function signoffTitle(event) {
36
+ return event === 'APPROVE' ? 'Approve on GitHub' : 'Request changes on GitHub'
37
+ }
38
+
39
+ /**
40
+ * @param {{ event: 'APPROVE' | 'REQUEST_CHANGES' }} opts
41
+ * @returns {string}
42
+ */
43
+ export function signoffDialogHtml(opts) {
44
+ return (
45
+ `<dialog id="${SIGNOFF_DIALOG_ID}" class="signoff-dialog" aria-labelledby="signoff-h">` +
46
+ `<h2 id="signoff-h">${esc(signoffTitle(opts.event))}</h2>` +
47
+ '<p class="hint">This posts a review on GitHub as you. Edit the body first if you want to.</p>' +
48
+ '<p class="signoff-target muted mono"></p>' +
49
+ '<label class="sr" for="signoff-body">Review body</label>' +
50
+ previewControlsHtml() +
51
+ '<textarea id="signoff-body" rows="12" aria-busy="true"></textarea>' +
52
+ '<p class="signoff-result" role="status"></p>' +
53
+ '<div class="dialog-actions">' +
54
+ '<button class="cmd fill" type="button" data-act="signoff-post" data-needs-post disabled ' +
55
+ `data-disabled-reason="${esc(LOADING_REASON)}" title="${esc(LOADING_REASON)}">post review</button>` +
56
+ '<button class="cmd" type="button" data-act="signoff-close">cancel</button></div></dialog>'
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Creates the dialog on first use, points it at this event, and opens it. The body arrives
62
+ * afterwards through `fillSignoffDialog`.
63
+ * @param {HTMLElement} root
64
+ * @param {{ event: 'APPROVE' | 'REQUEST_CHANGES' }} opts
65
+ * @returns {HTMLDialogElement}
66
+ */
67
+ export function openSignoffDialog(root, opts) {
68
+ let dialog = root.querySelector(`#${SIGNOFF_DIALOG_ID}`)
69
+ if (!(dialog instanceof HTMLDialogElement)) {
70
+ const t = document.createElement('template')
71
+ t.innerHTML = signoffDialogHtml(opts)
72
+ root.appendChild(t.content)
73
+ dialog = root.querySelector(`#${SIGNOFF_DIALOG_ID}`)
74
+ }
75
+ if (!(dialog instanceof HTMLDialogElement)) {
76
+ throw new Error('sign-off dialog did not render')
77
+ }
78
+ dialog.querySelector('[data-act="markdown-write"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
79
+ dialog.setAttribute('data-event', opts.event)
80
+ const heading = dialog.querySelector('#signoff-h')
81
+ if (heading !== null) {
82
+ heading.textContent = signoffTitle(opts.event)
83
+ }
84
+ const result = dialog.querySelector('.signoff-result')
85
+ if (result !== null) {
86
+ result.textContent = ''
87
+ }
88
+ const area = dialog.querySelector('textarea')
89
+ if (area instanceof HTMLTextAreaElement) {
90
+ area.value = ''
91
+ area.placeholder = 'loading the review body…'
92
+ area.setAttribute('aria-busy', 'true')
93
+ }
94
+ setDisabledReason(dialog.querySelector('[data-act="signoff-post"]'), LOADING_REASON)
95
+ if (typeof dialog.showModal === 'function' && !dialog.open) {
96
+ dialog.showModal()
97
+ } else {
98
+ dialog.setAttribute('open', '')
99
+ }
100
+ return dialog
101
+ }
102
+
103
+ /**
104
+ * Puts the generated body and the target commit in the dialog.
105
+ * @param {HTMLDialogElement} dialog
106
+ * @param {ReviewBodyResponse} preview
107
+ */
108
+ export function fillSignoffDialog(dialog, preview) {
109
+ const area = dialog.querySelector('textarea')
110
+ if (area instanceof HTMLTextAreaElement) {
111
+ area.value = preview.body
112
+ area.removeAttribute('aria-busy')
113
+ area.focus()
114
+ }
115
+ setDisabledReason(dialog.querySelector('[data-act="signoff-post"]'), null)
116
+ const target = dialog.querySelector('.signoff-target')
117
+ if (target !== null) {
118
+ target.textContent = `on commit ${preview.headSha.slice(0, 7)}`
119
+ }
120
+ return dialog
121
+ }
122
+
123
+ /**
124
+ * Shows the link to the review GitHub created.
125
+ * @param {HTMLDialogElement} dialog
126
+ * @param {import('./contract-types.js').ReviewSummary} review
127
+ */
128
+ export function showSignoffResult(dialog, review) {
129
+ const result = dialog.querySelector('.signoff-result')
130
+ if (result !== null) {
131
+ result.textContent = 'Posted · '
132
+ result.append(externalLink(review.url, 'see it on GitHub'))
133
+ }
134
+ return result
135
+ }
136
+
137
+ /**
138
+ * An anchor built as a node, with an href only when the target is an http(s) address. A link
139
+ * that comes from an API is still untrusted input.
140
+ * @param {string} url
141
+ * @param {string} text
142
+ */
143
+ export function externalLink(url, text) {
144
+ const a = document.createElement('a')
145
+ a.textContent = text
146
+ if (/^https?:\/\//i.test(url)) {
147
+ a.href = url
148
+ a.target = '_blank'
149
+ a.rel = 'noopener noreferrer'
150
+ }
151
+ return a
152
+ }
153
+
154
+ /**
155
+ * Says inside the dialog why the body could not be read, and keeps the post command off.
156
+ * @param {HTMLDialogElement} dialog
157
+ * @param {string} message
158
+ */
159
+ export function showSignoffError(dialog, message) {
160
+ const result = dialog.querySelector('.signoff-result')
161
+ if (result !== null) {
162
+ result.textContent = message
163
+ }
164
+ return result
165
+ }
166
+
167
+ /** @param {HTMLDialogElement} dialog */
168
+ export function signoffBody(dialog) {
169
+ const area = dialog.querySelector('textarea')
170
+ return area instanceof HTMLTextAreaElement ? area.value.trim() : ''
171
+ }
@@ -0,0 +1,56 @@
1
+ // @ts-check
2
+ // The skin command cycles terminal → github. `data-skin` on <html> says which look the page wears:
3
+ // the component styles are written in the terminal skin, and `styles/skin-github.css` repaints them
4
+ // for github. It is independent of the light/dark theme, so each of the four pairings works.
5
+ //
6
+ // The choice is saved in `.pr-review/settings.yml`, so it holds for every browser that opens this
7
+ // server and can be edited by hand. The server renders `data-skin` into the page, which is why this
8
+ // module reads the attribute instead of storage and never has to guess before the styles apply.
9
+
10
+ export const SKINS = /** @type {const} */ (['terminal', 'github'])
11
+ /** @typedef {(typeof SKINS)[number]} Skin */
12
+
13
+ /** The look a page wears when the settings file says nothing. */
14
+ export const DEFAULT_SKIN = /** @type {Skin} */ ('terminal')
15
+
16
+ /**
17
+ * @param {unknown} value
18
+ * @returns {value is Skin}
19
+ */
20
+ export function isSkin(value) {
21
+ return typeof value === 'string' && /** @type {readonly string[]} */ (SKINS).includes(value)
22
+ }
23
+
24
+ /**
25
+ * The skin the page is wearing, from the attribute the server rendered.
26
+ * @param {HTMLElement} root usually document.documentElement
27
+ * @returns {Skin}
28
+ */
29
+ export function readSkin(root) {
30
+ const attribute = root.getAttribute('data-skin')
31
+ return isSkin(attribute) ? attribute : DEFAULT_SKIN
32
+ }
33
+
34
+ /**
35
+ * @param {Skin} skin
36
+ * @returns {Skin}
37
+ */
38
+ export function nextSkin(skin) {
39
+ const next = SKINS[SKINS.indexOf(skin) + 1]
40
+ return next ?? DEFAULT_SKIN
41
+ }
42
+
43
+ /**
44
+ * Repaints the page. Saving is the caller's job, so the look changes under the click whether or
45
+ * not the server answers.
46
+ * @param {Skin} skin
47
+ * @param {HTMLElement} root usually document.documentElement
48
+ */
49
+ export function applySkin(skin, root) {
50
+ root.setAttribute('data-skin', skin)
51
+ }
52
+
53
+ /** @param {Skin} skin */
54
+ export function skinLabel(skin) {
55
+ return `skin: ${skin}`
56
+ }
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @template T
5
+ * @typedef {{ get: () => T, set: (next: T) => void, update: (fn: (cur: T) => T) => void, subscribe: (fn: (state: T) => void) => () => void }} Store
6
+ */
7
+
8
+ /**
9
+ * A tiny observable value. Subscribers run after every set; unsubscribe with the returned function.
10
+ * @template T
11
+ * @param {T} initial
12
+ * @returns {Store<T>}
13
+ */
14
+ export function createStore(initial) {
15
+ let state = initial
16
+ /** @type {Set<(state: T) => void>} */
17
+ const subs = new Set()
18
+ const set = /** @param {T} next */ next => {
19
+ state = next
20
+ for (const fn of subs) {
21
+ fn(state)
22
+ }
23
+ }
24
+ return {
25
+ get: () => state,
26
+ set,
27
+ update: fn => set(fn(state)),
28
+ subscribe: fn => {
29
+ subs.add(fn)
30
+ return () => {
31
+ subs.delete(fn)
32
+ }
33
+ },
34
+ }
35
+ }
@@ -0,0 +1,56 @@
1
+ // @ts-check
2
+ // The theme command cycles auto → light → dark. `data-theme` on <html> says which one the page
3
+ // wears; `auto` follows the operating system, which is what the `color-scheme: light dark` on
4
+ // :root already does, so no rule matches that value and none has to.
5
+ //
6
+ // The choice is saved in `.pr-review/settings.yml` next to the skin, so it holds for every browser
7
+ // that opens this server. The server renders `data-theme` into the page, which is why this module
8
+ // reads the attribute instead of storage and no theme ever flashes before the styles apply.
9
+
10
+ export const THEMES = /** @type {const} */ (['auto', 'light', 'dark'])
11
+ /** @typedef {(typeof THEMES)[number]} Theme */
12
+
13
+ /** The theme a page wears when the settings file says nothing. */
14
+ export const DEFAULT_THEME = /** @type {Theme} */ ('auto')
15
+
16
+ /**
17
+ * @param {unknown} value
18
+ * @returns {value is Theme}
19
+ */
20
+ export function isTheme(value) {
21
+ return typeof value === 'string' && /** @type {readonly string[]} */ (THEMES).includes(value)
22
+ }
23
+
24
+ /**
25
+ * The theme the page is wearing, from the attribute the server rendered.
26
+ * @param {HTMLElement} root usually document.documentElement
27
+ * @returns {Theme}
28
+ */
29
+ export function readTheme(root) {
30
+ const attribute = root.getAttribute('data-theme')
31
+ return isTheme(attribute) ? attribute : DEFAULT_THEME
32
+ }
33
+
34
+ /**
35
+ * @param {Theme} theme
36
+ * @returns {Theme}
37
+ */
38
+ export function nextTheme(theme) {
39
+ const next = THEMES[THEMES.indexOf(theme) + 1]
40
+ return next ?? DEFAULT_THEME
41
+ }
42
+
43
+ /**
44
+ * Repaints the page. Saving is the caller's job, so the theme changes under the click whether or
45
+ * not the server answers.
46
+ * @param {Theme} theme
47
+ * @param {HTMLElement} root usually document.documentElement
48
+ */
49
+ export function applyTheme(theme, root) {
50
+ root.setAttribute('data-theme', theme)
51
+ }
52
+
53
+ /** @param {Theme} theme */
54
+ export function themeLabel(theme) {
55
+ return `theme: ${theme}`
56
+ }
@@ -0,0 +1,78 @@
1
+ // @ts-check
2
+ /** @typedef {import('./contract-types.js').ReviewComment} ReviewComment */
3
+
4
+ /**
5
+ * @typedef {{ root: ReviewComment, replies: ReviewComment[], resolved: boolean, outdated: boolean, path: string, side: 'new' | 'old', line: number | null }} Thread
6
+ */
7
+
8
+ /**
9
+ * @param {string} path
10
+ * @param {'new' | 'old'} side
11
+ * @param {number} line
12
+ */
13
+ export function anchorKey(path, side, line) {
14
+ return `${path}|${side}|${line}`
15
+ }
16
+
17
+ /**
18
+ * Groups review comments into threads by `inReplyToId`. Threads keyed by their anchor line;
19
+ * outdated threads (no current line) go to the `outdated` bucket per path.
20
+ * @param {ReadonlyArray<ReviewComment>} comments
21
+ * @returns {{ byAnchor: Map<string, Thread[]>, outdated: Map<string, Thread[]>, count: number }}
22
+ */
23
+ export function buildThreads(comments) {
24
+ /** @type {Map<number, Thread>} */
25
+ const roots = new Map()
26
+ const sorted = [...comments].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
27
+ for (const c of sorted) {
28
+ if (c.inReplyToId === undefined) {
29
+ roots.set(c.id, {
30
+ root: c,
31
+ replies: [],
32
+ resolved: c.resolved,
33
+ outdated: c.outdated,
34
+ path: c.path,
35
+ side: c.side,
36
+ line: c.line,
37
+ })
38
+ }
39
+ }
40
+ for (const c of sorted) {
41
+ if (c.inReplyToId === undefined) {
42
+ continue
43
+ }
44
+ const t = roots.get(c.inReplyToId)
45
+ if (t) {
46
+ t.replies.push(c)
47
+ t.resolved = t.resolved || c.resolved
48
+ } else {
49
+ // A reply whose root was deleted: show it as its own thread.
50
+ roots.set(c.id, {
51
+ root: c,
52
+ replies: [],
53
+ resolved: c.resolved,
54
+ outdated: c.outdated,
55
+ path: c.path,
56
+ side: c.side,
57
+ line: c.line,
58
+ })
59
+ }
60
+ }
61
+ /** @type {Map<string, Thread[]>} */
62
+ const byAnchor = new Map()
63
+ /** @type {Map<string, Thread[]>} */
64
+ const outdated = new Map()
65
+ for (const t of roots.values()) {
66
+ if (t.outdated || t.line === null) {
67
+ const list = outdated.get(t.path) ?? []
68
+ list.push(t)
69
+ outdated.set(t.path, list)
70
+ continue
71
+ }
72
+ const k = anchorKey(t.path, t.side, t.line)
73
+ const list = byAnchor.get(k) ?? []
74
+ list.push(t)
75
+ byAnchor.set(k, list)
76
+ }
77
+ return { byAnchor, outdated, count: roots.size }
78
+ }
@@ -0,0 +1,15 @@
1
+ // `hljs` is the import-map name of @highlightjs/cdn-assets/es/highlight.min.js, which ships no types.
2
+ declare module 'hljs' {
3
+ interface HighlightResult {
4
+ value: string
5
+ }
6
+ interface HighlightOptions {
7
+ language: string
8
+ ignoreIllegals?: boolean
9
+ }
10
+ const hljs: {
11
+ highlight(code: string, options: HighlightOptions): HighlightResult
12
+ getLanguage(name: string): unknown
13
+ }
14
+ export default hljs
15
+ }