@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.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- 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
|
+
}
|