@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,134 @@
1
+ // @ts-check
2
+ /** @typedef {import('./contract-types.js').PrBundle} PrBundle */
3
+ /** @typedef {import('./contract-types.js').RiskTag} RiskTag */
4
+ import { setDisabledReason } from './composer.js'
5
+ import { esc, timeAgo } from './dom.js'
6
+ import { refreshRail } from './layers.js'
7
+ import { progressSummary } from './progress.js'
8
+ import { approveBlockedReason } from './signoff.js'
9
+ import { skinLabel } from './skin.js'
10
+ import { themeLabel } from './theme.js'
11
+
12
+ /**
13
+ * @param {ReadonlyArray<RiskTag>} risk
14
+ * @returns {string} the `touches:` line, or '' when nothing is tagged
15
+ */
16
+ export function riskLineHtml(risk) {
17
+ if (risk.length === 0) {
18
+ return ''
19
+ }
20
+ const pills = risk
21
+ .map(r => {
22
+ const title = r.source === 'model' && r.reason ? ` title="${esc(r.reason)}"` : ''
23
+ return `<span class="pill risk ${r.source}"${title}>${esc(r.label)}</span>`
24
+ })
25
+ .join('<span aria-hidden="true">·</span>')
26
+ return `<p class="touches"><span>touches:</span>${pills}</p>`
27
+ }
28
+
29
+ /**
30
+ * One line saying the canvas was generated under the large-change-set caps, so a reader who finds
31
+ * a file with no annotation knows why.
32
+ * @param {PrBundle} bundle
33
+ */
34
+ export function largePrNoticeHtml(bundle) {
35
+ if (!bundle.largePr) {
36
+ return ''
37
+ }
38
+ return (
39
+ '<p class="notice large-pr" role="status">Large change set: the canvas keeps at most 6 annotations per file ' +
40
+ 'and 12 attention points, and diffs were not inlined for the generator.</p>'
41
+ )
42
+ }
43
+
44
+ /** @param {PrBundle['pr']} pr */
45
+ export function statePill(pr) {
46
+ const state = pr.state === 'open' && pr.draft ? 'draft' : pr.state
47
+ return `<span class="pill ${esc(state)}">${esc(state)}</span>`
48
+ }
49
+
50
+ /**
51
+ * @param {PrBundle} bundle
52
+ * @param {{ host: string, theme: import('./theme.js').Theme, skin: import('./skin.js').Skin, now: Date }} opts
53
+ * @returns {string}
54
+ */
55
+ export function renderHeader(bundle, opts) {
56
+ const { pr, artifact } = bundle
57
+ const ready = bundle.status === 'ready' && artifact !== undefined
58
+ // A stale canvas can be exported and regenerated too, so both commands stay live for it.
59
+ const hasCanvas = artifact !== undefined && (bundle.status === 'ready' || bundle.status === 'stale')
60
+ const agent = ready
61
+ ? `<span class="pill agent">${esc(artifact.generator.agent)}${artifact.generator.model ? ` · ${esc(artifact.generator.model)}` : ''} · ${esc(artifact.generator.harness)}</span><span>generated ${esc(timeAgo(artifact.generatedAt, opts.now))}</span>`
62
+ : ''
63
+ const number = pr.number === null ? '' : `<span class="mono num">#${pr.number}</span>`
64
+ const progress = ready ? progressHtml(artifact, bundle.state) : ''
65
+ const risk = ready ? riskLineHtml(artifact.risk) : ''
66
+ return (
67
+ '<header class="hdr">' +
68
+ `<div class="hdr-bar"><div class="brand"><span class="box">PR review canvas</span><span class="mono muted">${esc(opts.host)}</span></div>` +
69
+ '<div class="hdr-actions" role="group" aria-label="Canvas actions">' +
70
+ `<button class="cmd" type="button" id="regenerate" title="Generate a new canvas for this PR" aria-haspopup="dialog"${hasCanvas ? '' : ' disabled'}>regenerate</button>` +
71
+ `<button class="cmd" type="button" id="export-zip" title="Download this canvas as a zip to share on GitHub"${hasCanvas ? '' : ' disabled'}>export zip</button>` +
72
+ '<button class="cmd" type="button" id="refresh" title="Fetch the latest PR, comments, and shared canvas from GitHub">refresh</button>' +
73
+ `<button class="cmd" type="button" id="settings" data-act="settings" aria-haspopup="dialog"${bundle.chat.enabled || bundle.chat.acpx ? ' title="Configure the AI chat agent, model, and limits"' : ' disabled title="acpx is not installed"'}>settings</button>` +
74
+ '<button class="cmd" type="button" data-act="help" title="Show keyboard shortcuts and review help" aria-haspopup="dialog">help</button>' +
75
+ `<button class="cmd" type="button" id="skin-toggle" title="Switch between Terminal and GitHub styling">${esc(skinLabel(opts.skin))}</button>` +
76
+ `<button class="cmd" type="button" id="theme-toggle" title="Switch between Light, Dark, and Auto themes">${esc(themeLabel(opts.theme))}</button>` +
77
+ '</div></div>' +
78
+ '<div class="stripe" aria-hidden="true"></div>' +
79
+ '<div class="hdr-title">' +
80
+ `<div class="title"><h1>${number}${esc(pr.title)}</h1><a class="cmd" href="${esc(pr.url)}" target="_blank" rel="noopener noreferrer">github</a></div>` +
81
+ `<p class="meta"><span>by <a href="https://github.com/${esc(pr.author)}" target="_blank" rel="noopener noreferrer">${esc(pr.author)}</a></span>` +
82
+ `<span class="mono">${esc(pr.headRef)} &rarr; ${esc(pr.baseRef)}</span>${statePill(pr)}` +
83
+ `<span class="diffstat"><span class="ok">+${pr.additions}</span> <span class="bad">&minus;${pr.deletions}</span></span>${agent}</p>` +
84
+ `${largePrNoticeHtml(bundle)}${risk}${progress}</div></header>`
85
+ )
86
+ }
87
+
88
+ /**
89
+ * The thin line, its text, and the two sign-off commands. Approve stays disabled with the
90
+ * reason until every layer that is not Other has been marked reviewed.
91
+ * @param {import('./contract-types.js').ReviewArtifact} artifact
92
+ * @param {import('./contract-types.js').PrState} state
93
+ */
94
+ export function progressHtml(artifact, state) {
95
+ const p = progressSummary(artifact, state)
96
+ const blocked = approveBlockedReason(artifact, state)
97
+ const approve =
98
+ `<button class="cmd" type="button" id="approve" data-tooltip="Write and preview an approving review on GitHub" data-act="signoff" data-event="APPROVE" data-needs-post` +
99
+ `${blocked === null ? ' title="Write and preview an approving review on GitHub"' : ` disabled data-disabled-reason="${esc(blocked)}" title="${esc(blocked)}"`}>approve on github</button>`
100
+ return (
101
+ `<div class="progress"><div class="pline" role="progressbar" aria-valuenow="${p.done}" aria-valuemin="0" aria-valuemax="${p.total}" aria-label="Layers reviewed"><span style="width:${p.percent}%"></span></div>` +
102
+ `<span class="ptext">${p.done} of ${p.total} layers reviewed</span></div>` +
103
+ `<div class="signoff">${approve}` +
104
+ '<button class="cmd" type="button" id="request-changes" data-tooltip="Write and preview a review requesting changes on GitHub" title="Write and preview a review requesting changes on GitHub" data-act="signoff" data-event="REQUEST_CHANGES" data-needs-post>request changes</button>' +
105
+ '<span class="capability-note" role="status"></span></div>'
106
+ )
107
+ }
108
+
109
+ /**
110
+ * Draws the progress line, its text, and the approve command again from the current state, and
111
+ * with them the rail. Called after every reviewed change.
112
+ * @param {ParentNode} root
113
+ * @param {import('./contract-types.js').ReviewArtifact} artifact
114
+ * @param {import('./contract-types.js').PrState} state
115
+ */
116
+ export function refreshProgress(root, artifact, state) {
117
+ const p = progressSummary(artifact, state)
118
+ const line = root.querySelector('.pline')
119
+ const fill = line?.querySelector('span')
120
+ if (line !== null) {
121
+ line.setAttribute('aria-valuenow', String(p.done))
122
+ line.setAttribute('aria-valuemax', String(p.total))
123
+ }
124
+ if (fill instanceof HTMLElement) {
125
+ fill.style.width = `${p.percent}%`
126
+ }
127
+ const text = root.querySelector('.ptext')
128
+ if (text !== null) {
129
+ text.textContent = `${p.done} of ${p.total} layers reviewed`
130
+ }
131
+ setDisabledReason(root.querySelector('#approve'), approveBlockedReason(artifact, state))
132
+ refreshRail(root, artifact, state)
133
+ return p
134
+ }
@@ -0,0 +1,62 @@
1
+ // @ts-check
2
+ // Hunk header parsing and the hunk-for-line lookup. One implementation for the browser and the
3
+ // server (src/git/patch-lines.ts re-exports it), so both sides agree on which hunk owns a line.
4
+ /** @typedef {import('./contract-types.js').Side} Side */
5
+ /** @typedef {{ oldStart: number, oldLines: number, newStart: number, newLines: number }} HunkRange */
6
+
7
+ export const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
8
+
9
+ /**
10
+ * Line numbers and counts of a unified-diff hunk header; a missing count means 1.
11
+ * @param {string} line
12
+ * @returns {HunkRange | null} null when the line is not a hunk header
13
+ */
14
+ export function parseHunkHeader(line) {
15
+ const m = HUNK_HEADER_RE.exec(line)
16
+ if (!m) {
17
+ return null
18
+ }
19
+ return {
20
+ oldStart: Number(m[1]),
21
+ oldLines: m[2] === undefined ? 1 : Number(m[2]),
22
+ newStart: Number(m[3]),
23
+ newLines: m[4] === undefined ? 1 : Number(m[4]),
24
+ }
25
+ }
26
+
27
+ /**
28
+ * The hunk that covers `line` on `side`, or null when the line is outside the diff.
29
+ * @template {HunkRange} H
30
+ * @param {ReadonlyArray<H>} hunks
31
+ * @param {Side} side
32
+ * @param {number} line
33
+ * @returns {H | null}
34
+ */
35
+ export function hunkForLine(hunks, side, line) {
36
+ for (const h of hunks) {
37
+ const start = side === 'new' ? h.newStart : h.oldStart
38
+ const count = side === 'new' ? h.newLines : h.oldLines
39
+ // A zero-length range (pure deletion or pure addition) still anchors on its start line.
40
+ const end = start + Math.max(count, 1) - 1
41
+ if (line >= start && line <= end) {
42
+ return h
43
+ }
44
+ }
45
+ return null
46
+ }
47
+
48
+ /**
49
+ * Valid anchor ranges, using the same zero-length anchor rule as hunkForLine.
50
+ * @param {ReadonlyArray<HunkRange>} hunks
51
+ * @param {Side} side
52
+ * @returns {string}
53
+ */
54
+ export function hunkLineRanges(hunks, side) {
55
+ const ranges = hunks.map(h => {
56
+ const start = side === 'new' ? h.newStart : h.oldStart
57
+ const count = side === 'new' ? h.newLines : h.oldLines
58
+ const end = start + Math.max(count, 1) - 1
59
+ return start === end ? `${start}` : `${start}-${end}`
60
+ })
61
+ return `${side}-side lines ${ranges.length ? ranges.join(', ') : 'none'}`
62
+ }
@@ -0,0 +1,95 @@
1
+ // @ts-check
2
+ // The drop zone of the empty and stale screens: pick or drop a zip, POST it, and show what
3
+ // happened next to the zone instead of in the console.
4
+ /** @typedef {import('./contract-types.js').ImportResult} ImportResult */
5
+ /** @typedef {import('./empty-state.js').DropState} DropState */
6
+ /** @typedef {typeof import('./api.js').importCanvas} ImportCanvas */
7
+ import { clearCommandError, showCommandError } from './commands.js'
8
+ import { qs } from './dom.js'
9
+ import { DROP_ZONE_ID, dropReducer, INITIAL_DROP_STATE, validateCanvasFilename } from './empty-state.js'
10
+
11
+ /**
12
+ * @param {DropState} state
13
+ * @returns {string}
14
+ */
15
+ export function dropStatusText(state) {
16
+ if (state.phase === 'busy') {
17
+ return `importing… ${Math.round(state.progress * 100)}%`
18
+ }
19
+ return state.phase === 'over' ? 'drop to import' : ''
20
+ }
21
+
22
+ /**
23
+ * @typedef {{
24
+ * prNumber: number,
25
+ * importImpl: ImportCanvas,
26
+ * onImported: (result: ImportResult) => void,
27
+ * }} DropZoneOptions
28
+ */
29
+
30
+ /**
31
+ * @typedef {{ element: HTMLElement, send: (file: File) => Promise<void>, state: () => DropState }} DropZone
32
+ */
33
+
34
+ /**
35
+ * Wires the zone under `root`. Returns null when the screen has no drop zone.
36
+ * @param {HTMLElement} root
37
+ * @param {DropZoneOptions} opts
38
+ * @returns {DropZone | null}
39
+ */
40
+ export function wireDropZone(root, opts) {
41
+ const label = qs('.drop', root)
42
+ const input = qs(`#${DROP_ZONE_ID}`, root)
43
+ const status = qs('.drop-status', root)
44
+ if (!(label instanceof HTMLElement && input instanceof HTMLInputElement && status instanceof HTMLElement)) {
45
+ return null
46
+ }
47
+ let state = INITIAL_DROP_STATE
48
+ const apply = (/** @type {import('./empty-state.js').DropEvent} */ event) => {
49
+ state = dropReducer(state, event)
50
+ label.classList.toggle('over', state.phase === 'over')
51
+ status.textContent = dropStatusText(state)
52
+ if (state.error === null) {
53
+ clearCommandError(status)
54
+ } else {
55
+ showCommandError(status, state.error)
56
+ }
57
+ }
58
+ const send = async (/** @type {File} */ file) => {
59
+ const problem = validateCanvasFilename(file.name)
60
+ if (problem !== null) {
61
+ apply({ type: 'fail', message: problem })
62
+ return
63
+ }
64
+ apply({ type: 'start' })
65
+ try {
66
+ const result = await opts.importImpl(opts.prNumber, file, {
67
+ onProgress: fraction => apply({ type: 'progress', fraction }),
68
+ })
69
+ apply({ type: 'done' })
70
+ opts.onImported(result)
71
+ } catch (err) {
72
+ apply({ type: 'fail', message: err instanceof Error ? err.message : String(err) })
73
+ }
74
+ }
75
+ label.addEventListener('dragover', event => {
76
+ event.preventDefault()
77
+ apply({ type: 'enter' })
78
+ })
79
+ label.addEventListener('dragleave', () => apply({ type: 'leave' }))
80
+ label.addEventListener('drop', event => {
81
+ event.preventDefault()
82
+ apply({ type: 'leave' })
83
+ const file = event instanceof DragEvent ? event.dataTransfer?.files[0] : undefined
84
+ if (file) {
85
+ void send(file)
86
+ }
87
+ })
88
+ input.addEventListener('change', () => {
89
+ const file = input.files?.[0]
90
+ if (file) {
91
+ void send(file)
92
+ }
93
+ })
94
+ return { element: label, send, state: () => state }
95
+ }