@vintasoftware/pr-review-canvas 0.3.0 → 0.5.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 (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
@@ -0,0 +1,176 @@
1
+ // @ts-check
2
+ // How aggressively the canvas hides code. One implementation for the server (re-exported by
3
+ // src/contract/review-artifact.ts) and the browser, so the validator and the page agree on what
4
+ // a level hides.
5
+
6
+ /**
7
+ * The levels, from the least hiding to the most. They nest: a fold hides at its own level and at
8
+ * every level above it, so `light` folds are hidden in all three modes. The page opens at the
9
+ * level saved in the settings file, `light` until the reader picks another, and a change from
10
+ * the control holds for the session only.
11
+ */
12
+ export const FOLD_LEVELS = /** @type {const} */ (['light', 'moderate', 'aggressive'])
13
+
14
+ /** @typedef {(typeof FOLD_LEVELS)[number]} FoldLevel */
15
+
16
+ /** The level a review opens at until the reader saves another, and the level of an older canvas's folds. */
17
+ export const DEFAULT_FOLD_LEVEL = 'light'
18
+
19
+ /**
20
+ * @param {unknown} value
21
+ * @returns {value is FoldLevel}
22
+ */
23
+ export function isFoldLevel(value) {
24
+ return FOLD_LEVELS.some(level => level === value)
25
+ }
26
+
27
+ /**
28
+ * Where a level sits in the order, so two levels can be compared.
29
+ * @param {FoldLevel} level
30
+ * @returns {number}
31
+ */
32
+ export function foldLevelRank(level) {
33
+ return FOLD_LEVELS.indexOf(level)
34
+ }
35
+
36
+ /**
37
+ * Whether code marked with `level` is hidden while the reader is at `current`.
38
+ * @param {FoldLevel} level
39
+ * @param {FoldLevel} current
40
+ * @returns {boolean}
41
+ */
42
+ export function hidesAt(level, current) {
43
+ return foldLevelRank(level) <= foldLevelRank(current)
44
+ }
45
+
46
+ /**
47
+ * The level after `level`, back to the first after the last, so one key can step through them.
48
+ * @param {FoldLevel} level
49
+ * @returns {FoldLevel}
50
+ */
51
+ export function nextFoldLevel(level) {
52
+ return FOLD_LEVELS[(foldLevelRank(level) + 1) % FOLD_LEVELS.length] ?? DEFAULT_FOLD_LEVEL
53
+ }
54
+
55
+ /**
56
+ * @typedef {{ side: 'new' | 'old', startLine: number, endLine: number }} Span
57
+ * @typedef {Span & { level: FoldLevel }} Range
58
+ * @typedef {{
59
+ * collapsed?: FoldLevel | undefined,
60
+ * annotations: readonly Span[],
61
+ * folds?: readonly Range[] | undefined,
62
+ * hunks: readonly string[],
63
+ * }} FoldedFile
64
+ */
65
+
66
+ /**
67
+ * Whether `inner` sits wholly inside `outer` without being the same range. The validator allows
68
+ * this only when the inner fold has the lower level; the page then draws the outer one alone.
69
+ * @param {Span} outer
70
+ * @param {Span} inner
71
+ */
72
+ export function contains(outer, inner) {
73
+ return (
74
+ outer !== inner &&
75
+ outer.side === inner.side &&
76
+ outer.startLine <= inner.startLine &&
77
+ inner.endLine <= outer.endLine &&
78
+ (outer.startLine < inner.startLine || inner.endLine < outer.endLine)
79
+ )
80
+ }
81
+
82
+ /**
83
+ * The folds that hide at `level`, with every fold nested inside another kept fold dropped. Only
84
+ * the outermost fold is drawn, so raising the level replaces a set of small titles with one.
85
+ * @template {Range} T
86
+ * @param {readonly T[]} folds
87
+ * @param {FoldLevel} level
88
+ * @returns {T[]}
89
+ */
90
+ export function foldsForLevel(folds, level) {
91
+ const applicable = folds.filter(fold => hidesAt(fold.level, level))
92
+ return applicable.filter(fold => !applicable.some(other => contains(other, fold)))
93
+ }
94
+
95
+ /**
96
+ * Whether a file's whole body starts hidden at this level. An annotated file never collapses: a
97
+ * fold over an annotation still shows the annotation's text, a collapsed file shows only its path.
98
+ * @param {FoldedFile} file
99
+ * @param {FoldLevel} level
100
+ * @returns {boolean}
101
+ */
102
+ export function collapsesAt(file, level) {
103
+ return file.collapsed !== undefined && hidesAt(file.collapsed, level) && file.annotations.length === 0
104
+ }
105
+
106
+ /**
107
+ * Rows a set of ranges covers, each row counted once where ranges nest or touch. The one count
108
+ * the page's counters and the validator's thresholds share.
109
+ * @param {readonly Span[]} ranges
110
+ * @returns {number}
111
+ */
112
+ export function coveredRows(ranges) {
113
+ let total = 0
114
+ for (const side of /** @type {const} */ (['new', 'old'])) {
115
+ const sorted = ranges
116
+ .filter(range => range.side === side)
117
+ .map(range => ({ start: range.startLine, end: range.endLine }))
118
+ .sort((a, b) => a.start - b.start)
119
+ /** @type {{ start: number, end: number } | null} */
120
+ let current = null
121
+ for (const range of sorted) {
122
+ if (current !== null && range.start <= current.end + 1) {
123
+ current.end = Math.max(current.end, range.end)
124
+ continue
125
+ }
126
+ total += current === null ? 0 : current.end - current.start + 1
127
+ current = { ...range }
128
+ }
129
+ total += current === null ? 0 : current.end - current.start + 1
130
+ }
131
+ return total
132
+ }
133
+
134
+ /**
135
+ * Rows a file's hunks draw, near enough for a counter: context lines are shared by both sides,
136
+ * so the longer side is the row count when nothing else is known about the patch.
137
+ * @param {FoldedFile} file
138
+ * @param {ReadonlyArray<{ id: string, oldLines: number, newLines: number }>} hunks every hunk of the file
139
+ * @returns {number}
140
+ */
141
+ export function fileRows(file, hunks) {
142
+ return hunks
143
+ .filter(hunk => file.hunks.includes(hunk.id))
144
+ .reduce((sum, hunk) => sum + Math.max(hunk.oldLines, hunk.newLines), 0)
145
+ }
146
+
147
+ /**
148
+ * What the review's discussion keeps open in one file card: `keepsOpen` when a comment or an
149
+ * attention point stops the card collapsing, `discussed` when a thread or a pending draft in its
150
+ * chunks stops every fold. The page decides both once per card, and the card and its counter read
151
+ * the answer.
152
+ * @typedef {{ keepsOpen: boolean, discussed: boolean }} Discussion
153
+ */
154
+
155
+ /** A file nobody has discussed yet, as the validator sees every file of a fresh generation. */
156
+ export const UNDISCUSSED = /** @type {Discussion} */ ({ keepsOpen: false, discussed: false })
157
+
158
+ /**
159
+ * How many diff lines a file shows, and how many of them `level` hides. Measured from the model,
160
+ * so the counter reads the same before and after a card draws its diff.
161
+ * @param {FoldedFile} file
162
+ * @param {ReadonlyArray<{ id: string, oldLines: number, newLines: number }>} hunks every hunk of the file
163
+ * @param {FoldLevel} level
164
+ * @param {Discussion} discussion
165
+ * @returns {{ total: number, hidden: number }}
166
+ */
167
+ export function hiddenLines(file, hunks, level, discussion) {
168
+ const total = fileRows(file, hunks)
169
+ if (collapsesAt(file, level) && !discussion.keepsOpen) {
170
+ return { total, hidden: total }
171
+ }
172
+ if (discussion.discussed) {
173
+ return { total, hidden: 0 }
174
+ }
175
+ return { total, hidden: coveredRows(foldsForLevel(file.folds ?? [], level)) }
176
+ }
@@ -3,8 +3,11 @@
3
3
  /** @typedef {import('./contract-types.js').RiskTag} RiskTag */
4
4
  import { setDisabledReason } from './composer.js'
5
5
  import { esc, timeAgo } from './dom.js'
6
- import { refreshRail } from './layers.js'
6
+ import { authorProfileUrl, currentHost, hostLabel } from './host.js'
7
+ import { canvasHiddenLines, getFoldLevel, refreshRail } from './layers.js'
8
+ import { pendingBarHtml, pendingCount } from './pending.js'
7
9
  import { progressSummary } from './progress.js'
10
+ import { foldLevelControlHtml } from './reading-level.js'
8
11
  import { approveBlockedReason } from './signoff.js'
9
12
  import { skinLabel } from './skin.js'
10
13
  import { themeLabel } from './theme.js'
@@ -41,6 +44,19 @@ export function largePrNoticeHtml(bundle) {
41
44
  )
42
45
  }
43
46
 
47
+ /**
48
+ * The author, linked to their page on the forge. A local review's author is the name this clone
49
+ * commits as, which is no forge login, so it stays plain text.
50
+ * @param {string} author
51
+ * @param {boolean} local
52
+ * @returns {string}
53
+ */
54
+ function authorHtml(author, local) {
55
+ return local
56
+ ? esc(author)
57
+ : `<a href="${esc(authorProfileUrl(author))}" target="_blank" rel="noopener noreferrer">${esc(author)}</a>`
58
+ }
59
+
44
60
  /** @param {PrBundle['pr']} pr */
45
61
  export function statePill(pr) {
46
62
  const state = pr.state === 'open' && pr.draft ? 'draft' : pr.state
@@ -61,47 +77,81 @@ export function renderHeader(bundle, opts) {
61
77
  ? `<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
78
  : ''
63
79
  const number = pr.number === null ? '' : `<span class="mono num">#${pr.number}</span>`
64
- const progress = ready ? progressHtml(artifact, bundle.state) : ''
80
+ // Work that is not pushed has no page on the forge, and nothing to refresh from it either.
81
+ const local = bundle.local !== undefined
82
+ const forgeLink =
83
+ pr.url === ''
84
+ ? ''
85
+ : `<a class="cmd" href="${esc(pr.url)}" target="_blank" rel="noopener noreferrer">${esc(currentHost().kind)}</a>`
86
+ const refreshTitle = local
87
+ ? 'Snapshot the working tree again and redraw'
88
+ : `Fetch the latest PR, comments, and shared canvas from ${esc(hostLabel())}`
89
+ const progress = ready ? progressHtml(artifact, bundle.state, pr.headSha) : ''
90
+ const level = getFoldLevel()
91
+ const reading = ready
92
+ ? foldLevelControlHtml(
93
+ level,
94
+ canvasHiddenLines(
95
+ {
96
+ artifact,
97
+ files: bundle.files,
98
+ comments: bundle.comments.reviewComments,
99
+ state: bundle.state,
100
+ headSha: pr.headSha,
101
+ },
102
+ level
103
+ )
104
+ )
105
+ : ''
65
106
  const risk = ready ? riskLineHtml(artifact.risk) : ''
66
107
  return (
67
108
  '<header class="hdr">' +
68
109
  `<div class="hdr-bar"><div class="brand"><span class="brand-wordmark"><img class="brand-icon" src="/static/brand.svg" width="32" height="32" alt="">PR review canvas</span><span class="mono muted">${esc(opts.host)}</span></div>` +
69
110
  '<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>` +
111
+ `<button class="cmd" type="button" id="regenerate" title="Generate a new canvas for ${local ? 'this local work' : 'this PR'}" aria-haspopup="dialog"${hasCanvas ? '' : ' disabled'}>regenerate</button>` +
112
+ `<button class="cmd" type="button" id="export-zip" title="Download this canvas as a zip to share on ${esc(hostLabel())}"${hasCanvas ? '' : ' disabled'}>export zip</button>` +
113
+ `<button class="cmd" type="button" id="refresh" title="${refreshTitle}">refresh</button>` +
114
+ '<button class="cmd" type="button" id="settings" data-act="settings" aria-haspopup="dialog" title="Configure the default reading level and the AI chat agent, model, and limits">settings</button>' +
74
115
  '<button class="cmd" type="button" data-act="help" title="Show keyboard shortcuts and review help" aria-haspopup="dialog">help</button>' +
75
116
  `<button class="cmd" type="button" id="skin-toggle" title="Switch between Terminal and GitHub styling">${esc(skinLabel(opts.skin))}</button>` +
76
117
  `<button class="cmd" type="button" id="theme-toggle" title="Switch between Light, Dark, and Auto themes">${esc(themeLabel(opts.theme))}</button>` +
77
118
  '</div></div>' +
78
119
  '<div class="stripe" aria-hidden="true"></div>' +
79
120
  '<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>` +
121
+ `<div class="title"><h1>${number}${esc(pr.title)}</h1>${forgeLink}</div>` +
122
+ `<p class="meta"><span>by ${authorHtml(pr.author, local)}</span>` +
82
123
  `<span class="mono">${esc(pr.headRef)} &rarr; ${esc(pr.baseRef)}</span>${statePill(pr)}` +
83
124
  `<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>`
125
+ `${largePrNoticeHtml(bundle)}${risk}${reading}${progress}</div></header>`
85
126
  )
86
127
  }
87
128
 
88
129
  /**
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.
130
+ * The thin line, its text, the pending-review bar, and the three sign-off commands. Approve stays
131
+ * disabled with the reason until every layer that is not Other has been marked reviewed; a
132
+ * comment-only review and a request for changes are always allowed, since neither claims the
133
+ * change set was read in full.
91
134
  * @param {import('./contract-types.js').ReviewArtifact} artifact
92
135
  * @param {import('./contract-types.js').PrState} state
93
136
  */
94
- export function progressHtml(artifact, state) {
137
+ export function progressHtml(artifact, state, headSha = artifact.pr.headSha) {
95
138
  const p = progressSummary(artifact, state)
96
139
  const blocked = approveBlockedReason(artifact, state)
140
+ const approveTitle = `Write and preview an approving review on ${esc(hostLabel())}`
97
141
  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>`
142
+ `<button class="cmd" type="button" id="approve" data-tooltip="${approveTitle}" data-act="signoff" data-event="APPROVE" data-needs-post` +
143
+ `${blocked === null ? ` title="${approveTitle}"` : ` disabled data-disabled-reason="${esc(blocked)}" title="${esc(blocked)}"`}>approve on ${esc(currentHost().kind)}</button>`
144
+ const commentTitle = `Write and preview a review with no verdict on ${esc(hostLabel())}`
100
145
  return (
101
146
  `<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
147
  `<span class="ptext">${p.done} of ${p.total} layers reviewed</span></div>` +
148
+ `<div class="pending-bar-host${pendingCount(state) > 0 ? ' has-pending' : ''}">${pendingBarHtml(
149
+ pendingCount(state),
150
+ state.pending.filter(draft => draft.headSha !== headSha)
151
+ )}</div>` +
103
152
  `<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>' +
153
+ `<button class="cmd" type="button" id="request-changes" data-tooltip="Write and preview a review requesting changes on ${esc(hostLabel())}" title="Write and preview a review requesting changes on ${esc(hostLabel())}" data-act="signoff" data-event="REQUEST_CHANGES" data-needs-post>request changes</button>` +
154
+ `<button class="cmd" type="button" id="comment-review" data-tooltip="${commentTitle}" title="${commentTitle}" data-act="signoff" data-event="COMMENT" data-needs-post>comment</button>` +
105
155
  '<span class="capability-note" role="status"></span></div>'
106
156
  )
107
157
  }
@@ -0,0 +1,40 @@
1
+ // @ts-check
2
+ // The forge this server talks to, as the page shell's bootstrap names it. One server serves one
3
+ // host, so the words and links that depend on it are read from here rather than threaded through
4
+ // every renderer.
5
+ /** @typedef {import('./contract-types.js').PublicHost} PublicHost */
6
+
7
+ /** @type {PublicHost} */
8
+ const DEFAULT_HOST = { kind: 'github', label: 'GitHub', webBase: 'https://github.com' }
9
+
10
+ /** @type {PublicHost} */
11
+ let current = DEFAULT_HOST
12
+
13
+ /** @param {PublicHost | undefined} host */
14
+ export function setHost(host) {
15
+ current = host ?? DEFAULT_HOST
16
+ }
17
+
18
+ export function currentHost() {
19
+ return current
20
+ }
21
+
22
+ /** `GitHub` or `GitLab`, for sentences. */
23
+ export function hostLabel() {
24
+ return current.label
25
+ }
26
+
27
+ /** `post to github` / `post to gitlab`, the command label. */
28
+ export function postToLabel() {
29
+ return `post to ${current.kind}`
30
+ }
31
+
32
+ /** What a command says when this login may not post. */
33
+ export function noPostingTitle() {
34
+ return `this ${current.label} login cannot post on this repository`
35
+ }
36
+
37
+ /** @param {string} author */
38
+ export function authorProfileUrl(author) {
39
+ return `${current.webBase}/${author}`
40
+ }
@@ -56,7 +56,7 @@ export function wireDropZone(root, opts) {
56
56
  }
57
57
  }
58
58
  const send = async (/** @type {File} */ file) => {
59
- const problem = validateCanvasFilename(file.name)
59
+ const problem = validateCanvasFilename(file.name, opts.prNumber)
60
60
  if (problem !== null) {
61
61
  apply({ type: 'fail', message: problem })
62
62
  return